diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index db038c77ed3..9a160345fe8 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -49,6 +49,10 @@ "tests": 5, "electron": "Git changes re-read on native window focus; native PTY ownership survives Session navigation and renderer replacement, while Stop/exit cross preload/main into the terminal view; Side Chat owns a Host fork lifecycle; first send reaches the Host; the Trace launcher opens a workbar whose per-Session visibility survives renderer reload." }, + "settings-pricing.spec.ts": { + "tests": 1, + "electron": "the installed contextBridge, main IPC handlers and selected-Host adapter carry Pricing writes and return authoritative values through a fresh preload after renderer reload; injected services and handler fakes cannot detect missing registrations, incorrect Host scope or serialization loss. Picker, tabs and focus are covered by the existing Pricing and Settings Storybook plays" + }, "settings.spec.ts": { "tests": 2, "electron": "Code Mode reads back Host settings through IPC; opening Settings commits a pending title rename to the Host, with workbar hide/restore asserted in that same Session." diff --git a/apps/desktop/e2e/settings-pricing.spec.ts b/apps/desktop/e2e/settings-pricing.spec.ts new file mode 100644 index 00000000000..6ad54c489c2 --- /dev/null +++ b/apps/desktop/e2e/settings-pricing.spec.ts @@ -0,0 +1,68 @@ +/* + * 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 { ensureSidebarExpanded, expect, test } from './fixtures'; + +// Verifies the installed Pricing contextBridge, IPC handlers and selected-Host +// adapter together. A missing handler, wrong Host scope or lost mutation/result +// serialization is invisible to injected Storybook services and handler fakes. +// The saved override must be readable through a new preload after renderer reload. +// Picker, tab scope and focus contracts live in the existing Pricing stories. +test('pricing writes cross preload and main and remain readable after renderer reload', async ({ + window: page, +}) => { + await ensureSidebarExpanded(page); + await page.getByRole('button', { name: '设置' }).click(); + await page.getByRole('button', { name: '使用统计', exact: true }).click(); + await page + .getByRole('navigation', { name: '使用统计视图' }) + .getByRole('button', { name: '定价配置', exact: true }) + .click(); + + const modelKey = `e2e:pricing-ipc-${Date.now()}`; + const pricing = { modelKey, inputUsdPer1M: 1, outputUsdPer1M: 2 }; + const host = await page.evaluate(() => window.maka.runtimeHostProfiles.getDefaultHost()); + await page.getByRole('button', { name: '添加定价' }).click(); + const editor = page.getByRole('dialog', { name: '添加定价' }); + await editor.getByRole('button', { name: '模型不在列表中?手动输入' }).click(); + await editor.getByRole('textbox', { name: /模型键/ }).fill(modelKey); + await editor.getByRole('textbox', { name: /输入价格/ }).fill('1'); + await editor.getByRole('textbox', { name: /输出价格/ }).fill('2'); + await editor.getByRole('button', { name: '保存' }).click(); + await expect.poll(() => page.evaluate(async ({ host, modelKey }) => { + const snapshot = await window.maka.settings.pricing.load(host); + return snapshot.entries.find((row) => row.pricing.modelKey === modelKey)?.pricing; + }, { host, modelKey })).toEqual(pricing); + + await page.reload(); + await expect.poll(() => page.evaluate(async ({ host, modelKey }) => { + const snapshot = await window.maka.settings.pricing.load(host); + return snapshot.entries.find((row) => row.pricing.modelKey === modelKey)?.pricing; + }, { host, modelKey })).toEqual(pricing); + + const removed = await page.evaluate(async ({ host, modelKey }) => { + const base = await window.maka.settings.pricing.load(host); + return window.maka.settings.pricing.mutate(base, { kind: 'delete', modelKey }, host); + }, { host, modelKey }); + expect(removed.kind).toBe('saved'); + await expect.poll(() => page.evaluate(async ({ host, modelKey }) => { + const snapshot = await window.maka.settings.pricing.load(host); + return snapshot.entries.some((row) => row.pricing.modelKey === modelKey); + }, { host, modelKey })).toBe(false); +}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index f3a9c66c71b..0be04b892cd 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -53,6 +53,7 @@ "src/renderer/locales/settings-memory-copy.ts", "src/renderer/locales/settings-navigation-copy.ts", "src/renderer/locales/settings-preferences-copy.ts", + "src/renderer/locales/settings-pricing-copy.ts", "src/renderer/locales/settings-projects-copy.ts", "src/renderer/locales/settings-shared-copy.ts", "src/renderer/locales/settings-subagents-copy.ts", @@ -491,6 +492,11 @@ "TaskEntryServicesProvider" ] }, + "src/renderer/features/usage/index.ts": { + "composition": [ + "UsagePricingServicesProvider" + ] + }, "src/renderer/features/workbar/index.ts": { "appShell": [ "WorkbarHost", @@ -1328,6 +1334,15 @@ "@maka/core/ui-locale": 1 } }, + "src/renderer/locales/settings-pricing-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, "src/renderer/locales/settings-projects-copy.ts": { "bridgePaths": {}, "environmentCapabilities": {}, diff --git a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts index c727ae94de5..a4c9bf4f657 100644 --- a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts @@ -247,7 +247,6 @@ test('projects only present Usage Session ids into the Desktop host namespace', byProvider: [], byModel: [], byTool: [], - pricing: [], provenance: EMPTY_USAGE_PROVENANCE, }; diff --git a/apps/desktop/src/main/__tests__/pricing-editor.test.ts b/apps/desktop/src/main/__tests__/pricing-editor.test.ts new file mode 100644 index 00000000000..9bbb7f027ba --- /dev/null +++ b/apps/desktop/src/main/__tests__/pricing-editor.test.ts @@ -0,0 +1,1591 @@ +/* + * 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 { strict as assert } from 'node:assert'; +import { afterEach, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { StrictMode, act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import { createDefaultSettings } from '@maka/core/settings'; +import type { + DesktopPricingMutationInput, + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from '../../shared/desktop-pricing.js'; +import { + getPricingSettingsCopy, + PricingEditor, + UsagePricingServicesProvider, + UsageFeatureScope, + formatCache, + formatUsd, + type UsageHostRef, + type UsagePricingServices, +} from '../../renderer/features/usage/testing.js'; + +const copy = getPricingSettingsCopy('en'); + +const TEST_RUNTIME_HOST: UsageHostRef = { profileId: 'test-profile', hostId: 'test-host' }; + +const SNAPSHOT: DesktopPricingSnapshot = { + hostEpoch: 'epoch-1', + connectionId: 'conn-1', + revision: 5, + entries: [ + { source: 'builtin', pricing: { modelKey: 'openai:gpt-4o', inputUsdPer1M: 2.5, outputUsdPer1M: 10 } }, + { + source: 'custom', + resetEffect: 'restore_builtin', + pricing: { modelKey: 'anthropic:claude', inputUsdPer1M: 2, outputUsdPer1M: 12 }, + }, + ], +}; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + matchMedia: globalThis.matchMedia, + HTMLElement: globalThis.HTMLElement, + HTMLIFrameElement: globalThis.HTMLIFrameElement, + getComputedStyle: globalThis.getComputedStyle, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + CSS: (globalThis as { CSS?: unknown }).CSS, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +afterEach(() => { + Object.assign(globalThis, originalGlobals); +}); + +describe('PricingEditor', () => { + for (const action of ['edit', 'reset', 'add'] as const) { + it(`keeps the viewed CAS base when a background refresh finishes during ${action}`, async () => { + const refresh = deferred(); + const latest: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 6, + entries: SNAPSHOT.entries.map((row) => ({ + source: 'custom', + resetEffect: 'restore_builtin', + pricing: { ...row.pricing, inputUsdPer1M: 99 }, + })), + }; + let reads = 0; + const harness = await renderEditor({ + load: async () => ++reads === 1 ? SNAPSHOT : refresh.promise, + mutate: async () => ({ kind: 'review_required', reason: 'revision_conflict', snapshot: latest }), + }); + await clickWithoutSettling(buttonByLabel(harness.doc, copy.refresh)); + if (action === 'add') await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + else await click(buttonByLabel(harness.doc, action === 'edit' + ? copy.editAria('anthropic:claude') + : copy.resetAria('anthropic:claude'))); + await act(async () => refresh.resolve(latest)); + await click(buttonByText(openDialog(harness.doc)!, action === 'reset' ? copy.confirmReset : copy.save)); + + assert.equal(harness.mutations.length, 1, 'a stale Add reaches CAS instead of getting stuck as a duplicate'); + assert.deepEqual(harness.mutations[0]?.base, SNAPSHOT, 'refresh cannot silently authorize the newer revision'); + assert.match(openDialog(harness.doc)?.textContent ?? '', new RegExp(copy.conflictTitle)); + await act(async () => harness.root.unmount()); + }); + } + + it('retains input typed during a pending save and saves it only on the next submit', async () => { + const pending = deferred(); + const committed = { ...SNAPSHOT, revision: 6 }; + let writes = 0; + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ++writes === 1 ? pending.promise + : { kind: 'saved', disposition: 'committed', snapshot: committed }, + }); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await clickWithoutSettling(buttonByText(harness.doc, copy.save)); + await typeInput(inputByLabel(harness.doc, copy.inputLabel), '123'); + await act(async () => pending.resolve({ kind: 'saved', disposition: 'committed', snapshot: committed })); + + assert.ok(openDialog(harness.doc), 'the earlier save must not dismiss later input'); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '123'); + assert.equal(harness.mutations.length, 1); + await click(buttonByText(harness.doc, copy.save)); + assert.equal(harness.mutations.length, 2); + assert.deepEqual(harness.mutations[1]?.base, committed); + assert.deepEqual(harness.mutations[1]?.mutation, { + kind: 'upsert', pricing: { modelKey: 'anthropic:claude', inputUsdPer1M: 123, outputUsdPer1M: 12 }, + }); + await act(async () => harness.root.unmount()); + }); + + it('submits the latest text when input and submit happen before a render', async () => { + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'saved_refresh_failed', disposition: 'committed' }), + }); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + const input = inputByLabel(harness.doc, copy.inputLabel)!; + const form = openDialog(harness.doc)!.querySelector('form')!; + await act(async () => { + input.value = '7.25'; + reactProps(input).onChange?.({ target: input, defaultPrevented: false }); + form.dispatchEvent(new harness.doc.defaultView!.Event('submit', { bubbles: true, cancelable: true })); + }); + assert.deepEqual(harness.mutations[0]?.mutation, { + kind: 'upsert', pricing: { modelKey: 'anthropic:claude', inputUsdPer1M: 7.25, outputUsdPer1M: 12 }, + }); + await act(async () => harness.root.unmount()); + }); + + for (const outcome of ['saved_refresh_failed', 'reconciliation_unavailable'] as const) { + it(`retains later input when ${outcome} reconciles the submitted Add`, async () => { + const committed: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 6, + entries: SNAPSHOT.entries.map((row) => row.source === 'builtin' + ? { ...row, source: 'custom', resetEffect: 'restore_builtin' } : row), + }; + let reads = 0; + const harness = await renderEditor({ + load: async () => ++reads === 1 ? SNAPSHOT : committed, + mutate: async () => outcome === 'saved_refresh_failed' + ? { kind: outcome, disposition: 'committed' } + : { kind: outcome, reason: 'outcome_unknown' }, + }); + await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + await click(buttonByText(harness.doc, copy.save)); + await typeInput(inputByLabel(harness.doc, copy.inputLabel), '123'); + await click(buttonByText(openDialog(harness.doc)!, copy.refresh)); + + assert.ok(openDialog(harness.doc), 'matching the submitted intent cannot discard a newer draft'); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '123'); + assert.equal(openDialog(harness.doc)?.getAttribute('aria-label'), copy.editTitle, 'the saved Add becomes an Edit'); + await typeInput(inputByLabel(harness.doc, copy.modelKeyLabel), 'another:model'); + assert.equal(harness.mutations.length, 1, 'reconciliation does not replay a write'); + await click(buttonByText(harness.doc, copy.save)); + assert.equal(harness.mutations.length, 2, 'the retained draft can be submitted explicitly'); + assert.deepEqual(harness.mutations[1]?.base, committed); + assert.deepEqual(harness.mutations[1]?.mutation, { + kind: 'upsert', pricing: { modelKey: 'openai:gpt-4o', inputUsdPer1M: 123, outputUsdPer1M: 10 }, + }); + await act(async () => harness.root.unmount()); + }); + } + + it('retries a recovered draft load inside the modal without losing input', async () => { + const retry = deferred(); + const replacement = { ...SNAPSHOT, hostEpoch: 'epoch-2', revision: 1 }; + let reads = 0; + const harness = await renderEditor({ load: async () => { + reads += 1; + if (reads === 1) return SNAPSHOT; + if (reads === 2) throw new Error('Host temporarily unavailable'); + return retry.promise; + } }); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '7.25'); + await harness.hideView(); + await harness.rerender('replacement-host:epoch-2'); + const dialog = openDialog(harness.doc)!; + assert.match(dialog.textContent ?? '', new RegExp(copy.loadFailedTitle)); + assertButtonDisabled(buttonByText(dialog, copy.reviewHostChange)); + await clickWithoutSettling(buttonByText(dialog, copy.retry)); + assert.match(dialog.textContent ?? '', new RegExp(copy.loading)); + await act(async () => retry.resolve(replacement)); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '7.25'); + assertButtonEnabled(buttonByText(dialog, copy.reviewHostChange)); + assertButtonDisabled(buttonByText(dialog, copy.save)); + await act(async () => harness.root.unmount()); + }); + + for (const text of ['7.25', '7.']) { + it(`preserves unblurred rate text ${text} across a Host gate remount`, async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await typeInput(inputByLabel(harness.doc, copy.inputLabel), text); + await harness.hideView(); + await harness.rerender('replacement-host:epoch-2'); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, text); + assertButtonDisabled(buttonByText(harness.doc, copy.save)); + await act(async () => harness.root.unmount()); + }); + } + + it('reviews a recovered Add against an existing override and preserves its rates', async () => { + const latest: DesktopPricingSnapshot = { + ...SNAPSHOT, + hostEpoch: 'epoch-2', + revision: 1, + entries: SNAPSHOT.entries.map((row) => row.source === 'builtin' + ? { source: 'custom', resetEffect: 'restore_builtin', pricing: { ...row.pricing, inputUsdPer1M: 99 } } + : row), + }; + let reads = 0; + const harness = await renderEditor({ + load: async () => ++reads === 1 ? SNAPSHOT : latest, + mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: latest }), + }); + await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + await harness.hideView(); + await harness.rerender('replacement-host:epoch-2'); + assert.ok(openDialog(harness.doc)?.textContent?.includes('$99'), 'review shows the replacement Host price'); + await click(buttonByText(harness.doc, copy.reviewHostChange)); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '2.5'); + await click(buttonByText(harness.doc, copy.save)); + assert.equal(harness.mutations.length, 1, 'reviewed Add must not remain duplicate-blocked'); + assert.deepEqual(harness.mutations[0]?.base, latest); + assert.deepEqual(harness.mutations[0]?.mutation, { + kind: 'upsert', pricing: { modelKey: 'openai:gpt-4o', inputUsdPer1M: 2.5, outputUsdPer1M: 10 }, + }); + await act(async () => harness.root.unmount()); + }); + + it('rejects a negative rate without clamping it to a free price', async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '-5'); + await click(buttonByText(harness.doc, copy.save)); + assert.equal(harness.mutations.length, 0); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '-5'); + assert.ok(openDialog(harness.doc)?.textContent?.includes(copy.errorInvalidRate)); + await act(async () => harness.root.unmount()); + }); + + it('renders only the user overrides; built-ins are catalog-only', async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + // The custom override is listed with its 自定义 source label. + assert.match(harness.container.textContent ?? '', /anthropic:claude/); + assert.match(harness.container.textContent ?? '', new RegExp(copy.sourceCustomFallback)); + // The built-in is NOT rendered as a table row — it is reachable only through + // the Add flow's catalog picker — nor does the 内置 source label appear. + assert.doesNotMatch(harness.container.textContent ?? '', /openai:gpt-4o/); + assert.doesNotMatch(harness.container.textContent ?? '', /Built-in/); + assert.equal(harness.loadCalls(), 1); + // The Pricing tab loads against the settings-SELECTED Host it was handed — + // not the app's active Host (an omitted arg) — so it stays in lockstep with + // the rest of the settings page. + assert.deepEqual(harness.loadHosts, [TEST_RUNTIME_HOST]); + await act(async () => harness.root.unmount()); + }); + + it('the Add dialog offers a catalog picker with a manual-entry fallback', async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + await click(buttonByText(harness.doc, copy.add)); + // Catalog mode is the default: no free-text model-key input, plus a toggle to + // manual entry (inferred without depending on the Typeahead's internal DOM). + assert.equal( + inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), + undefined, + 'no free-text model-key input in catalog mode', + ); + const toManual = buttonByText(harness.doc, copy.manualEntryToggle); + assert.ok(toManual, 'manual-entry toggle present in catalog mode'); + // Switching to manual reveals one exact model-key input + a toggle + // back to the catalog. + await click(toManual); + assert.ok(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'manual model-key input'); + assert.ok(buttonByText(harness.doc, copy.catalogToggle), 'catalog toggle present in manual mode'); + await act(async () => harness.root.unmount()); + }); + + it('selecting a built-in catalog model saves an override', async () => { + const committed: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 6, + entries: [ + { + source: 'custom', + resetEffect: 'restore_builtin', + pricing: { + modelKey: 'openai:gpt-4o', + inputUsdPer1M: 2.5, + outputUsdPer1M: 10, + }, + }, + SNAPSHOT.entries[1]!, + ], + }; + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: committed }), + }); + await click(buttonByText(harness.doc, copy.add)); + await setInput(inputByPlaceholder(harness.doc, copy.catalogPickerPlaceholder), 'openai'); + const option = Array.from(harness.doc.querySelectorAll('[role="option"]')).find( + (item) => item.textContent?.includes('openai:gpt-4o'), + ); + assert.ok(option, 'catalog search exposes the built-in model'); + await clickElement(option); + await click(buttonByText(harness.doc, copy.save)); + + assert.equal(harness.mutations.length, 1); + assert.deepEqual(harness.mutations[0]?.mutation, { + kind: 'upsert', + pricing: { + modelKey: 'openai:gpt-4o', + inputUsdPer1M: 2.5, + outputUsdPer1M: 10, + }, + }); + await act(async () => harness.root.unmount()); + }); + + it('switching entry modes clears values that would otherwise be hidden', async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:hidden'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '1'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '2'); + await click(buttonByText(harness.doc, copy.catalogToggle)); + await click(buttonByText(harness.doc, copy.save)); + + assert.match(harness.container.textContent ?? '', new RegExp(copy.errorRequired)); + assert.equal(harness.mutations.length, 0); + await act(async () => harness.root.unmount()); + }); + + it('does not load host-scoped pricing when no Host is selected', async () => { + let loadInvoked = false; + const harness = await renderEditor({ + host: null, + load: async () => { + loadInvoked = true; + return SNAPSHOT; + }, + }); + // No selected Host: the controller resolves an empty state instead of + // reaching the bridge (which would otherwise fall back to the active Host). + assert.equal(loadInvoked, false); + assert.equal(harness.loadCalls(), 0); + // Without a loaded snapshot there is no CAS base, so Add is disabled rather + // than opening an editor whose save would silently no-op. + assert.equal(buttonByText(harness.doc, copy.add)?.getAttribute('aria-disabled'), 'true'); + await act(async () => harness.root.unmount()); + }); + + it('keeps fresh authority when a StrictMode mount read settles after the replacement read', async () => { + const oldRead = deferred(); + const latest = { ...SNAPSHOT, revision: 6 }; + let reads = 0; + const harness = await renderEditor({ + strict: true, + load: async () => ++reads === 1 ? oldRead.promise : latest, + mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: latest }), + }); + assert.equal(harness.loadCalls(), 2, 'StrictMode restarts the initial load'); + await act(async () => oldRead.resolve({ ...SNAPSHOT, entries: [] })); + assert.match(harness.container.textContent ?? '', /anthropic:claude/); + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + assert.equal(harness.mutations[0]?.base.revision, 6); + await act(async () => harness.root.unmount()); + }); + + it('reset sends a delete against the loaded snapshot', async () => { + const committed: DesktopPricingSnapshot = { ...SNAPSHOT, revision: 6, entries: [SNAPSHOT.entries[0]!] }; + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: committed }), + }); + + const resetButton = buttonByLabel(harness.doc, copy.resetAria('anthropic:claude')); + assert.ok(resetButton, 'reset button is present for a custom-with-fallback row'); + await click(resetButton); + + const confirmButton = buttonByText(harness.doc, copy.confirmReset); + assert.ok(confirmButton, 'confirm dialog exposes the reset action'); + await click(confirmButton); + + assert.equal(harness.mutations.length, 1); + const mutation = harness.mutations[0]!; + // The renderer carries the snapshot it loaded as the CAS base — same revision + // and Host stamp — never a freshly reloaded latest. + assert.deepEqual(mutation.base, SNAPSHOT); + assert.deepEqual(mutation.mutation, { kind: 'delete', modelKey: 'anthropic:claude' }); + // The mutation targets the same settings-selected Host as the load. + assert.deepEqual(harness.mutateHosts, [TEST_RUNTIME_HOST]); + await act(async () => harness.root.unmount()); + }); + + it('moves focus to Add after a successful reset removes its trigger row', async () => { + const committed: DesktopPricingSnapshot = { ...SNAPSHOT, revision: 6, entries: [SNAPSHOT.entries[0]!] }; + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: committed }), + }); + const resetButton = buttonByLabel(harness.doc, copy.resetAria('anthropic:claude')); + const addButton = buttonByText(harness.doc, copy.add); + assert.ok(resetButton); + assert.ok(addButton); + let focused: HTMLElement | null = null; + resetButton.focus = () => { focused = resetButton; }; + addButton.focus = () => { focused = addButton; }; + + await click(resetButton); + await click(buttonByText(harness.doc, copy.confirmReset)); + + assert.equal(resetButton.isConnected, false, 'the reset trigger leaves with its override row'); + assert.ok(focused === addButton, 'the committed row removal falls back to the stable Add action'); + await act(async () => harness.root.unmount()); + }); + + it('a saved-but-refresh-failed outcome disables further writes', async () => { + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'saved_refresh_failed', disposition: 'committed' }), + }); + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + + assert.match(harness.container.textContent ?? '', new RegExp(copy.refreshFailedTitle)); + // #2015: the committed-but-unrefreshed list is now stale — it must not be + // shown as authoritative, so the previously-listed override is cleared until + // a successful refresh. + assert.doesNotMatch( + harness.container.querySelector('table')?.textContent ?? '', + /anthropic:claude/, + ); + assert.doesNotMatch(harness.container.textContent ?? '', new RegExp(copy.emptyTitle), + 'discarded authority must not claim that there are no overrides'); + const addButton = buttonByText(harness.doc, copy.add); + assert.ok(addButton); + // A disabled control that carries its reason via tooltip stays focusable and + // marks itself with aria-disabled rather than the native disabled attribute + // (DESIGN.md §Fields), so the write-block reason stays discoverable. + assert.equal(addButton.getAttribute('aria-disabled'), 'true'); + await act(async () => harness.root.unmount()); + }); + + it('keeps a saved reset reachable from its dialog until pricing refreshes', async () => { + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'saved_refresh_failed', disposition: 'committed' }), + }); + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + + const dialog = openDialog(harness.doc); + assert.ok(dialog, 'the pending reset stays reachable after refresh failure'); + assert.match(dialog.textContent ?? '', new RegExp(copy.refreshFailedTitle)); + assert.ok(buttonByText(dialog, copy.refresh), 'recovery is available inside the modal focus trap'); + assertButtonDisabled(buttonByText(dialog, copy.confirmReset)); + await act(async () => harness.root.unmount()); + }); + + it('keeps an upsert draft reachable and refreshes from inside the editor', async () => { + const committed: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 6, + entries: [ + ...SNAPSHOT.entries, + { + source: 'custom', + resetEffect: 'become_unpriced', + pricing: { modelKey: 'acme:draft', inputUsdPer1M: 1, outputUsdPer1M: 2 }, + }, + ], + }; + let loadCall = 0; + const harness = await renderEditor({ + load: async () => { + loadCall += 1; + return loadCall === 1 ? SNAPSHOT : committed; + }, + mutate: async () => ({ kind: 'saved_refresh_failed', disposition: 'committed' }), + }); + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:draft'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '1'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '2'); + await click(buttonByText(harness.doc, copy.save)); + + const dialog = openDialog(harness.doc); + assert.ok(dialog, 'the draft stays open after the committed refresh failure'); + assert.match(dialog.textContent ?? '', new RegExp(copy.refreshFailedTitle)); + assert.equal(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder)?.value, 'acme:draft'); + const refresh = buttonByText(dialog, copy.refresh); + assert.ok(refresh, 'recovery is available inside the modal focus trap'); + await click(refresh); + + assert.equal(openDialog(harness.doc) === undefined, true, 'a successful refresh completes the saved draft'); + assert.match(harness.container.textContent ?? '', /acme:draft/); + assert.equal(harness.loadCalls(), 2); + assert.equal(harness.mutations.length, 1, 'refresh never replays the upsert'); + await act(async () => harness.root.unmount()); + }); + + it('a reset conflict keeps the dialog and confirms again against fresh authority', async () => { + const latest: DesktopPricingSnapshot = { ...SNAPSHOT, revision: 9 }; + let calls = 0; + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => { + calls += 1; + return calls === 1 + ? { kind: 'review_required', reason: 'revision_conflict', snapshot: latest } + : { kind: 'saved', disposition: 'committed', snapshot: latest }; + }, + }); + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + + // The conflict is surfaced inside the modal focus trap and the action label + // changes before an explicit second confirm — the mutation is never replayed + // blindly while the warning sits behind the dialog. + const dialog = openDialog(harness.doc); + assert.ok(dialog, 'reset dialog stays open on conflict'); + assert.match(dialog.textContent ?? '', new RegExp(copy.conflictTitle)); + const confirmAgain = buttonByText(dialog, copy.reviewReset); + assert.ok(confirmAgain, 'the reset action calls out the required review'); + await click(confirmAgain); + + assert.equal(calls, 2); + // The second attempt carries the fresh authority (revision 9) as its base. + assert.equal(harness.mutations[1]?.base.revision, 9); + await act(async () => harness.root.unmount()); + }); + + it('an uncertain outcome blocks writes and explains the required recovery', async () => { + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }), + }); + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + + assert.match(harness.container.textContent ?? '', new RegExp(copy.reconcileTitle)); + assert.equal(buttonByText(harness.doc, copy.add)?.getAttribute('aria-disabled'), 'true'); + await act(async () => harness.root.unmount()); + }); + + it('reconciles an unavailable reset against the next authoritative refresh', async () => { + const latest: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 6, + entries: [ + SNAPSHOT.entries[0]!, + { + source: 'custom', + resetEffect: 'restore_builtin', + pricing: { + modelKey: 'anthropic:claude', + inputUsdPer1M: 7, + outputUsdPer1M: 8, + cacheReadUsdPer1M: 0, + cacheWriteUsdPer1M: 0.4, + }, + }, + ], + }; + let loadCall = 0; + const harness = await renderEditor({ + load: async () => { + loadCall += 1; + return loadCall === 1 ? SNAPSHOT : latest; + }, + mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }), + }); + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + assert.match(harness.container.textContent ?? '', new RegExp(copy.reconcileTitle)); + + const dialog = openDialog(harness.doc); + assert.ok(dialog, 'the unreconciled draft stays open'); + await click(buttonByText(dialog, copy.refresh)); + + const text = harness.container.textContent ?? ''; + assert.match(text, new RegExp(copy.conflictTitleUnknown)); + assert.match(text, new RegExp(copy.sourceCustomFallback)); + for (const value of ['$7', '$8', '$0', '$0.4']) assert.ok(text.includes(value)); + assert.equal(harness.mutations.length, 1, 'refresh reconciles without replaying the reset'); + await act(async () => harness.root.unmount()); + }); + + it('clears a cancelled reset conflict before opening another editor', async () => { + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'review_required', reason: 'revision_conflict', snapshot: SNAPSHOT }), + }); + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + await click(buttonByText(openDialog(harness.doc)!, copy.cancel)); + await click(buttonByText(harness.doc, copy.add)); + + const dialog = openDialog(harness.doc); + assert.ok(dialog); + assert.doesNotMatch(dialog.textContent ?? '', new RegExp(copy.conflictTitle)); + assert.ok(buttonByText(dialog, copy.save)); + await act(async () => harness.root.unmount()); + }); + + it('keeps a reset cancelled while its recovery read is pending closed', async () => { + const recovery = deferred(); + const latest = { ...SNAPSHOT, revision: 6 }; + let reads = 0; + const harness = await renderEditor({ + load: async () => ++reads === 1 ? SNAPSHOT : recovery.promise, + mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }), + }); + try { + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(openDialog(harness.doc)!, copy.confirmReset)); + await clickWithoutSettling(buttonByText(openDialog(harness.doc)!, copy.refresh)); + await click(buttonByText(openDialog(harness.doc)!, copy.cancel)); + assert.equal(openDialog(harness.doc) === undefined, true); + assertButtonDisabled(buttonByText(harness.doc, copy.add)); + + await act(async () => recovery.resolve(latest)); + + assert.equal(Boolean(openDialog(harness.doc)), false, 'a recovery result cannot undo Cancel'); + assertButtonEnabled(buttonByText(harness.doc, copy.add)); + assert.equal(harness.mutations.length, 1, 'the cancelled reset is still reconciled, never replayed'); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + assert.doesNotMatch(openDialog(harness.doc)?.textContent ?? '', new RegExp(copy.conflictTitleUnknown)); + assert.ok(buttonByText(openDialog(harness.doc)!, copy.save)); + } finally { + await act(async () => harness.root.unmount()); + } + }); + + it('does not carry a cancelled Add recovery into another model editor', async () => { + const latest = { ...SNAPSHOT, revision: 6 }; + let reads = 0; + const harness = await renderEditor({ + load: async () => ++reads === 1 ? SNAPSHOT : latest, + mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }), + }); + try { + await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + await click(buttonByText(openDialog(harness.doc)!, copy.save)); + await click(buttonByText(openDialog(harness.doc)!, copy.cancel)); + assertButtonDisabled(buttonByText(harness.doc, copy.add)); + await click(buttonByLabel(harness.doc, copy.refresh)); + assert.equal(harness.mutations.length, 1, 'refresh must not replay the cancelled Add'); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + + const dialog = openDialog(harness.doc)!; + assert.equal(inputByLabel(harness.doc, copy.modelKeyLabel)?.value, 'anthropic:claude'); + assert.doesNotMatch(dialog.textContent ?? '', new RegExp(copy.conflictTitleUnknown)); + await click(buttonByText(dialog, copy.save)); + assert.deepEqual(harness.mutations[1]?.base, latest); + assert.equal(harness.mutations[1]?.mutation.kind, 'upsert'); + } finally { + await act(async () => harness.root.unmount()); + } + }); + + for (const outcome of ['saved', 'synchronized', 'saved_refresh_failed', 'reconciliation_unavailable'] as const) { + for (const duplicate of [false, true]) { + it(`uses fresh authority for a changed model key after ${outcome}${duplicate ? ' and rejects a newly existing override' : ''}`, async () => { + const pending = deferred(); + const latest: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 6, + entries: [ + ...SNAPSHOT.entries, + { source: 'custom', resetEffect: 'become_unpriced', pricing: { modelKey: 'acme:first', inputUsdPer1M: 1, outputUsdPer1M: 2 } }, + ...(duplicate ? [{ source: 'custom' as const, resetEffect: 'become_unpriced' as const, pricing: { modelKey: 'acme:second', inputUsdPer1M: 99, outputUsdPer1M: 99 } }] : []), + ], + }; + const saved: DesktopPricingSnapshot = { + ...latest, + revision: 7, + entries: [...latest.entries, { source: 'custom', resetEffect: 'become_unpriced', pricing: { modelKey: 'acme:second', inputUsdPer1M: 7.25, outputUsdPer1M: 2 } }], + }; + let reads = 0; + let writes = 0; + const harness = await renderEditor({ + load: async () => ++reads === 1 ? SNAPSHOT : latest, + mutate: async (base) => { + if (++writes === 1) return pending.promise; + // Simulate the Host's revision-CAS check for the second model. + return base.revision === latest.revision + ? { kind: 'saved', disposition: 'committed', snapshot: saved } + : { kind: 'review_required', reason: 'revision_conflict', snapshot: latest }; + }, + }); + try { + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByLabel(harness.doc, copy.modelKeyLabel), 'acme:first'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '1'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '2'); + await clickWithoutSettling(buttonByText(harness.doc, copy.save)); + await typeInput(inputByLabel(harness.doc, copy.modelKeyLabel), 'acme:second'); + await typeInput(inputByLabel(harness.doc, copy.inputLabel), '7.25'); + await act(async () => pending.resolve( + outcome === 'saved' ? { kind: 'saved', disposition: 'committed', snapshot: latest } + : outcome === 'synchronized' ? { kind: 'synchronized', reason: 'outcome_unknown', snapshot: latest } + : outcome === 'saved_refresh_failed' ? { kind: 'saved_refresh_failed', disposition: 'committed' } + : { kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }, + )); + if (outcome === 'saved_refresh_failed' || outcome === 'reconciliation_unavailable') { + await click(buttonByText(openDialog(harness.doc)!, copy.refresh)); + } + assert.ok(openDialog(harness.doc), 'completion of the first model leaves the new draft open'); + assert.equal(inputByLabel(harness.doc, copy.modelKeyLabel)?.value, 'acme:second'); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '7.25'); + assert.equal(harness.mutations.length, 1, 'a fresh snapshot never replays the first write'); + await click(buttonByText(openDialog(harness.doc)!, copy.save)); + if (duplicate) { + assert.equal(harness.mutations.length, 1, 'the newly existing override must not be overwritten'); + assert.match(openDialog(harness.doc)?.textContent ?? '', new RegExp(copy.errorDuplicate)); + } else { + assert.equal(harness.mutations.length, 2); + assert.deepEqual(harness.mutations[1]?.base, latest); + assert.deepEqual(harness.mutations[1]?.mutation, { + kind: 'upsert', pricing: { modelKey: 'acme:second', inputUsdPer1M: 7.25, outputUsdPer1M: 2 }, + }); + assert.equal(openDialog(harness.doc) === undefined, true, 'the second save succeeds without a false conflict'); + } + } finally { + await act(async () => harness.root.unmount()); + } + }); + } + } + + it('does not apply an earlier model conflict after the pending Add changes keys', async () => { + const pending = deferred(); + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => pending.promise, + }); + try { + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByLabel(harness.doc, copy.modelKeyLabel), 'acme:first'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '1'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '2'); + await clickWithoutSettling(buttonByText(harness.doc, copy.save)); + await typeInput(inputByLabel(harness.doc, copy.modelKeyLabel), 'acme:second'); + await act(async () => pending.resolve({ + kind: 'review_required', reason: 'revision_conflict', snapshot: { ...SNAPSHOT, revision: 6 }, + })); + + const dialog = openDialog(harness.doc)!; + assert.equal(inputByLabel(harness.doc, copy.modelKeyLabel)?.value, 'acme:second'); + assert.doesNotMatch(dialog.textContent ?? '', new RegExp(copy.conflictTitle)); + assert.ok(buttonByText(dialog, copy.save), 'the new key does not review the earlier key conflict'); + assert.equal(harness.mutations.length, 1); + } finally { + await act(async () => harness.root.unmount()); + } + }); + + it('finishes an unavailable upsert when the refreshed authority matches exactly', async () => { + const committed: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 6, + entries: [ + { + source: 'custom', + resetEffect: 'restore_builtin', + pricing: { + modelKey: 'openai:gpt-4o', + inputUsdPer1M: 2.5, + outputUsdPer1M: 10, + }, + }, + SNAPSHOT.entries[1]!, + ], + }; + let loadCall = 0; + const harness = await renderEditor({ + load: async () => { + loadCall += 1; + return loadCall === 1 ? SNAPSHOT : committed; + }, + mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }), + }); + await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + await click(buttonByText(harness.doc, copy.save)); + const dialog = openDialog(harness.doc); + assert.ok(dialog, 'the unreconciled draft stays open'); + await click(buttonByText(dialog, copy.refresh)); + + assert.equal(openDialog(harness.doc) === undefined, true, 'the matched draft is complete'); + assert.equal(harness.mutations.length, 1, 'reconciliation never replays the upsert'); + await act(async () => harness.root.unmount()); + }); + + it('blocks form submission while an upsert still needs reconciliation', async () => { + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }), + }); + await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + await click(buttonByText(harness.doc, copy.save)); + assertButtonDisabled(buttonByText(harness.doc, copy.save)); + + await submitEditor(harness.doc); + + assert.equal(harness.mutations.length, 1, 'form submission must obey the same write blocker'); + const dialog = openDialog(harness.doc); + assert.ok(dialog, 'the unreconciled draft stays open'); + assert.match(dialog.textContent ?? '', /openai:gpt-4o/, 'the catalog selection stays visible'); + await act(async () => harness.root.unmount()); + }); + + it('does not mistake equal built-in rates for a reconciled custom override', async () => { + let loadCall = 0; + const harness = await renderEditor({ + load: async () => { + loadCall += 1; + return SNAPSHOT; + }, + mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }), + }); + await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + await click(buttonByText(harness.doc, copy.save)); + await click(buttonByLabel(harness.doc, copy.refresh)); + + assert.match(harness.container.textContent ?? '', new RegExp(copy.conflictTitleUnknown)); + assert.ok(openDialog(harness.doc), 'the draft remains open for explicit review'); + assert.equal(harness.mutations.length, 1); + await act(async () => harness.root.unmount()); + }); + + it('associates required-field errors with their controls after an empty save', async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + // Open the Add editor and submit it empty. + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.save)); + + // The required-field message renders, and at least one control is marked + // invalid — the DS wires aria-invalid + aria-describedby to the message, so + // the error is announced against its own field rather than floating free. + assert.match(harness.container.textContent ?? '', new RegExp(copy.errorRequired)); + const invalid = harness.doc.querySelector('[aria-invalid="true"]'); + assert.ok(invalid, 'an empty required field is marked aria-invalid'); + assert.ok( + invalid?.getAttribute('aria-describedby'), + 'the invalid field points at its error message via aria-describedby', + ); + + // No mutation is attempted while the draft is invalid. + assert.equal(harness.mutations.length, 0); + await act(async () => harness.root.unmount()); + }); + + for (const field of ['cacheRead', 'cacheWrite'] as const) { + it(`reveals a collapsed invalid ${field} field when saving`, async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + try { + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.cacheSection)); + const input = inputByLabel(harness.doc, field === 'cacheRead' ? copy.cacheReadLabel : copy.cacheWriteLabel); + await typeInput(input, 'invalid'); + await click(buttonByText(harness.doc, copy.cacheSection)); + await click(buttonByText(harness.doc, copy.save)); + + assert.equal(harness.mutations.length, 0); + assert.equal(input?.getAttribute('aria-invalid'), 'true'); + assert.equal(buttonByText(harness.doc, copy.cacheSection)?.getAttribute('aria-expanded'), 'true', + 'the field preventing save must be revealed'); + } finally { + await act(async () => harness.root.unmount()); + } + }); + } + + it('a Host generation change preserves the draft and requires review after reloading authority (P1.1)', async () => { + const nextSnapshot: DesktopPricingSnapshot = { ...SNAPSHOT, hostEpoch: 'epoch-2', revision: 1 }; + let loadCall = 0; + const harness = await renderEditor({ + load: async () => { + loadCall += 1; + return loadCall === 1 ? SNAPSHOT : nextSnapshot; + }, + mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: nextSnapshot }), + }); + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:new'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '1'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '2'); + // A Host generation bump (new epoch) re-renders with a new generationKey. + await harness.rerender(`${TEST_RUNTIME_HOST.profileId}:${TEST_RUNTIME_HOST.hostId}:e2`); + // The draft remains visible, but the old mutation base is discarded. Even + // after the fresh snapshot loads, save stays disabled until explicit review. + assert.equal(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder)?.value, 'acme:new'); + assert.equal(harness.loadCalls(), 2, 'a fresh authority reload ran'); + assert.match(harness.container.textContent ?? '', new RegExp(copy.hostChangedTitle)); + assertButtonDisabled(buttonByText(harness.doc, copy.save)); + await submitEditor(harness.doc); + assert.equal(harness.mutations.length, 0, 'form submission cannot bypass Host review'); + await click(buttonByText(harness.doc, copy.reviewHostChange)); + assertButtonEnabled(buttonByText(harness.doc, copy.save)); + await click(buttonByText(harness.doc, copy.save)); + assert.equal(harness.mutations.length, 1); + assert.deepEqual(harness.mutations[0]?.base, nextSnapshot); + await act(async () => harness.root.unmount()); + }); + + it('does not allow Host-change review until fresh authority loads', async () => { + const secondLoad = deferred(); + let loadCall = 0; + const harness = await renderEditor({ + load: async () => { + loadCall += 1; + return loadCall === 1 ? SNAPSHOT : secondLoad.promise; + }, + }); + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:new'); + await harness.rerender(`${TEST_RUNTIME_HOST.profileId}:${TEST_RUNTIME_HOST.hostId}:e2`); + + assert.match( + harness.container.querySelector('table')?.getAttribute('aria-label') ?? '', + new RegExp(copy.loading), + 'the replacement authority renders as loading immediately', + ); + assertButtonDisabled(buttonByText(harness.doc, copy.reviewHostChange)); + assertButtonDisabled(buttonByText(harness.doc, copy.save)); + await act(async () => { + secondLoad.resolve({ ...SNAPSHOT, hostEpoch: 'epoch-2', revision: 1 }); + await Promise.resolve(); + await Promise.resolve(); + }); + assertButtonEnabled(buttonByText(harness.doc, copy.reviewHostChange)); + assertButtonDisabled(buttonByText(harness.doc, copy.save)); + await act(async () => harness.root.unmount()); + }); + + it('preserves the manual draft across the Settings Host gate unmount and rejects its old save', async () => { + const previousSave = deferred(); + const replacement = { ...SNAPSHOT, hostEpoch: 'epoch-2', connectionId: 'conn-2', revision: 1 }; + let reads = 0; + let writes = 0; + const harness = await renderEditor({ + load: async () => ++reads === 1 ? SNAPSHOT : replacement, + mutate: async () => ++writes === 1 + ? previousSave.promise + : { kind: 'saved', disposition: 'committed', snapshot: replacement }, + }); + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:retained'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '1.25'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '2.75'); + await clickWithoutSettling(buttonByText(harness.doc, copy.save)); + await harness.hideView(); + assert.equal(openDialog(harness.doc) === undefined, true, 'the Settings gate actually unmounts the view'); + await harness.rerender('replacement-host:epoch-2'); + await act(async () => previousSave.resolve({ kind: 'saved', disposition: 'committed', snapshot: SNAPSHOT })); + + assert.equal(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder)?.value, 'acme:retained'); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '1.25'); + assert.equal(inputByLabel(harness.doc, copy.outputLabel)?.value, '2.75'); + assertButtonDisabled(buttonByText(harness.doc, copy.save)); + await click(buttonByText(harness.doc, copy.reviewHostChange)); + await click(buttonByText(harness.doc, copy.save)); + assert.equal(harness.mutations.length, 2); + assert.deepEqual(harness.mutations[1]?.base, replacement); + await act(async () => harness.root.unmount()); + }); + + it('ignores an old-Host save result after the generation changes', async () => { + const pendingSave = deferred(); + let loadCall = 0; + const harness = await renderEditor({ + load: async () => { + loadCall += 1; + return loadCall === 1 + ? SNAPSHOT + : { ...SNAPSHOT, hostEpoch: 'epoch-2', revision: 1 }; + }, + mutate: async () => pendingSave.promise, + }); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '3'); + await click(buttonByText(harness.doc, copy.save)); + await harness.rerender(`${TEST_RUNTIME_HOST.profileId}:${TEST_RUNTIME_HOST.hostId}:e2`); + await act(async () => { + pendingSave.resolve({ + kind: 'saved', + disposition: 'committed', + snapshot: { ...SNAPSHOT, revision: 6 }, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + assert.match(harness.container.textContent ?? '', new RegExp(copy.hostChangedTitle)); + assert.equal(buttonByText(harness.doc, copy.save)?.textContent, copy.save); + assertButtonDisabled(buttonByText(harness.doc, copy.save)); + await act(async () => harness.root.unmount()); + }); + + it('ignores an old-Host save result when the Host event fences it before React re-renders', async () => { + const pendingSave = deferred(); + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => pendingSave.promise, + }); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await clickWithoutSettling(buttonByText(harness.doc, copy.save)); + assert.equal(harness.mutations.length, 1, 'the save is in flight before the Host fence'); + assert.ok(buttonByText(harness.doc, copy.cancel), 'the edit dialog starts open'); + + // The settings Host event arrives before React can commit the replacement + // generation. Its synchronous fence must already make the in-flight result + // stale; otherwise this old Host can close the draft and claim success. + harness.fenceTarget(); + await act(async () => { + pendingSave.resolve({ + kind: 'saved', + disposition: 'committed', + snapshot: { ...SNAPSHOT, revision: 6 }, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + assert.ok(buttonByText(harness.doc, copy.cancel), 'the old-Host result must not close the preserved draft'); + await act(async () => harness.root.unmount()); + }); + + it('does not dispatch a write after the Host event fences the still-visible editor', async () => { + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'saved_refresh_failed', disposition: 'committed' }), + }); + await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + harness.fenceTarget(); + await click(buttonByText(harness.doc, copy.save)); + + assert.equal(harness.mutations.length, 0, 'the stale Host must receive no write'); + assert.ok(openDialog(harness.doc), 'the draft survives until the replacement Host renders'); + await act(async () => harness.root.unmount()); + }); + + it('submits once when the form is submitted twice before a render', async () => { + const pending = deferred(); + const harness = await renderEditor({ load: async () => SNAPSHOT, mutate: async () => pending.promise }); + await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + const form = openDialog(harness.doc)?.querySelector('form'); + assert.ok(form); + await act(async () => { + const EventClass = harness.doc.defaultView!.Event; + form.dispatchEvent(new EventClass('submit', { bubbles: true, cancelable: true })); + form.dispatchEvent(new EventClass('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + }); + assert.equal(harness.mutations.length, 1); + await act(async () => pending.resolve({ kind: 'saved', disposition: 'unchanged', snapshot: SNAPSHOT })); + await act(async () => harness.root.unmount()); + }); + + it('a reload landing after a mutation does not overwrite the committed authority (P1.2)', async () => { + // The reset commits a claude-less authority; a refresh started earlier is + // still in flight and will resolve with the PRE-reset snapshot. + const committed: DesktopPricingSnapshot = { ...SNAPSHOT, revision: 6, entries: [SNAPSHOT.entries[0]!] }; + const secondLoad = deferred(); + let loadCall = 0; + const harness = await renderEditor({ + load: async () => { + loadCall += 1; + return loadCall === 1 ? SNAPSHOT : secondLoad.promise; + }, + mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: committed }), + }); + // Kick off a manual refresh (reload #2) that stays pending. + await click(buttonByLabel(harness.doc, copy.refresh)); + // Reset the override; the mutation commits the fresh (claude-less) authority. + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + assert.doesNotMatch(harness.container.textContent ?? '', /anthropic:claude/, 'committed authority shown'); + // The stale in-flight refresh resolves with the pre-reset snapshot — it must + // be fenced, not resurrect the deleted override. + await act(async () => { + secondLoad.resolve(SNAPSHOT); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.doesNotMatch( + harness.container.textContent ?? '', + /anthropic:claude/, + 'a stale reload must not overwrite the committed authority', + ); + await act(async () => harness.root.unmount()); + }); + + it('clears an earlier refresh failure when a save returns fresh authority', async () => { + const refresh = deferred(); + const committed = { ...SNAPSHOT, revision: 6 }; + let reads = 0; + const harness = await renderEditor({ + load: async () => ++reads === 1 ? SNAPSHOT : refresh.promise, + mutate: async () => ({ kind: 'saved', disposition: 'unchanged', snapshot: committed }), + }); + await click(buttonByLabel(harness.doc, copy.refresh)); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await act(async () => refresh.reject(new Error('read disconnected'))); + await click(buttonByText(harness.doc, copy.save)); + + assert.doesNotMatch(harness.container.textContent ?? '', new RegExp(copy.loadFailedTitle)); + assert.match(harness.container.textContent ?? '', /anthropic:claude/); + assertButtonEnabled(buttonByText(harness.doc, copy.add)); + await act(async () => harness.root.unmount()); + }); + + it('an Add conflict whose key now exists converts to Edit so the second save upserts (P1.3)', async () => { + const conflictLatest: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 7, + entries: [ + ...SNAPSHOT.entries, + { + source: 'custom', + resetEffect: 'become_unpriced', + pricing: { modelKey: 'acme:new', inputUsdPer1M: 9, outputUsdPer1M: 9 }, + }, + ], + }; + let calls = 0; + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => { + calls += 1; + return calls === 1 + ? { kind: 'review_required', reason: 'revision_conflict', snapshot: conflictLatest } + : { kind: 'saved', disposition: 'committed', snapshot: conflictLatest }; + }, + }); + // Add a brand-new key via the manual fallback. + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:new'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '1'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '2'); + // First save → conflict: the same key was added elsewhere. + await click(buttonByText(harness.doc, copy.save)); + assert.match(harness.container.textContent ?? '', new RegExp(copy.conflictTitle)); + // The Add converted to an Edit locked on the key, so the explicit second save + // upserts (not silently blocked by the duplicate check). + await click(buttonByText(harness.doc, copy.reviewSave)); + assert.equal(calls, 2, 'the second save was allowed'); + assert.equal(harness.mutations.length, 2); + assert.equal(harness.mutations[0]!.mutation.kind, 'upsert'); + assert.deepEqual(harness.mutations[1]!.base, conflictLatest); + const second = harness.mutations[1]!.mutation as { kind: 'upsert'; pricing: { modelKey: string } }; + assert.equal(second.pricing.modelKey, 'acme:new'); + await act(async () => harness.root.unmount()); + }); + + it('adding a built-in key that is not yet overridden is a new override, not a duplicate', async () => { + const committed: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 6, + entries: [ + ...SNAPSHOT.entries, + { + source: 'custom', + resetEffect: 'restore_builtin', + pricing: { modelKey: 'openai:gpt-4o', inputUsdPer1M: 5, outputUsdPer1M: 15 }, + }, + ], + }; + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: committed }), + }); + // Enter a key that exists as a BUILT-IN (openai:gpt-4o, catalog-only) but is + // not yet overridden. Overriding it is an upsert, not a duplicate — the + // catalog picker offers exactly these built-ins, so this must save (a full + // built-in ∪ overrides union check would wrongly block it). + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'openai:gpt-4o'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '5'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '15'); + await click(buttonByText(harness.doc, copy.save)); + + assert.doesNotMatch(harness.container.textContent ?? '', new RegExp(copy.errorDuplicate)); + assert.equal(harness.mutations.length, 1); + const mutation = harness.mutations[0]!.mutation as { kind: 'upsert'; pricing: { modelKey: string } }; + assert.equal(mutation.kind, 'upsert'); + assert.equal(mutation.pricing.modelKey, 'openai:gpt-4o'); + await act(async () => harness.root.unmount()); + }); + + it('rejects a key that already has a custom override as a duplicate', async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + // 'anthropic:claude' already has a custom override row — re-adding it is a + // duplicate; the user is told to edit the existing row instead. + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'anthropic:claude'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '1'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '2'); + await click(buttonByText(harness.doc, copy.save)); + + assert.match(harness.container.textContent ?? '', new RegExp(copy.errorDuplicate)); + assert.equal(harness.mutations.length, 0); + await act(async () => harness.root.unmount()); + }); +}); + +describe('pricing display formatting', () => { + const copy = getPricingSettingsCopy('en'); + + it('round-trips positive rates without collapsing to $0 or losing precision', () => { + assert.equal(formatUsd(2.5), '$2.5'); + assert.equal(formatUsd(10), '$10'); + // A small positive rate keeps its digits — never rounded to `$0`. + assert.equal(formatUsd(0.075), '$0.075'); + assert.equal(formatUsd(1.23456789), '$1.23456789'); + // An explicit zero rate (e.g. a free local model) is a real `$0`. + assert.equal(formatUsd(0), '$0'); + }); + + it('keeps an omitted cache rate distinct from an explicit zero', () => { + assert.equal(formatCache(undefined, copy), copy.cacheNotSet); + assert.equal(formatCache(0, copy), '$0'); + assert.equal(formatCache(0.3, copy), '$0.3'); + }); +}); + +async function renderEditor(options: { + load: () => Promise; + mutate?: ( + base: DesktopPricingSnapshot, + mutation: DesktopPricingMutationInput['mutation'], + ) => Promise; + // Omitted → the default selected Host; `null` → no Host selected. + host?: UsageHostRef | null; + strict?: boolean; +}) { + const { document, window } = parseHTML('
'); + const matchMedia = (media: string) => ({ + matches: false, + media, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => false, + }); + Object.assign(window, { matchMedia, scrollTo: () => {} }); + Object.assign(globalThis, { + document, + window, + matchMedia, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + getComputedStyle: (element: Element) => ({ + color: (element as HTMLElement).style?.color || 'currentColor', + // Astryx's logical-axis scrolling reads these browser defaults. + writingMode: 'horizontal-tb', + direction: 'ltr', + }) as CSSStyleDeclaration, + requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(callback, 0), + cancelAnimationFrame: (handle: number) => clearTimeout(handle), + // Astryx Dialog probes `CSS.supports` during layout; linkedom has no CSS. + CSS: { supports: () => false, escape: (value: string) => value }, + IS_REACT_ACT_ENVIRONMENT: true, + }); + + const runtimeHost = options.host === null ? undefined : options.host ?? TEST_RUNTIME_HOST; + let loadCalls = 0; + const loadHosts: UsageHostRef[] = []; + const mutateHosts: UsageHostRef[] = []; + const mutations: DesktopPricingMutationInput[] = []; + // Host resolution lives in the preload/adapter for the *selected* Host, threaded + // to the feature as a prop — so the feature-facing services take the Host as + // their first argument (they never resolve it themselves). + const services: UsagePricingServices = { + loadPricing: async (host) => { + loadHosts.push(host); + loadCalls += 1; + return options.load(); + }, + mutatePricing: async (host, base, mutation) => { + mutateHosts.push(host); + mutations.push({ base, mutation }); + return ( + options.mutate?.(base, mutation) ?? + Promise.reject(new Error('mutate is not used by this test')) + ); + }, + }; + + const container = document.querySelector('#root'); + assert.ok(container); + // linkedom's has no showModal/close; Astryx Dialog/AlertDialog call + // them on mount. Patch the element prototype so modal dialogs can render. + const dialogProto = Object.getPrototypeOf(document.createElement('dialog')) as { + showModal?: () => void; + close?: () => void; + }; + dialogProto.showModal = function showModal(this: { open?: boolean }) { + this.open = true; + }; + dialogProto.close = function close(this: { open?: boolean }) { + this.open = false; + }; + const root = createRoot(container); + const defaultGenerationKey = runtimeHost + ? `${runtimeHost.profileId}:${runtimeHost.hostId}:e1` + : 'no-host'; + let currentGenerationKey: string | null = defaultGenerationKey; + function renderTree(generationKey: string, showView = true): void { + currentGenerationKey = generationKey; + const editor = createElement(PricingEditor, { + describeError: (error: unknown) => (error instanceof Error ? error.message : String(error)), + target: runtimeHost + ? { + host: runtimeHost, + generationKey, + isCurrent: () => currentGenerationKey === generationKey, + } + : null, + }); + const scope = createElement(UsageFeatureScope, { + targetKey: generationKey, + services: { + loadUsageStats: async () => null, + updateUsageSettings: async () => createDefaultSettings().usage, + }, + loadErrorTitle: 'Usage load failed', + describeError: String, + children: showView ? editor : null, + }); + const provided = createElement(UsagePricingServicesProvider, { services, children: scope }); + const toasted = createElement(ToastProvider, { children: provided }); + const localized = createElement(AstryxLocaleProvider, { children: toasted }); + const tree = createElement(LocaleProvider, { locale: 'en', children: localized }); + root.render(options.strict ? createElement(StrictMode, null, tree) : tree); + } + await act(async () => { + renderTree(defaultGenerationKey); + await Promise.resolve(); + await Promise.resolve(); + }); + async function rerender(generationKey: string): Promise { + await act(async () => { + renderTree(generationKey); + await Promise.resolve(); + await Promise.resolve(); + }); + } + return { + doc: document as unknown as Document, + container, + root: root as Root, + rerender, + hideView: async () => { + await act(async () => renderTree(currentGenerationKey ?? defaultGenerationKey, false)); + }, + fenceTarget: () => { + currentGenerationKey = null; + }, + loadCalls: () => loadCalls, + loadHosts, + mutateHosts, + mutations, + }; +} + +async function click(button: HTMLButtonElement | undefined) { + assert.ok(button, 'expected a clickable button'); + await act(async () => { + activateButton(button); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function submitEditor(doc: Document): Promise { + const form = openDialog(doc)?.querySelector('form'); + assert.ok(form, 'expected the editor form'); + await act(async () => { + const EventClass = doc.defaultView!.Event; + form.dispatchEvent(new EventClass('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function clickWithoutSettling(button: HTMLButtonElement | undefined) { + assert.ok(button, 'expected a clickable button'); + await act(async () => { + activateButton(button); + await Promise.resolve(); + }); +} + +function activateButton(button: HTMLButtonElement): void { + button.click(); + // linkedom has no default form activation. Model that step for controller + // tests; the Pricing Populated browser story proves actual Enter submission. + if (button.getAttribute('type') !== 'submit' || button.disabled || button.getAttribute('aria-disabled') === 'true') return; + const formId = button.getAttribute('form'); + const form = formId ? button.ownerDocument.getElementById(formId) : button.closest('form'); + assert.ok(form, 'a submit button must belong to a form'); + const EventClass = button.ownerDocument.defaultView!.Event; + form.dispatchEvent(new EventClass('submit', { bubbles: true, cancelable: true })); +} + +async function clickElement(element: HTMLElement | undefined) { + assert.ok(element, 'expected a clickable element'); + await act(async () => { + element.click(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function selectCatalogModel(doc: Document, modelKey: string): Promise { + await click(buttonByText(doc, copy.add)); + await setInput(inputByPlaceholder(doc, copy.catalogPickerPlaceholder), modelKey); + const option = Array.from(doc.querySelectorAll('[role="option"]')).find( + (item) => item.textContent?.includes(modelKey), + ); + await clickElement(option); +} + +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +} +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function inputByPlaceholder(doc: Document, placeholder: string): HTMLInputElement | undefined { + return doc.querySelector(`input[placeholder="${placeholder}"]`) ?? undefined; +} + +function inputByLabel(doc: Document, label: string): HTMLInputElement | undefined { + const labelElement = Array.from(doc.querySelectorAll('label')).find((element) => element.textContent?.includes(label)); + return Array.from(doc.querySelectorAll('input')).find((input) => + (labelElement?.getAttribute('for') === input.id && input.id !== '') || + input.getAttribute('aria-label') === label || + input.getAttribute('aria-labelledby')?.split(' ').some((id) => doc.getElementById(id)?.textContent?.includes(label)), + ); +} + +function reactProps(input: HTMLInputElement): { + onChange?: (event: { target: HTMLInputElement; defaultPrevented: boolean }) => void; + onBlur?: (event: { target: HTMLInputElement }) => void; +} { + const propsKey = Object.keys(input).find((key) => key.startsWith('__reactProps$')); + assert.ok(propsKey, 'missing React props on the input'); + return (input as unknown as Record)[propsKey] as ReturnType; +} + +/** Typing and blur are separate: Host events and writes can finish during input. */ +async function typeInput(input: HTMLInputElement | undefined, value: string): Promise { + assert.ok(input, 'expected an input to fill'); + await act(async () => { + input.value = value; + reactProps(input).onChange?.({ target: input, defaultPrevented: false }); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function setInput(input: HTMLInputElement | undefined, value: string): Promise { + await typeInput(input, value); + assert.ok(input); + await act(async () => { + reactProps(input).onBlur?.({ target: input }); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function buttonByText(root: ParentNode, text: string): HTMLButtonElement | undefined { + return Array.from(root.querySelectorAll('button')).find( + (button) => (button.textContent ?? '').trim() === text, + ); +} + +function buttonByLabel(doc: Document, label: string): HTMLButtonElement | undefined { + return ( + doc.querySelector(`button[aria-label="${label}"]`) ?? undefined + ); +} + +function openDialog(doc: Document): HTMLDialogElement | undefined { + return Array.from(doc.querySelectorAll('dialog')).find((dialog) => dialog.open); +} + +function assertButtonDisabled(button: HTMLButtonElement | undefined): void { + assert.ok(button, 'expected button'); + assert.ok(button.disabled || button.getAttribute('aria-disabled') === 'true'); +} + +function assertButtonEnabled(button: HTMLButtonElement | undefined): void { + assert.ok(button, 'expected button'); + assert.equal(button.disabled, false); + assert.notEqual(button.getAttribute('aria-disabled'), 'true'); +} + + +it('a disposed view cannot dismiss the recovered draft on the same Host', async () => { + const pending = deferred(); + const harness = await renderEditor({ load: async () => SNAPSHOT, mutate: async () => pending.promise }); + try { + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await typeInput(inputByLabel(harness.doc, copy.inputLabel), '7.25'); + await clickWithoutSettling(buttonByText(harness.doc, copy.save)); + await harness.hideView(); + await harness.rerender(`${TEST_RUNTIME_HOST.profileId}:${TEST_RUNTIME_HOST.hostId}:e1`); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '7.25'); + await act(async () => pending.resolve({ kind: 'saved', disposition: 'committed', snapshot: SNAPSHOT })); + assert.ok(openDialog(harness.doc), 'a disposed controller must not clear the scope-owned draft'); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '7.25'); + } finally { await act(async () => harness.root.unmount()); } +}); + +for (const staleFails of [false, true]) { + it(`newer refresh wins when the older read ${staleFails ? 'fails' : 'succeeds'}`, async () => { + const stale = deferred(), fresh = deferred(); + const latest: DesktopPricingSnapshot = { ...SNAPSHOT, revision: 9, entries: SNAPSHOT.entries.map(row => ({...row, pricing: {...row.pricing,inputUsdPer1M: 987}})) }; + let reads = 0; + const harness = await renderEditor({load: async () => ++reads === 1 ? SNAPSHOT : reads === 2 ? stale.promise : fresh.promise }); + try { + const refresh = buttonByLabel(harness.doc, copy.refresh)!; + // Two requests dispatched before React commits the loading indicator. + await act(async () => { activateButton(refresh); activateButton(refresh); }); + assert.equal(harness.loadCalls(), 3); + await act(async () => fresh.resolve(latest)); + await act(async () => { if(staleFails) stale.reject(new Error('stale read disconnected')); else stale.resolve(SNAPSHOT); }); + assert.ok(harness.container.textContent?.includes('$987'), 'the newest authority must remain visible'); + assert.doesNotMatch(harness.container.textContent ?? '', new RegExp(copy.loadFailedTitle)); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '987'); + } finally { await act(async () => harness.root.unmount()); } + }); +} diff --git a/apps/desktop/src/main/__tests__/pricing-view-model.test.ts b/apps/desktop/src/main/__tests__/pricing-view-model.test.ts new file mode 100644 index 00000000000..36d4afff971 --- /dev/null +++ b/apps/desktop/src/main/__tests__/pricing-view-model.test.ts @@ -0,0 +1,136 @@ +/* + * 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 { + draftFromPricing, + validatePricingDraft, + type PricingDraft, +} from "../../renderer/features/usage/testing.js"; + +const EMPTY: PricingDraft = { + modelKey: "", + input: "", + output: "", + cacheRead: "", + cacheWrite: "", +}; + +test("prefilled drafts round-trip precision, omitted cache and explicit zero", () => { + const pricing = { + modelKey: "acme:coder-v2", + inputUsdPer1M: 0.000000123456789, + outputUsdPer1M: 2.123456789012345, + cacheReadUsdPer1M: 0, + }; + const { draft, cacheOpen } = draftFromPricing(pricing); + assert.equal(cacheOpen, true); + assert.equal(draft.cacheRead, "0"); + assert.equal(draft.cacheWrite, ""); + assert.deepEqual(validatePricingDraft(draft, { mode: "add", existingKeys: [] }).config, pricing); + assert.equal(draftFromPricing({ ...pricing, cacheReadUsdPer1M: undefined }).cacheOpen, false); +}); + +test("validatePricingDraft add flags an empty model key", () => { + const result = validatePricingDraft(EMPTY, { mode: "add", existingKeys: [] }); + assert.equal(result.errors.modelKey, "required"); + assert.equal(result.errors.input, "required"); + assert.equal(result.errors.output, "required"); + assert.equal(result.hasErrors, true); + assert.equal(result.config, null); +}); + +test("validatePricingDraft add flags a duplicate key against existing rows", () => { + const draft: PricingDraft = { ...EMPTY, modelKey: "openai:gpt-4o", input: "1", output: "2" }; + const result = validatePricingDraft(draft, { + mode: "add", + existingKeys: ["openai:gpt-4o"], + }); + assert.equal(result.errors.modelKey, "duplicate"); + assert.equal(result.config, null); +}); + +test("validatePricingDraft add builds a canonical config; blank cache is omitted", () => { + const draft: PricingDraft = { + modelKey: " DeepInfra:org/Model:Preview ", + input: "0.8", + output: "2.4", + cacheRead: "", + cacheWrite: "", + }; + const result = validatePricingDraft(draft, { mode: "add", existingKeys: [] }); + assert.equal(result.hasErrors, false); + assert.deepEqual(result.config, { + modelKey: "DeepInfra:org/Model:Preview", + inputUsdPer1M: 0.8, + outputUsdPer1M: 2.4, + }); + assert.equal(Object.hasOwn(result.config!, "cacheReadUsdPer1M"), false); +}); + +test("validatePricingDraft keeps an explicit 0 cache rate distinct from blank", () => { + const draft: PricingDraft = { + modelKey: "acme:coder-v2", + input: "1", + output: "2", + cacheRead: "0", + cacheWrite: "", + }; + const result = validatePricingDraft(draft, { mode: "add", existingKeys: [] }); + assert.equal(result.config?.cacheReadUsdPer1M, 0); + assert.equal(Object.hasOwn(result.config!, "cacheWriteUsdPer1M"), false); +}); + +test("validatePricingDraft rejects a negative rate", () => { + const draft: PricingDraft = { ...EMPTY, modelKey: "a:b", input: "-1", output: "2" }; + const result = validatePricingDraft(draft, { mode: "add", existingKeys: [] }); + assert.equal(result.errors.input, "invalid_rate"); + assert.equal(result.config, null); +}); + +test("rate text validates decimal syntax without losing precision or coercing invalid input", () => { + const valid: PricingDraft = { ...EMPTY, modelKey: "a:b", input: ".125", output: "2e-7", cacheRead: "0" }; + assert.deepEqual(validatePricingDraft(valid, { mode: "add", existingKeys: [] }).config, { + modelKey: "a:b", inputUsdPer1M: 0.125, outputUsdPer1M: 2e-7, cacheReadUsdPer1M: 0, + }); + for (const text of ["-", ".", "1e", "0x10", "1.2.3", "Infinity", "NaN", "1e309", "1e-400", "-1e-400"]) { + const result = validatePricingDraft({ ...valid, input: text, cacheRead: text }, { mode: "add", existingKeys: [] }); + assert.equal(result.errors.input, "invalid_rate", text); + assert.equal(result.errors.cacheRead, "invalid_rate", text); + assert.equal(result.config, null, text); + } +}); + +test("validatePricingDraft edit locks the key and ignores the draft key", () => { + const draft: PricingDraft = { + modelKey: "ignored", + input: "3", + output: "4", + cacheRead: "", + cacheWrite: "", + }; + const result = validatePricingDraft(draft, { + mode: "edit", + existingKeys: ["openai:gpt-4o"], + lockedModelKey: "openai:gpt-4o", + }); + assert.equal(result.hasErrors, false); + assert.equal(result.config?.modelKey, "openai:gpt-4o"); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts index 040f2ffb069..6ace6271a3b 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts @@ -32,8 +32,8 @@ import type { import { DesktopRuntimeHostClient, DesktopRuntimeHostClientError, - type DesktopPricingSnapshot, } from '../runtime-host-client.js'; +import type { DesktopPricingSnapshot } from '../../shared/desktop-pricing.js'; test('restarts a paginated Pricing read instead of mixing revisions', async () => { const stale = builtin('provider:stale', 1); @@ -465,3 +465,38 @@ function pricing(modelKey: string, inputUsdPer1M: number) { cacheWriteUsdPer1M: inputUsdPer1M * 1.5, }; } + + +for (const offsets of [[0], [1, 1], [1, 0], [2, 1]]) { + test(`rejects non-progressing Pricing offsets ${offsets.join(' -> ')} before dispatch`, async () => { + const responses = offsets.map((nextOffset, index) => page(1, index === 0 ? 0 : offsets[index-1]!, [builtin(`provider:model-${index}`, 1)], nextOffset)); + const { client, requests } = clientWithResponses(responses); + await assert.rejects(() => client.loadPricingSnapshot(), (error: unknown) => error instanceof DesktopRuntimeHostClientError && error.code === 'pricing_unstable'); + assert.equal(requests.length, offsets.length, 'invalid continuation must not cross the connection'); + }); +} + +test('assembles three strictly progressing Pricing pages', async () => { + const entries = [builtin('provider:a', 1), builtin('provider:b', 2), builtin('provider:c', 3)]; + const { client, requests } = clientWithResponses([page(8,0,[entries[0]!],1),page(8,1,[entries[1]!],2),page(8,2,[entries[2]!],null)]); + assert.deepEqual((await client.loadPricingSnapshot()).entries, entries); + assert.equal(requests.length, 3); +}); + +test('rejects a duplicated key at a Pricing page seam', async () => { + const { client } = clientWithResponses([page(1,0,[builtin('provider:a',1)],1),page(1,1,[builtin('provider:a',2)],null)]); + await assert.rejects(() => client.loadPricingSnapshot(), (error: unknown) => error instanceof DesktopRuntimeHostClientError && error.code === 'pricing_unstable'); +}); + +test('a missing row does not confirm restoration of bundled pricing', async () => { + const { client, requests } = clientWithResponses([ + {kind:'revision_conflict', expectedRevision:1, actualRevision:2}, + page(2,0,[],null), + ]); + const result = await client.applyPricingMutation({ + base: snapshot('host-current',1,[custom('provider:reset',2,'restore_builtin')]), + mutation:{kind:'delete',modelKey:'provider:reset'}, + }); + assert.equal(result.kind,'review_required'); + assert.equal(requests.filter(request=>request.operation==='pricing.mutate').length,1); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-pricing-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-pricing-ipc-main.test.ts new file mode 100644 index 00000000000..1145c13c663 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-pricing-ipc-main.test.ts @@ -0,0 +1,193 @@ +/* + * 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 type { Result } from "@maka/core/result"; +import type { + DesktopPricingMutationInput, + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from "../../shared/desktop-pricing.js"; +import type { IpcHandler } from "../ipc-reconnect-policy.js"; +import type { DesktopRuntimeHostClient } from "../runtime-host-client.js"; +import { registerRuntimeHostPricingIpc } from "../runtime-host-pricing-ipc-main.js"; +import { registerRuntimeHostUsageIpc } from "../runtime-host-usage-ipc-main.js"; + +function recordingIpc() { + const handlers = new Map(); + return { + handlers, + ipcMain: { + handle: (channel: string, listener: IpcHandler) => handlers.set(channel, listener), + handleReconnectableRead: (channel: string, listener: IpcHandler) => + handlers.set(channel, listener), + }, + }; +} + +const SNAPSHOT: DesktopPricingSnapshot = { + hostEpoch: "epoch-1", + connectionId: "conn-1", + revision: 7, + entries: [ + { source: "builtin", pricing: { modelKey: "openai:gpt-4o", inputUsdPer1M: 2.5, outputUsdPer1M: 10 } }, + ], +}; + +test("pricing IPC registers the two capabilities and fences the legacy handlers", () => { + const { handlers, ipcMain } = recordingIpc(); + registerRuntimeHostUsageIpc({ + ipcMain, + client: {} as unknown as DesktopRuntimeHostClient, + }); + registerRuntimeHostPricingIpc({ ipcMain, client: {} as unknown as DesktopRuntimeHostClient }); + + assert.ok(handlers.has("usage:pricing:load")); + assert.ok(handlers.has("usage:pricing:mutate")); + // Acceptance #12: the retired direct-Store routes must not coexist. + assert.equal(handlers.has("usage:pricing:list"), false); + assert.equal(handlers.has("usage:pricing:put"), false); + assert.equal(handlers.has("usage:pricing:reset"), false); +}); + +test("pricing load returns the full snapshot as a Result", async () => { + const { handlers, ipcMain } = recordingIpc(); + registerRuntimeHostPricingIpc({ + ipcMain, + client: { loadPricingSnapshot: async () => SNAPSHOT } as unknown as DesktopRuntimeHostClient, + }); + const handler = handlers.get("usage:pricing:load"); + assert.ok(handler); + const result = (await handler({} as never)) as Result; + assert.equal(result.ok, true); + assert.ok(result.ok && result.data.revision === 7); + assert.ok(result.ok && result.data.entries.length === 1); +}); + +test("pricing mutate passes the renderer-supplied base straight through (no re-read)", async () => { + let received: DesktopPricingMutationInput | undefined; + let loadCalls = 0; + const outcome: DesktopPricingMutationOutcome = { + kind: "saved", + disposition: "committed", + snapshot: { ...SNAPSHOT, revision: 8 }, + }; + const { handlers, ipcMain } = recordingIpc(); + registerRuntimeHostPricingIpc({ + ipcMain, + client: { + loadPricingSnapshot: async () => { + loadCalls += 1; + return SNAPSHOT; + }, + applyPricingMutation: async (input: DesktopPricingMutationInput) => { + received = input; + return outcome; + }, + } as unknown as DesktopRuntimeHostClient, + }); + const handler = handlers.get("usage:pricing:mutate"); + assert.ok(handler); + + const result = (await handler({} as never, SNAPSHOT, { + kind: "upsert", + pricing: { modelKey: "acme:coder", inputUsdPer1M: 1, outputUsdPer1M: 2 }, + })) as Result; + + assert.equal(result.ok, true); + assert.ok(result.ok && result.data.kind === "saved"); + // The base carries the revision the renderer was viewing — the handler must + // NOT reload the latest snapshot to synthesize a base (the retired-path bug). + assert.equal(received?.base.revision, 7); + assert.deepEqual(received?.base, SNAPSHOT); + assert.deepEqual(received?.mutation, { + kind: "upsert", + pricing: { modelKey: "acme:coder", inputUsdPer1M: 1, outputUsdPer1M: 2 }, + }); + assert.equal(loadCalls, 0); +}); + +test("pricing mutate rejects a malformed base as a failed Result", async () => { + let applyCalls = 0; + const { handlers, ipcMain } = recordingIpc(); + registerRuntimeHostPricingIpc({ + ipcMain, + client: { + applyPricingMutation: async () => { + applyCalls += 1; + return { kind: "saved_refresh_failed", disposition: "committed" } as const; + }, + } as unknown as DesktopRuntimeHostClient, + }); + const handler = handlers.get("usage:pricing:mutate"); + assert.ok(handler); + + const result = (await handler({} as never, { revision: "nope" }, { + kind: "delete", + modelKey: "acme:coder", + })) as Result; + + assert.equal(result.ok, false); + assert.equal(applyCalls, 0); +}); + +test("pricing mutate reconciles (no replay) when the dispatch outcome is unknown", async () => { + let reconciled: DesktopPricingMutationInput | undefined; + let reconciledReason: string | undefined; + const { handlers, ipcMain } = recordingIpc(); + registerRuntimeHostPricingIpc({ + ipcMain, + client: { + // The initial dispatch could not confirm its outcome on its own + // (likely-lost) connection — and it was a confirmed revision conflict. + applyPricingMutation: async () => + ({ kind: "reconciliation_unavailable", reason: "revision_conflict" }) as const, + // The reconciled-control path reloads fresh authority and compares intent + // WITHOUT re-dispatching the mutation, preserving the original reason. + reconcilePricingMutation: async ( + input: DesktopPricingMutationInput, + reason: "revision_conflict" | "outcome_unknown", + ) => { + reconciled = input; + reconciledReason = reason; + return { + kind: "review_required", + reason, + snapshot: { ...SNAPSHOT, revision: 8 }, + } as const; + }, + } as unknown as DesktopRuntimeHostClient, + }); + const handler = handlers.get("usage:pricing:mutate"); + assert.ok(handler); + + const result = (await handler({} as never, SNAPSHOT, { + kind: "delete", + modelKey: "acme:coder", + })) as Result; + + // The synchronous fallback runs dispatch → reconcile; the reconcile carries + // the renderer's base and the original reason (not a blanket "unknown"). + assert.equal(result.ok, true); + assert.ok(result.ok && result.data.kind === "review_required"); + assert.equal(reconciledReason, "revision_conflict"); + assert.deepEqual(reconciled?.base, SNAPSHOT); + assert.deepEqual(reconciled?.mutation, { kind: "delete", modelKey: "acme:coder" }); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts index 34ae7a2cf78..958900e0cc7 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts @@ -78,12 +78,11 @@ function handlers(queryUsage: (input: UsageQueryInput) => Promise {}, }); return handlers; } const event = {} as Parameters[0]; -test('one screen call atomically supplies totals, complete groups, pricing, and only the first activity page', async () => { +test('one screen call supplies complete usage groups and the first activity page without legacy pricing', async () => { const calls: UsageQueryInput[] = []; const fixture = screen(); const h = handlers(async (input) => { @@ -102,6 +101,7 @@ test('one screen call atomically supplies totals, complete groups, pricing, and assert.equal(stats.logs[0]?.sessionName, 'Host title'); assert.equal(stats.navigation?.nextCursor, 'next'); assert.equal(stats.navigation?.revision, fixture.revision); + assert.equal('pricing' in stats, false, 'Pricing loads its own revisioned Host snapshot'); }); test('initial All load fixes its time bound before dispatch', async () => { let sent: UsageQueryInput | undefined; diff --git a/apps/desktop/src/main/__tests__/usage-settings-view.test.ts b/apps/desktop/src/main/__tests__/usage-settings-view.test.ts index f1af80b7d08..62a01e0178d 100644 --- a/apps/desktop/src/main/__tests__/usage-settings-view.test.ts +++ b/apps/desktop/src/main/__tests__/usage-settings-view.test.ts @@ -62,7 +62,6 @@ function statsWithRequests(totalRequests: number): UsageStats { byProvider: [], byModel: [], byTool: [], - pricing: [], provenance: EMPTY_USAGE_PROVENANCE, }; } @@ -158,6 +157,7 @@ function tree(opts: { ? createElement(UsageSettingsView, { settings: opts.settings.usage, describeError: (error: unknown) => String(error), + runtimeHost: undefined, }) : null, }), @@ -611,6 +611,7 @@ describe('Usage feature scope', () => { : createElement(UsageSettingsView, { settings: base.usage, describeError: (error: unknown) => String(error), + runtimeHost: undefined, }), }), }), @@ -678,6 +679,7 @@ describe('Usage feature scope', () => { children: createElement(UsageSettingsView, { settings: base.usage, describeError: (error: unknown) => String(error), + runtimeHost: undefined, }), }), }), diff --git a/apps/desktop/src/main/__tests__/workhub-remote-bridge.test.ts b/apps/desktop/src/main/__tests__/workhub-remote-bridge.test.ts index ccf2eef64ab..e28a18d1eef 100644 --- a/apps/desktop/src/main/__tests__/workhub-remote-bridge.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-remote-bridge.test.ts @@ -69,6 +69,7 @@ async function fixture(t: TestContext, workName: string | ((id: string) => strin const projectFrame = async (frame: SubscriptionFrame) => { const nextRead = Math.max(1, events.nextCount) + 1; events.push(frame); + // The bridge reads its next frame only after reconciliation has finished. await t.waitFor(() => assert.ok(events.nextCount >= nextRead)); }; return { @@ -252,6 +253,25 @@ test('direct-answer topic receipts survive paging and label a delayed answer wit } }); +test('the latest WeChat follow-up carries its associated work heading without a newer request', async (t) => { + const f = await fixture(t, 'CS336'); + try { + await f.bridge.handle({ ...message('wechat-owner', '刚才到第几讲内容了?'), platform: 'wechat' }); + const turnId = f.starts[0]!.turnId; + const call: StoredMessage = { type: 'tool_call', id: 'associate-current', turnId, ts: 1, toolName: 'mcp__desktop_workhub__tasks', args: { request: { operation: 'associate', targetSessionId: 'cs336' } } }; + await f.transcript([call], false); + await f.restart(); + await f.transcript([call, + { type: 'tool_result', id: 'topic-current', turnId, ts: 2, toolUseId: call.id, isError: false, content: { kind: 'json', value: { structuredContent: { operation: 'associate', targetSessionId: 'cs336', targetSessionKey: JSON.stringify(['host', 'cs336']), targetSessionName: 'CS336' } } } }, + { type: 'assistant', id: 'answer-current', turnId, ts: 3, text: '刚到第 2 讲。', modelId: 'test' }, + ]); + assert.deepEqual(f.sends, [{ chat: 'wechat-owner', text: '[CS336]\n刚到第 2 讲。' }]); + assert.equal(f.starts.length, 1, 'association does not dispatch work'); + await f.restart(); + assert.equal(f.sends.length, 1, 'already delivered answers are not resent'); + } finally { await f.close(); } +}); + function association(turnId: string, id: string, targetSessionId: string | null, reason = 'unrelated'): [StoredMessage, StoredMessage] { const request = targetSessionId === null ? { operation: 'associate', targetSessionId, reason } : { operation: 'associate', targetSessionId }; const receipt = targetSessionId === null ? request : { ...request, targetSessionKey: JSON.stringify(['host', targetSessionId]), targetSessionName: 'SQL 入门' }; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 3df1a5d7727..9ddb19a067c 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -258,6 +258,7 @@ import { } from "./runtime-host-settings-ipc-main.js"; import { registerRuntimeHostSkillsIpc } from "./runtime-host-skills-ipc-main.js"; import { registerRuntimeHostUsageIpc } from "./runtime-host-usage-ipc-main.js"; +import { registerRuntimeHostPricingIpc } from "./runtime-host-pricing-ipc-main.js"; import { registerRuntimeHostWorkspaceIpc } from "./runtime-host-workspace-ipc-main.js"; import { registerSettingsBotsIpc, @@ -1775,8 +1776,8 @@ function registerHostClientIpc( registerRuntimeHostUsageIpc({ ipcMain: scopedIpc, client, - sendToRenderer, }); + registerRuntimeHostPricingIpc({ ipcMain: scopedIpc, client }); registerRuntimeHostWorkspaceIpc({ ipcMain: scopedIpc, client, diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index a90cba26e82..f03e6363c93 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -38,11 +38,7 @@ import type { RuntimePolicy, RuntimePolicyMutation, } from "@maka/core/runtime-policy"; -import { - canonicalPricingConfigsEqual, - comparePricingModelKeys, -} from "@maka/core/usage-stats/pricing"; -import type { PricingConfig } from "@maka/core/usage-stats/types"; +import { comparePricingModelKeys } from "@maka/core/usage-stats/pricing"; import { type ClientCapabilityProvider, type DecodedSessionTranscriptPage, @@ -67,6 +63,7 @@ import { } from "@maka/runtime-host/client"; import { ARTIFACT_INGEST_CHUNK_MAX_BYTES, + createPricingReconciliationTarget, decodePricingMutateInput, type ArtifactBinaryPreview, type ArtifactProjection, @@ -92,8 +89,9 @@ import { type OperationOutput, type PlanProjectionItem, type PlanQueryResult, - type PricingMutation, type PricingQueryResult, + pricingReconciliationTargetMatches, + type PricingReconciliationTarget, type ProjectCatalogMutateInput, type ProjectCatalogMutateResult, type ProjectCatalogProject, @@ -154,6 +152,11 @@ import { type TurnMessageSubmitResult, type WorkspaceProjection, } from "@maka/runtime-host/protocol"; +import type { + DesktopPricingMutationInput, + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from "../shared/desktop-pricing.js"; const decodeStoredMessage = (value: unknown): StoredMessage => decodePersistedStoredMessage(markPersisted(value)); @@ -240,13 +243,6 @@ export interface DesktopRuntimeHostSession { close(): Promise; } -export interface DesktopPricingSnapshot { - readonly hostEpoch: string; - readonly connectionId: string; - readonly revision: number; - readonly entries: readonly EffectivePricingEntry[]; -} - export interface DesktopSkillCatalogSnapshot { readonly revision: SkillCatalogRevision; readonly view: SkillCatalogView; @@ -254,39 +250,6 @@ export interface DesktopSkillCatalogSnapshot { readonly workspace: WorkspaceProjection; } -export interface DesktopPricingMutationInput { - readonly base: DesktopPricingSnapshot; - readonly mutation: PricingMutation; -} - -export type DesktopPricingMutationOutcome = - | { - readonly kind: "saved"; - readonly disposition: "committed" | "unchanged"; - readonly snapshot: DesktopPricingSnapshot; - } - | { - readonly kind: "saved_refresh_failed"; - readonly disposition: "committed" | "unchanged"; - } - | { - readonly kind: "synchronized" | "review_required"; - readonly reason: "revision_conflict" | "outcome_unknown"; - readonly snapshot: DesktopPricingSnapshot; - } - | { - readonly kind: "reconciliation_unavailable"; - readonly reason: "revision_conflict" | "outcome_unknown"; - }; - -type PricingReconciliationTarget = - | { readonly kind: "upsert"; readonly pricing: Readonly } - | { - readonly kind: "delete"; - readonly modelKey: string; - readonly expected: "builtin" | "unpriced" | "no_override"; - }; - export class DesktopRuntimeHostClient { readonly #sessions = new Set(); #closeTask: Promise | undefined; @@ -698,7 +661,7 @@ export class DesktopRuntimeHostClient { mutation: input.mutation, }); const reconciliationTarget = createPricingReconciliationTarget( - input.base, + input.base.entries, request.mutation, ); let result: OperationOutput<"pricing.mutate">; @@ -735,6 +698,27 @@ export class DesktopRuntimeHostClient { } } + /** + * Reconcile a pricing write whose outcome the dispatching connection could not + * settle, run against a replacement Host by the Desktop reconciled-control IPC + * after a response-losing disconnect. It reloads fresh authority and compares + * the intended end state; it must never replay the write, and it deliberately + * skips the connection stale-guard because `base` legitimately belongs to the + * previous connection. + */ + async reconcilePricingMutation( + input: DesktopPricingMutationInput, + reason: "revision_conflict" | "outcome_unknown", + ): Promise { + this.#assertOpen(); + const request = decodePricingMutateInput({ + expectedRevision: input.base.revision, + mutation: input.mutation, + }); + const target = createPricingReconciliationTarget(input.base.entries, request.mutation); + return this.#reconcilePricingMutation(target, reason); + } + /** * Recall over this Host's own corpus. * @@ -1787,17 +1771,17 @@ export class DesktopRuntimeHostClient { ); } const entries = [...first.entries]; - const offsets = new Set([0]); let page: Extract = first; while (page.nextOffset !== null) { const offset = page.nextOffset; - if (offset <= page.offset || offsets.has(offset)) { + // Every accepted page matches its requested offset, so strict progress + // also excludes every previously visited offset without a history set. + if (offset <= page.offset) { throw new DesktopRuntimeHostClientError( "pricing_unstable", "Runtime Host repeated a Pricing page offset", ); } - offsets.add(offset); const next = await this.request("pricing.query", { kind: "continue", revision: first.revision, @@ -1834,7 +1818,7 @@ export class DesktopRuntimeHostClient { try { const snapshot = await this.loadPricingSnapshot(); return { - kind: pricingTargetMatchesSnapshot(target, snapshot) + kind: pricingReconciliationTargetMatches(target, snapshot.entries) ? "synchronized" : "review_required", reason, @@ -2093,51 +2077,6 @@ function unstableProjection( ); } -function createPricingReconciliationTarget( - base: DesktopPricingSnapshot, - mutation: PricingMutation, -): PricingReconciliationTarget { - if (mutation.kind === "upsert") - return { kind: "upsert", pricing: mutation.pricing }; - const baseEntry = base.entries.find( - ({ pricing }) => pricing.modelKey === mutation.modelKey, - ); - const expected = - baseEntry?.source === "custom" - ? baseEntry.resetEffect === "restore_builtin" - ? "builtin" - : "unpriced" - : "no_override"; - return { kind: "delete", modelKey: mutation.modelKey, expected }; -} - -function pricingTargetMatchesSnapshot( - target: PricingReconciliationTarget, - snapshot: DesktopPricingSnapshot, -): boolean { - const current = snapshot.entries.find( - ({ pricing }) => pricing.modelKey === pricingTargetModelKey(target), - ); - if (target.kind === "upsert") { - return ( - current?.source === "custom" && - canonicalPricingConfigsEqual(current.pricing, target.pricing) - ); - } - switch (target.expected) { - case "builtin": - return current?.source === "builtin"; - case "unpriced": - return current === undefined; - case "no_override": - return current === undefined || current.source === "builtin"; - } -} - -function pricingTargetModelKey(target: PricingReconciliationTarget): string { - return target.kind === "upsert" ? target.pricing.modelKey : target.modelKey; -} - function pricingEntriesAreCanonical( entries: readonly EffectivePricingEntry[], ): boolean { diff --git a/apps/desktop/src/main/runtime-host-pricing-ipc-main.ts b/apps/desktop/src/main/runtime-host-pricing-ipc-main.ts new file mode 100644 index 00000000000..9c6aaec7aed --- /dev/null +++ b/apps/desktop/src/main/runtime-host-pricing-ipc-main.ts @@ -0,0 +1,156 @@ +/* + * 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. + */ + +/** + * Pricing Settings IPC — the renderer's two capabilities from #2015: + * + * - `usage:pricing:load` → one complete effective snapshot (built-in ∪ + * overrides), revision- and connection-stamped. + * - `usage:pricing:mutate` → apply one upsert/delete against the revision the + * renderer was viewing. + * + * The renderer round-trips the exact snapshot it loaded back as the CAS `base`; + * this handler passes it straight to the adapter and never re-reads the latest + * snapshot to synthesize a base (the bug in the retired `usage:pricing:put` + * path, which defeated conflict detection). CAS + reconciliation live entirely + * in `DesktopRuntimeHostClient.applyPricingMutation`. + */ + +import type { Result } from "@maka/core/result"; +import { + normalizePricingConfig, + normalizePricingModelKey, +} from "@maka/core/usage-stats/pricing"; +import type { PricingMutation } from "@maka/runtime-host/protocol"; +import { decodeDesktopPricingSnapshot } from "../shared/desktop-pricing-decode.js"; +import type { + DesktopPricingMutationInput, + DesktopPricingMutationOutcome, +} from "../shared/desktop-pricing.js"; +import { + handleReconciledControl, + handleReconnectableRead, + rethrowReconnectableReadFailure, + type ReconnectableReadIpcMain, + tryReconnectableReadResult, +} from "./ipc-reconnect-policy.js"; +import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; + +interface RuntimeHostPricingIpcDeps { + readonly ipcMain: ReconnectableReadIpcMain; + readonly client: DesktopRuntimeHostClient; +} + +type PricingMutateResult = Result; +type PricingReconcileReason = "revision_conflict" | "outcome_unknown"; +interface PricingReconcileContext { + readonly input: DesktopPricingMutationInput; + readonly reason: PricingReconcileReason; +} + +export function registerRuntimeHostPricingIpc( + deps: RuntimeHostPricingIpcDeps, +): void { + handleReconnectableRead(deps.ipcMain, "usage:pricing:load", () => + tryReconnectableReadResult( + () => deps.client.loadPricingSnapshot(), + "USAGE_PRICING_LOAD_FAILED", + ), + ); + // Reconciled control (like `goal:arm`): when the write's outcome is unknown + // (a response-losing disconnect), defer to the harness to wait for a + // replacement Host and reconcile against it — reload fresh authority and + // compare the intended end state, never replaying the mutation. The original + // conflict reason rides along so a confirmed revision conflict is not later + // reported as merely uncertain. + handleReconciledControl( + deps.ipcMain, + "usage:pricing:mutate", + { + dispatch: async (_event, base: unknown, mutation: unknown) => { + try { + const input: DesktopPricingMutationInput = { + base: decodeDesktopPricingSnapshot(base), + mutation: decodePricingMutation(mutation), + }; + const outcome = await deps.client.applyPricingMutation(input); + // The adapter could not reload on its own (likely-lost) connection; + // wait for a replacement Host and reconcile there instead of + // returning "unavailable" immediately. + if (outcome.kind === "reconciliation_unavailable") { + return { kind: "reconcile", context: { input, reason: outcome.reason } }; + } + return { kind: "completed", value: { ok: true, data: outcome } }; + } catch (error) { + return { kind: "completed", value: mutateFailure(error) }; + } + }, + reconcile: async (context) => { + try { + return { + ok: true, + data: await deps.client.reconcilePricingMutation(context.input, context.reason), + }; + } catch (error) { + rethrowReconnectableReadFailure(error); + return mutateFailure(error); + } + }, + reconciliationUnavailable: async (context) => ({ + ok: true, + data: { kind: "reconciliation_unavailable", reason: context.reason }, + }), + }, + ); +} + +function mutateFailure(error: unknown): PricingMutateResult { + return { + ok: false, + error: { + code: "USAGE_PRICING_MUTATE_FAILED", + message: error instanceof Error ? error.message : String(error), + details: error, + }, + }; +} + +/** + * Shape-guard the renderer-supplied mutation for an early, user-facing error. + * The Host is still the authoritative validator — the adapter re-decodes this + * before dispatch — but rejecting a malformed payload here beats throwing deep + * inside the adapter. + */ +function decodePricingMutation(value: unknown): PricingMutation { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Pricing mutation must be an object"); + } + const record = value as Record; + if (record.kind === "upsert") { + const normalized = normalizePricingConfig(record.pricing); + if (!normalized.ok) throw new Error(normalized.error); + return { kind: "upsert", pricing: normalized.value }; + } + if (record.kind === "delete") { + const normalized = normalizePricingModelKey(record.modelKey); + if (!normalized.ok) throw new Error(normalized.error); + return { kind: "delete", modelKey: normalized.value }; + } + throw new Error('Pricing mutation kind must be "upsert" or "delete"'); +} diff --git a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts index 31cfb41ada7..fc4570a41c7 100644 --- a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts @@ -18,14 +18,8 @@ */ import { resolveUsageRange } from "@maka/core/model-call-usage-projection"; -import { tryResult } from "@maka/core/result"; import type { UsageRange, UsageStats, UsageScreenQuery, UsageScreenFailure } from "@maka/core/settings"; -import { - normalizePricingConfig, - normalizePricingModelKey, -} from "@maka/core/usage-stats/pricing"; import type { - PricingConfig, UsageGroupBy, UsageQuery, } from "@maka/core/usage-stats/types"; @@ -43,23 +37,12 @@ import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; interface RuntimeHostUsageIpcDeps { readonly ipcMain: ReconnectableReadIpcMain; readonly client: DesktopRuntimeHostClient; - readonly sendToRenderer: (channel: string, ...args: unknown[]) => void; } export function registerRuntimeHostUsageIpc( deps: RuntimeHostUsageIpcDeps, ): void { - let pricingMutationQueue: Promise = Promise.resolve(); - const enqueuePricingMutation = (operation: () => Promise): Promise => { - const result = pricingMutationQueue.then(operation); - pricingMutationQueue = result.then( - () => undefined, - () => undefined, - ); - return result; - }; - handleReconnectableRead( deps.ipcMain, "settings:usageStats", @@ -119,45 +102,7 @@ export function registerRuntimeHostUsageIpc( }; }, "USAGE_LOGS_FAILED"), ); - handleReconnectableRead(deps.ipcMain, "usage:pricing:list", () => - tryReconnectableReadResult(async () => { - const snapshot = await deps.client.loadPricingSnapshot(); - return snapshot.entries - .filter((entry) => entry.source === "custom") - .map((entry) => entry.pricing); - }, "USAGE_PRICING_LIST_FAILED"), - ); - deps.ipcMain.handle("usage:pricing:put", (_event, pricing: unknown) => - tryResult( - () => - enqueuePricingMutation(async () => { - const normalized = normalizePricingConfig(pricing); - if (!normalized.ok) throw new Error(normalized.error); - await applyPricingMutation(deps.client, { - kind: "upsert", - pricing: normalized.value, - }); - deps.sendToRenderer("usage:pricing:changed"); - return normalized.value; - }), - "USAGE_PRICING_PUT_FAILED", - ), - ); - deps.ipcMain.handle("usage:pricing:reset", (_event, modelKey: unknown) => - tryResult( - () => - enqueuePricingMutation(async () => { - const normalized = normalizePricingModelKey(modelKey); - if (!normalized.ok) throw new Error(normalized.error); - await applyPricingMutation(deps.client, { - kind: "delete", - modelKey: normalized.value, - }); - deps.sendToRenderer("usage:pricing:changed"); - }), - "USAGE_PRICING_RESET_FAILED", - ), - ); + } async function loadUsageStats( @@ -170,7 +115,7 @@ async function loadUsageStats( }}); if (result.kind === "screen_response_too_large") return result; if (result.kind !== "screen") throw invalidUsageProjection(); - const {revision, queryIdentity, query: resolvedQuery, nextCursor, activityTotal, ...stats} = result.screen; + const {revision, queryIdentity, query: resolvedQuery, nextCursor, activityTotal, pricing: _pricing, ...stats} = result.screen; return {...stats, navigation: {revision, queryIdentity, query: resolvedQuery, nextCursor, activityTotal}}; } @@ -226,26 +171,6 @@ function toToolQuery(query: UsageQuery) { }; } -async function applyPricingMutation( - client: DesktopRuntimeHostClient, - mutation: - | { readonly kind: "upsert"; readonly pricing: PricingConfig } - | { readonly kind: "delete"; readonly modelKey: string }, -): Promise { - const outcome = await client.applyPricingMutation({ - base: await client.loadPricingSnapshot(), - mutation, - }); - if ( - outcome.kind === "saved" || - outcome.kind === "saved_refresh_failed" || - outcome.kind === "synchronized" - ) { - return; - } - throw new Error("Pricing changed concurrently; reload it before retrying"); -} - function invalidUsageProjection(): Error { return new Error("Runtime Host returned an invalid Usage projection"); } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 42de2cd7b65..2e9af84dcd3 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -203,6 +203,11 @@ import type { DesktopSessionSummary, DesktopSessionUpdateResult, } from '../shared/desktop-session-projection.js'; +import type { + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from '../shared/desktop-pricing.js'; +import type { PricingMutation } from '@maka/runtime-host/protocol'; import type { SessionCollaborationCancelResult, SessionCollaborationImportPhase, @@ -1671,6 +1676,14 @@ export interface MakaBridge { testNetworkProxy(input?: TestProxyInput, host?: DesktopRuntimeHostRef): Promise; testBotChannel(provider: BotProvider): Promise; usageStats(range?: UsageRange | Extract, host?: DesktopRuntimeHostRef, query?: UsageScreenQuery): Promise; + pricing: { + load(host: DesktopRuntimeHostRef): Promise; + mutate( + base: DesktopPricingSnapshot, + mutation: PricingMutation, + host: DesktopRuntimeHostRef, + ): Promise; + }; bots: { listStatuses(): Promise>; restart(provider: BotProvider): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index a46dbc0a50f..11615008835 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -237,6 +237,11 @@ import { type TestProxyInput, } from '@maka/core/settings/network-settings'; import type { Result } from '@maka/core/result'; +import type { + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from '../shared/desktop-pricing.js'; +import type { PricingMutation } from '@maka/runtime-host/protocol'; import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; import type { McpConfigAddResult, @@ -3714,6 +3719,34 @@ const makaBridge = { if ('kind' in stats) return stats; return projectDesktopUsageStats(scope, stats); }, + pricing: { + // Load one complete effective snapshot (built-in ∪ overrides), stamped to + // its Host connection/revision; the renderer round-trips it as the CAS base. + async load(host: DesktopRuntimeHostRef): Promise { + const result = await invokeSelectedRuntimeHost>( + host, + 'usage:pricing:load', + ); + if (!result.ok) throw new Error(result.error.message); + return result.data; + }, + // Apply one upsert/delete against the viewed revision (`base`). The adapter + // owns CAS + reconciliation; the outcome encodes committed/conflict/uncertain. + async mutate( + base: DesktopPricingSnapshot, + mutation: PricingMutation, + host: DesktopRuntimeHostRef, + ): Promise { + const result = await invokeSelectedRuntimeHost>( + host, + 'usage:pricing:mutate', + base, + mutation, + ); + if (!result.ok) throw new Error(result.error.message); + return result.data; + }, + }, bots: { listStatuses(): Promise> { return invokeWhenReady('settings:bots:listStatuses'); diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index 54ca3556fb1..663a6ca1ce7 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -47,6 +47,7 @@ import { SessionCollaborationServicesProvider } from '../features/session-collab import { SessionNavigationServicesProvider } from '../features/session-navigation'; import { SessionSettingsServicesProvider } from '../features/session-settings'; import { TaskEntryServicesProvider } from '../features/task-entry'; +import { UsagePricingServicesProvider } from '../features/usage'; import { WorkbarServicesProvider } from '../features/workbar'; import { OverlaysServicesProvider } from '../features/overlays/index.js'; import { createDesktopAppUpdateServices } from '../platform/desktop/create-app-update-services'; @@ -65,6 +66,7 @@ import { createDesktopStorageUsageServices } from '../platform/desktop/create-st import { createDesktopSessionBundleServices } from '../platform/desktop/create-session-bundle-services.js'; import { createDesktopSessionSettingsServices } from '../platform/desktop/create-session-settings-services'; import { createDesktopTaskEntryServices } from '../platform/desktop/create-task-entry-services'; +import { createDesktopUsagePricingServices } from '../platform/desktop/create-usage-pricing-services'; import { createDesktopWorkbarServices } from '../platform/desktop/create-workbar-services'; import { createDesktopOverlaysServices } from '../platform/desktop/create-overlays-services'; import { observeReactPerformanceMeasures } from '../platform/desktop/react-performance-measures'; @@ -133,6 +135,7 @@ export function createDesktopFeatureServices() { sessionSettings: createDesktopSessionSettingsServices(), storageUsage: createDesktopStorageUsageServices(), taskEntry: createDesktopTaskEntryServices(), + usagePricing: createDesktopUsagePricingServices(), workbar: createDesktopWorkbarServices(), }; } @@ -171,7 +174,9 @@ export function DesktopFeatureServicesProvider(props: { - {props.children} + + {props.children} + diff --git a/apps/desktop/src/renderer/features/usage/README.md b/apps/desktop/src/renderer/features/usage/README.md index d0ecb07827f..3cc868dad6e 100644 --- a/apps/desktop/src/renderer/features/usage/README.md +++ b/apps/desktop/src/renderer/features/usage/README.md @@ -37,12 +37,40 @@ to a thin wrapper). ## Boundary - `ports.ts` — `UsageServices`: `loadUsageStats(range, query)`, revision-checked - `loadUsageActivity(input)`, and `updateUsageSettings(patch)`. All narrow — the feature consumes only + `loadUsageActivity(input)`, and `updateUsageSettings(patch)`. The feature consumes only `UsageSettings`/`UsageStats`, never the whole `AppSettings`. +- `pricing-ports.ts` + `pricing-services-context.tsx` — the two Host-backed + Pricing capabilities: load one complete effective snapshot and apply one CAS + mutation against the settings-selected Host. +- `controller/pricing-controller.ts` — disposable Pricing authority, conflict, + mutation execution, and Host-generation fencing. An open editor/reset dialog + pins its viewed CAS snapshot; a list refresh cannot advance that write base or + its catalog/duplicate validation. A Host change or view remount recovers only + the scope-owned draft, reloads authority, and requires explicit review of the + new Host's price before the next save. Recovery errors and Retry stay inside + the editor. A recovered editor without a pinned mutation base is the review + state; no separate review flag is stored. Review reclassifies Add/Edit against + the new Host without changing rate input. A save retains its submitted draft + identity, so its result only closes that draft; input typed while saving remains + open for a later submit. + If reconciliation was temporarily unavailable, it retains that same attempt + and compares its exact intent with the next successful snapshot via the shared + pure reconciliation rules in `@maka/runtime-host/protocol`, without replaying it. + Each attempt also belongs to its dialog generation: cancellation or a different + model key detaches the dialog while authority reconciliation continues. A late + result cannot reopen a cancelled dialog or carry its conflict into another edit. - `services-context.tsx` — `UsageFeatureScope`, the persistent state owner (single complete snapshot with range/query labels, screen/page request tickets, fixed filter time bounds, Host invalidation, and visible stale/capacity failures), plus `useUsageServices()` - and `useUsageStats(range)`. + and `useUsageStats(range)`. It also keeps the Pricing editor's input (mode, + raw rate text, and cache-section state) through `usePricingEditorDraft()`, so + Settings' Host-keyed content and loading gate cannot discard the user's work. + Pricing snapshots and in-flight operations never persist in this scope. +- `pricing-view-model.ts` — validates raw decimal/scientific rate text at the + submission boundary, preserving partial input through remounts. Invalid and + negative rates remain visible for correction; blank cache prices are omitted + and an explicit zero remains zero. Astryx `TextInput` reports every keystroke; + `NumberInput`'s private, blur-committed pending text cannot satisfy this lifetime. - `ui/usage-settings-view.tsx` — the surface (overview + tabs + per-tab panels). A disposable view: it unmounts on a section change and reads the snapshot from the scope via `useUsageStats`, so leaving/returning re-displays the last @@ -50,13 +78,12 @@ to a thin wrapper). - `ui/usage-stats-table.tsx`, `ui/metric-card.tsx`, `controller/*` — feature-owned presentational + framework helpers (external-only deps). -## Wiring (one deviation from the composition-feature pattern, forced by the ratchet) +## Wiring -Unlike the composition-wired features, `settings-surface.tsx` is itself a frozen +Usage stats remain a transitional exception because `settings-surface.tsx` is a frozen legacy closure file, so it cannot import the feature or a `platform/` adapter, and usage stats are scoped to the *settings-selected* Runtime Host (a settings concept -the app-global composition root does not have). So there is **no `platform/desktop` -adapter / no composition registration — a transitional seam.** `settings-surface.tsx` +the app-global composition root does not have). `settings-surface.tsx` builds a host-bound `loadUsageStats` (via its existing `window.maka.settings.usageStats` call) plus an `updateUsageSettings` that projects the app-settings update down to `UsageSettings`, bundles them as `UsageServices`, and mounts the legacy shim @@ -65,13 +92,18 @@ call) plus an `updateUsageSettings` that projects the app-settings update down t survives a Skeleton/Banner state or a section change; the disposable `UsageSettingsPage` view is rendered in the section content slot and reads the scope via context. The scope takes a `host:epoch` `targetKey` as a **prop** (not a React -`key`): on a change it clears the snapshot and fences the in-flight load *in place*, -so a Host change never remounts the rest of the Settings surface. The Host-change +`key`): on a change it clears the snapshot and fences the in-flight load *in place*. +The scope survives even when Settings replaces its Host-keyed page content. The Host-change handler also calls the scope's imperative `fenceTarget()` *synchronously* (alongside the other Host-scoped resources), rejecting an in-flight old-Host load before React -re-renders the new target. When #4425's composition step lands, only this mounting -seam moves to `composition/desktop-feature-services.tsx` + a stateless -`platform/desktop` adapter — the scope stays feature-owned. +re-renders the new target. That same fence is exposed to the Pricing controller as +an `isCurrent` witness, so an old-Host mutation result cannot land in the event-to- +render gap. Pricing itself is already composition-wired through +`platform/desktop/create-usage-pricing-services.ts` and +`composition/desktop-feature-services.tsx`; only the selected Host is threaded +from the settings surface. When #4425's remaining composition step lands, only +the Usage-stats mounting seam moves to composition plus a stateless Desktop +adapter; the scope stays feature-owned. Copy is **not** a deviation: the view imports `getUsageSettingsCopy` + `UsageSettingsCopy` from `locales/settings-usage-copy.ts` directly. A feature import @@ -82,17 +114,11 @@ shim, since `settings-error-copy` is not a copy catalog. ## Follow-up -- Add a `SettingsSurface` integration test for the mount seam this PR moves. - `usage-settings-view.test.ts` mounts `UsageFeatureScope` + `UsageSettingsView` - directly and drives `fenceTarget()` / `targetKey` by hand; it does not load - `settings-surface.tsx`, so the surface's fence call sites - (`commitSelectedRuntimeHostProfile`, the generation-change handler) and the - `usageTargetKey` derivation are not exercised end-to-end. Those three lifecycle - obligations are exactly what a stale head had regressed with every test green, so - a surface-level test guarding them is the real coverage; it is deferred to keep - this extraction PR contained. -- Add the editable pricing tab (#2015 / PR #4164) as a feature-internal tab, - replacing the read-only pricing tab preserved here. +- The `UsagePricingHostSwitch` Settings story now exercises a real Host lifecycle + event before input blur, page unmount, profile selection, recovered draft, and + save against the replacement Host's CAS base. `usage-settings-view.test.ts` still drives the + stats scope's fence and target key directly; broader stats integration coverage + remains a follow-up. - De-duplicate the controllers. `controller/action-guard.ts` and `controller/optimistic-settings-draft.ts` are feature-local copies of the legacy `settings/` helpers (which keep ~9 consumers and their own tests). They are diff --git a/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts b/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts new file mode 100644 index 00000000000..55e48fcd2c7 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts @@ -0,0 +1,598 @@ +/* + * 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 { useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react'; +import { useToast, useUiLocale } from '@maka/ui'; +import { normalizePricingModelKey } from '@maka/core/usage-stats/pricing'; +import { + createPricingReconciliationTarget, + pricingReconciliationTargetMatches, + pricingReconciliationTargetModelKey, + type EffectivePricingEntry, + type PricingMutation, + type PricingReconciliationTarget, +} from '@maka/runtime-host/protocol'; +import { useUsagePricingServices } from '../pricing-services-context.js'; +import { usePricingEditorDraft } from '../services-context.js'; +import type { UsagePricingServices, UsagePricingTarget } from '../pricing-ports.js'; +import { getPricingSettingsCopy } from '../../../locales/settings-pricing-copy.js'; +import { useActionGuard } from './action-guard.js'; +import { + draftFromPricing, + validatePricingDraft, + type PricingDraft, + type PricingEditorDraft, +} from '../pricing-view-model.js'; + +// Derive the controller's authority/outcome types from its injected port so the +// port remains the test seam even though it is expressed with shared contracts. +type DesktopPricingSnapshot = Awaited>; +type DesktopPricingMutationOutcome = Awaited>; +type PricingOverride = Extract; + +interface PricingWriteAttempt { + readonly intent: PricingReconciliationTarget; + /** A cancelled dialog or a different model draft no longer owns this result. */ + readonly dialogGeneration: number; + /** Immutable input actually submitted, distinct from subsequent user edits. */ + readonly draft: PricingDraft | null; +} + +/** + * Write blockers from #2015: after a save whose post-commit reload failed, or an + * outcome we could not reconcile, further writes are disabled until a fresh + * snapshot loads. `conflict` keeps the draft and allows an explicit second save + * against the latest snapshot. + */ +export type PricingWriteState = + | { readonly kind: 'idle' } + | { + readonly kind: 'conflict'; + readonly reason: 'revision_conflict' | 'outcome_unknown'; + readonly attempt: PricingWriteAttempt; + } + | { + readonly kind: 'refresh_failed'; + readonly attempt: PricingWriteAttempt; + } + | { + readonly kind: 'reconcile_unavailable'; + readonly reason: 'revision_conflict' | 'outcome_unknown'; + readonly attempt: PricingWriteAttempt; + }; + +const EMPTY_DRAFT: PricingDraft = { + modelKey: '', + input: '', + output: '', + cacheRead: '', + cacheWrite: '', +}; + +/** Reclassify against reviewed authority without replacing any rate input. */ +function reviewedEditorDraft( + current: PricingEditorDraft, + snapshot: DesktopPricingSnapshot, +): PricingEditorDraft { + const key = normalizePricingModelKey(current.draft.modelKey); + if (!key.ok) return current; + const row = snapshot.entries.find(({ pricing }) => pricing.modelKey === key.value); + if (row?.source === 'custom') { + return { ...current, mode: 'edit', draft: { ...current.draft, modelKey: key.value } }; + } + // An Edit whose override disappeared becomes Add; a catalog key that is no + // longer bundled stays visible as an exact manual key on the new Host. + return { + ...current, + mode: row?.source === 'builtin' && current.mode !== 'manual' ? 'catalog' : 'manual', + }; +} + +function validateEditorDraft(editor: PricingEditorDraft | null, existingKeys: readonly string[]) { + return validatePricingDraft(editor?.draft ?? EMPTY_DRAFT, { + mode: editor?.mode === 'edit' ? 'edit' : 'add', + existingKeys, + lockedModelKey: editor?.mode === 'edit' ? editor.draft.modelKey : undefined, + }); +} + +/** Owns disposable pricing authority and outcomes; the Usage scope keeps the draft. */ +export function usePricingController(props: { + readonly describeError: (error: unknown) => string; + /** Settings-selected Host plus its lifecycle generation (`host:epoch`). */ + readonly target: UsagePricingTarget | null; +}) { + const services = useUsagePricingServices(); + const { describeError } = props; + const locale = useUiLocale(); + const copy = getPricingSettingsCopy(locale); + const toast = useToast(); + + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(true); + const [loadFailed, setLoadFailed] = useState(false); + const [editor, setScopeEditor] = usePricingEditorDraft(); + const editorRef = useRef(editor); + editorRef.current = editor; + // List refreshes never replace the snapshot the open dialog was based on. + // Only opening a dialog or explicitly reviewing an outcome adopts authority. + const [mutationBase, setMutationBase] = useState(null); + const draft = editor?.draft ?? EMPTY_DRAFT; + const cacheOpen = editor?.cacheOpen ?? false; + const [writeState, setWriteState] = useState({ kind: 'idle' }); + const dialogGenerationRef = useRef(0); + // A remounted view may recover a draft, but never its former mutation base. + const needsReview = editor !== null && mutationBase === null; + const [pendingMutation, setPendingMutation] = useState(null); + const [resetTarget, setResetTarget] = useState(null); + const saving = pendingMutation === 'upsert'; + const resetBusy = pendingMutation === 'delete'; + const triggerRef = useRef(null); + const addButtonRef = useRef(null); + const focusRestorePendingRef = useRef(false); + + const guard = useActionGuard(); + // A single lifecycle generation fences Host replacement and unmount, + // including StrictMode's effect cleanup/restart. + const lifecycleRef = useRef(0); + // Authority sequence: bumped by a reload start (a newer reload supersedes an + // older one) AND by a committed mutation (`applyOutcome`). A reload captures + // it and drops its result if it changed while in flight — so a slow refresh + // started before a save can never land back on top of the saved authority, nor + // reset a `refresh_failed`/`reconcile` write-block to idle. + const reloadTicketRef = useRef(0); + const targetKey = props.target?.generationKey ?? 'no-host'; + const [renderedTargetKey, setRenderedTargetKey] = useState(targetKey); + + // Fence a changed Host during render, before an old asynchronous result can + // land in the event-to-effect gap. React immediately restarts this component + // with the new target key. Keep the editor draft, but discard all authority + // and require review after the replacement snapshot arrives. + if (targetKey !== renderedTargetKey) { + setRenderedTargetKey(targetKey); + lifecycleRef.current += 1; + reloadTicketRef.current += 1; + setSnapshot(null); + setMutationBase(null); + setLoading(true); + setLoadFailed(false); + setWriteState({ kind: 'idle' }); + setResetTarget(null); + setPendingMutation(null); + guard.finish(); + } + + useEffect(() => () => { + lifecycleRef.current += 1; + reloadTicketRef.current += 1; + }, []); + + function isCurrent( + lifecycle: number, + target = props.target, + ): boolean { + return ( + lifecycleRef.current === lifecycle && + (target === null || target.isCurrent()) + ); + } + + function setEditor(update: SetStateAction) { + const next = typeof update === 'function' ? update(editorRef.current) : update; + if (next?.draft.modelKey.trim() !== editorRef.current?.draft.modelKey.trim()) { + dialogGenerationRef.current += 1; + // Retain unresolved writes for reconciliation, but a settled conflict + // belongs only to the model/dialog that submitted it. + setWriteState((current) => current.kind === 'conflict' ? { kind: 'idle' } : current); + } + // Record input synchronously, even if a pending write settles before React + // commits the input event. Async completions must see this newer draft. + editorRef.current = next; + setScopeEditor(next); + } + + async function reload(): Promise { + const host = props.target?.host; + const pendingWrite = + writeState.kind === 'refresh_failed' || writeState.kind === 'reconcile_unavailable' + ? writeState + : undefined; + const lifecycle = lifecycleRef.current; + const ticket = ++reloadTicketRef.current; + setLoading(true); + setLoadFailed(false); + // No selected Host: nothing Host-scoped to load. Resolve to an empty state + // (like the usage stats loader's no-Host path) rather than letting the bridge + // fall back to a *different* (active) Host than the settings page shows. + if (!host) { + if (isCurrent(lifecycle) && ticket === reloadTicketRef.current) { + setSnapshot(null); + setLoadFailed(false); + setWriteState({ kind: 'idle' }); + setLoading(false); + } + return; + } + try { + const next = await services.loadPricing(host); + if (!isCurrent(lifecycle) || ticket !== reloadTicketRef.current) return; + if (pendingWrite) { + applyOutcome( + { + kind: pricingReconciliationTargetMatches(pendingWrite.attempt.intent, next.entries) + ? 'synchronized' + : 'review_required', + snapshot: next, + reason: + pendingWrite.kind === 'reconcile_unavailable' + ? pendingWrite.reason + : 'revision_conflict', + }, + pendingWrite.attempt, + ); + } else { + setSnapshot(next); + } + } catch { + if (!isCurrent(lifecycle) || ticket !== reloadTicketRef.current) return; + setLoadFailed(true); + } finally { + if (isCurrent(lifecycle) && ticket === reloadTicketRef.current) setLoading(false); + } + } + + // Load on mount and whenever the selected Host generation changes. Settings + // may also remount this view on a Host identity change or a loading gate. + // Either path reloads authority and keeps only the scope-owned user draft; + // lifecycle and target checks reject responses from the discarded view/Host. + useEffect(() => { + void reload(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [targetKey]); + + // Overrides-only surface (#2015 / maintainer direction on #2218): the table + // shows only the user's custom rows, and adding one picks from the built-in + // catalog. The Host collapses an overridden built-in into a single `custom` + // entry, so `catalogRows` is naturally the built-ins NOT yet overridden. + const overrideRows = useMemo( + () => snapshot?.entries.filter((row) => row.source === 'custom') ?? [], + [snapshot], + ); + const editorSnapshot = mutationBase ?? snapshot; + const catalogRows = useMemo( + () => editorSnapshot?.entries.filter((row) => row.source === 'builtin') ?? [], + [editorSnapshot], + ); + // Duplicate detection is over the OVERRIDES only (the visible list): picking or + // typing a built-in that is not yet overridden is a NEW override (an upsert), + // not a duplicate — only a key that already has a custom row is rejected + // ("edit its row instead"). Checking the full built-in ∪ overrides union here + // would wrongly flag every catalog pick (all built-ins) as a duplicate and + // block its save. + const overrideKeys = useMemo(() => editorSnapshot?.entries + .filter((row) => row.source === 'custom') + .map((row) => row.pricing.modelKey) ?? [], [editorSnapshot]); + const validation = useMemo( + () => validateEditorDraft(editor, overrideKeys), + [editor, overrideKeys], + ); + + const writesBlocked = + needsReview || + writeState.kind === 'refresh_failed' || + writeState.kind === 'reconcile_unavailable'; + + // Restore focus only after React has committed the dialog/row removal. A + // timer can run while the row action is still connected, focus that doomed + // trigger, and then leave focus on when the commit removes it. The + // layout effect observes the committed DOM and chooses the stable fallback + // when a successful reset/delete removed the opening row. + useLayoutEffect(() => { + if (!focusRestorePendingRef.current) return; + focusRestorePendingRef.current = false; + const trigger = triggerRef.current; + triggerRef.current = null; + if (trigger?.isConnected) trigger.focus(); + else addButtonRef.current?.focus(); + }, [editor, resetTarget]); + + // On a conflict, the fresh-authority row for whatever the user is editing or + // resetting — so the notice can show the latest value beside their draft + // rather than only claiming one exists. + const conflictLatestEntry = useMemo(() => { + if (writeState.kind !== 'conflict') return null; + const key = pricingReconciliationTargetModelKey(writeState.attempt.intent); + return editorSnapshot?.entries.find(({ pricing }) => pricing.modelKey === key) ?? null; + }, [writeState, editorSnapshot]); + + const hostReviewLatestEntry = needsReview + ? snapshot?.entries.find(({ pricing }) => pricing.modelKey === draft.modelKey.trim()) ?? null + : null; + + function restoreTriggerFocus() { + focusRestorePendingRef.current = true; + } + + function openAdd(trigger: HTMLElement | null) { + // No loaded authority (no selected Host, or a load still pending/failed) + // means a save would have no CAS base and silently no-op — so the editor + // must not open. The Add control is disabled for the same reason. + if (writesBlocked || guard.current || snapshot === null) return; + triggerRef.current = trigger; + setMutationBase(snapshot); + setEditor({ mode: 'catalog', draft: EMPTY_DRAFT, cacheOpen: false }); + } + + function openEdit(row: PricingOverride, trigger: HTMLElement | null) { + if (writesBlocked || guard.current || snapshot === null) return; + triggerRef.current = trigger; + setMutationBase(snapshot); + setEditor({ mode: 'edit', ...draftFromPricing(row.pricing) }); + } + + /** + * Pre-fill the open Add draft from a chosen built-in catalog row: the built-in + * price is the starting point for the override, and the cache section opens iff + * the built-in carries cache rates. Stays in add mode — the row is a built-in + * not yet overridden, so its key validates as a new override. + */ + function pickCatalogModel(row: EffectivePricingEntry) { + setEditor({ mode: 'catalog', ...draftFromPricing(row.pricing) }); + } + + function clearModel(mode: 'catalog' | 'manual' = 'catalog') { + setEditor({ mode, draft: EMPTY_DRAFT, cacheOpen: false }); + } + + function setCacheOpen(open: boolean) { + setEditor((current) => current ? { ...current, cacheOpen: open } : null); + } + + function reviewHostChange() { + if (!needsReview || loading || snapshot === null || !props.target?.isCurrent()) return; + setEditor((current) => current ? reviewedEditorDraft(current, snapshot) : null); + setMutationBase(snapshot); + } + + function closeEditor() { + if (guard.current) return; + setEditor(null); + setMutationBase(null); + restoreTriggerFocus(); + } + + const setField = (key: K, value: PricingDraft[K]) => + setEditor((current) => current && !(current.mode === 'edit' && key === 'modelKey') + ? { ...current, draft: { ...current.draft, [key]: value } } + : current); + + function finishReconciledIntent( + attempt: PricingWriteAttempt, + latest: DesktopPricingSnapshot, + ): void { + if (attempt.intent.kind === 'delete') { + setResetTarget(null); + setMutationBase(null); + restoreTriggerFocus(); + return; + } + const current = editorRef.current; + if (!current) return; + if (current.draft === attempt.draft) { + setEditor(null); + setMutationBase(null); + restoreTriggerFocus(); + return; + } + // The completed write owns only its submitted input. Keep later edits open + // and promote a saved Add to Edit so its next explicit save stays usable. + if (current.draft.modelKey.trim() === pricingReconciliationTargetModelKey(attempt.intent)) { + setEditor(reviewedEditorDraft(current, latest)); + setMutationBase(latest); + // A different client can write again between our commit and its reload. + // The new draft must review that change before adopting it as its base. + if (!pricingReconciliationTargetMatches(attempt.intent, latest.entries)) { + setWriteState({ kind: 'conflict', reason: 'revision_conflict', attempt }); + } + } + } + + function restoreReconciledIntent( + attempt: PricingWriteAttempt, + latest: DesktopPricingSnapshot, + ): void { + const { intent } = attempt; + const key = pricingReconciliationTargetModelKey(intent); + const latestRow = latest.entries.find(({ pricing }) => pricing.modelKey === key); + if (intent.kind === 'upsert') { + const current = editorRef.current; + if (current?.draft.modelKey.trim() === key) { + setEditor(reviewedEditorDraft(current, latest)); + setMutationBase(latest); + } + return; + } + setMutationBase(latest); + if (latestRow?.source === 'custom') setResetTarget(latestRow); + } + + /** Adopt one settled outcome using the same authority and intent as a reload. */ + function applyOutcome( + outcome: DesktopPricingMutationOutcome, + attempt: PricingWriteAttempt, + ): void { + // Fence any reload that was in flight when this mutation committed, so a + // stale refresh can't overwrite the authority we're about to set (nor reset + // a write-block to idle). Clear its loading indicator too — the fenced + // reload's own `finally` will no longer run. + reloadTicketRef.current += 1; + setLoading(false); + if ('snapshot' in outcome) { + setSnapshot(outcome.snapshot); + setLoadFailed(false); + } + // Reconciliation still updates authority after Cancel. Only its original + // dialog may be closed, reopened for review, or given a conflict notice. + const ownsDialog = attempt.dialogGeneration === dialogGenerationRef.current; + if (!ownsDialog && editorRef.current !== null && + (outcome.kind === 'saved' || outcome.kind === 'synchronized')) { + // A model selected during this write keeps its own input and mode, but + // the successful write advances its CAS and duplicate-check authority. + // Do not finish or reclassify that new draft as the completed intent. + setMutationBase(outcome.snapshot); + } + switch (outcome.kind) { + case 'saved': + setWriteState({ kind: 'idle' }); + if (ownsDialog) finishReconciledIntent(attempt, outcome.snapshot); + toast.success(copy.saved, outcome.disposition === 'unchanged' ? copy.synchronized : undefined); + return; + case 'synchronized': + setWriteState({ kind: 'idle' }); + if (ownsDialog) finishReconciledIntent(attempt, outcome.snapshot); + toast.success(copy.synchronized); + return; + case 'review_required': + if (!ownsDialog) { + setWriteState({ kind: 'idle' }); + return; + } + // Adopt fresh authority into the list so it is no longer speculative, + // keep the draft, and require an explicit second save against `latest`. + setWriteState({ kind: 'conflict', reason: outcome.reason, attempt }); + // If this was an Add and the fresh authority now already has that key + // (added elsewhere), the duplicate check would leave `validation.config` + // null and silently block the required second save. Convert the Add into + // an Edit locked on that key so the explicit re-save upserts against the + // latest revision (the draft's rates are preserved). + restoreReconciledIntent(attempt, outcome.snapshot); + return; + case 'saved_refresh_failed': + // The write committed but the post-commit reload failed — the loaded list + // is now definitely stale. Drop it (#2015: show no speculative final + // list); retain both the draft and intended end state so an in-dialog + // refresh can confirm the committed write without replaying it. + setSnapshot(null); + setWriteState({ kind: 'refresh_failed', attempt }); + return; + case 'reconciliation_unavailable': + setWriteState({ kind: 'reconcile_unavailable', reason: outcome.reason, attempt }); + return; + } + } + + /** Every submit path shares the write blockers, CAS base, and lifecycle fence. */ + async function mutate(mutation: PricingMutation): Promise { + const base = mutationBase; + const target = props.target; + if (writesBlocked || !base || !target?.isCurrent()) return; + if (!guard.begin('write')) return; + const lifecycle = lifecycleRef.current; + const attempt: PricingWriteAttempt = { + intent: createPricingReconciliationTarget(base.entries, mutation), + dialogGeneration: dialogGenerationRef.current, + draft: mutation.kind === 'upsert' ? editorRef.current?.draft ?? null : null, + }; + setPendingMutation(mutation.kind); + try { + const outcome = await services.mutatePricing(target.host, base, mutation); + if (!isCurrent(lifecycle, target)) return; + applyOutcome(outcome, attempt); + } catch (error) { + if (isCurrent(lifecycle, target)) { + toast.error(mutation.kind === 'upsert' ? copy.saveFailed : copy.resetFailed, describeError(error)); + } + } finally { + if (isCurrent(lifecycle, target)) { + guard.finish(); + setPendingMutation(null); + } + } + } + + async function save(): Promise { + const current = editorRef.current; + if (!current) return; + const { config } = validateEditorDraft(current, overrideKeys); + if (config) await mutate({ kind: 'upsert', pricing: config }); + } + + function openReset( + row: PricingOverride, + trigger: HTMLElement | null, + ) { + if (writesBlocked || guard.current || snapshot === null) return; + dialogGenerationRef.current += 1; + triggerRef.current = trigger; + setMutationBase(snapshot); + setResetTarget(row); + } + + function cancelReset() { + if (guard.current) return; + dialogGenerationRef.current += 1; + setResetTarget(null); + setMutationBase(null); + if (writeState.kind === 'conflict') setWriteState({ kind: 'idle' }); + restoreTriggerFocus(); + } + + async function confirmReset(): Promise { + if (resetTarget) await mutate({ kind: 'delete', modelKey: resetTarget.pricing.modelKey }); + } + + return { + copy, + addButtonRef, + loading, + loadFailed, + // A write needs a loaded snapshot as its CAS base; without one (no Host, or + // a load pending/failed) the Add flow is disabled rather than silently + // no-opping on save. + hasAuthority: snapshot !== null, + // Overrides-only table + catalog picker for the Add flow. + overrideRows, + catalogRows, + pickCatalogModel, + clearModel, + editor, + draft, + setField, + cacheOpen, + setCacheOpen, + validation, + writeState, + needsReview, + writesBlocked, + reviewHostChange, + conflictLatestEntry, + hostReviewLatestEntry, + saving, + resetTarget, + resetBusy, + reload, + openAdd, + openEdit, + closeEditor, + save, + openReset, + cancelReset, + confirmReset, + }; +} diff --git a/apps/desktop/src/renderer/features/usage/index.ts b/apps/desktop/src/renderer/features/usage/index.ts index bb59e3d7851..461ec90bf18 100644 --- a/apps/desktop/src/renderer/features/usage/index.ts +++ b/apps/desktop/src/renderer/features/usage/index.ts @@ -23,3 +23,9 @@ export { UsageSettingsView } from './ui/usage-settings-view.js'; export { UsageFeatureScope, type UsageScopeHandle } from './services-context.js'; export type { UsageServices } from './ports.js'; +// The editable Pricing surface (#2015) is a Usage tab, but its services are +// assembled in `composition/desktop-feature-services.tsx` (not the legacy +// settings-surface that assembles `UsageServices`), so its bridge access stays +// out of the frozen legacy-AppShell closure. +export { UsagePricingServicesProvider } from './pricing-services-context.js'; +export type { UsagePricingServices } from './pricing-ports.js'; diff --git a/apps/desktop/src/renderer/features/usage/ports.ts b/apps/desktop/src/renderer/features/usage/ports.ts index 1ef853d3671..768a796b85f 100644 --- a/apps/desktop/src/renderer/features/usage/ports.ts +++ b/apps/desktop/src/renderer/features/usage/ports.ts @@ -19,6 +19,18 @@ import type { UsageRange, UsageSettings, UsageStats, UsageScreenQuery, UsageScreenRequest, UsageScreenFailure, UsageScreenResult } from '@maka/core/settings'; +/** + * Minimal Runtime Host identity the feature threads for Host-scoped reads/writes + * (pricing overrides are per-Host / root-scoped). It is structurally compatible + * with the preload `DesktopRuntimeHostRef`, so the legacy surface can pass its + * `selectedRuntimeHost` straight through as a prop — without the feature + * importing the preload type. + */ +export interface UsageHostRef { + readonly profileId: string; + readonly hostId: string; +} + // Dependency-inversion boundary for the Usage settings feature (issue #4425). // The feature controller owns draft/state and reads these ports; it never // touches `window.maka` or legacy settings helpers directly. Both are narrow — diff --git a/apps/desktop/src/renderer/features/usage/pricing-ports.ts b/apps/desktop/src/renderer/features/usage/pricing-ports.ts new file mode 100644 index 00000000000..9c8ac0a056d --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/pricing-ports.ts @@ -0,0 +1,61 @@ +/* + * 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. + */ + +// Dependency-inversion boundary for the editable Pricing surface (#2015), +// hosted inside the Usage settings feature (#4425). The pricing controller owns +// the draft/CAS state and reads these ports; it never touches `window.maka`. +// +// Unlike the range-scoped `UsageServices` (still assembled inline in the legacy +// `settings/settings-surface.tsx`), pricing services are assembled in +// `composition/desktop-feature-services.tsx` via a `platform/desktop` adapter — +// the composition ownership #4425 targets. Pricing is net-new, so routing its +// `window.maka.settings.pricing` bridge access through the platform adapter is +// what keeps a new bridge path out of the frozen legacy-AppShell closure files +// (the renderer-architecture ratchet forbids growing their bridge paths). +// +import type { PricingMutation } from '@maka/runtime-host/protocol'; +import type { + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from '../../../shared/desktop-pricing.js'; +import type { UsageHostRef } from './ports.js'; + +export interface UsagePricingTarget { + readonly host: UsageHostRef; + readonly generationKey: string; + /** True only while this exact Host generation remains authoritative. */ + readonly isCurrent: () => boolean; +} + +export interface UsagePricingServices { + /** + * One complete effective pricing snapshot (built-in ∪ overrides) for the given + * Runtime Host. The `host` is the settings-*selected* Host (threaded from the + * legacy surface), not the app's active Host — pricing overrides are per-Host, + * so the Pricing tab must read/write the same Host as the rest of the settings + * page. The renderer round-trips the snapshot as the CAS base for a mutation. + */ + loadPricing(host: UsageHostRef): Promise; + /** Apply one pricing upsert/delete against the viewed snapshot (the CAS base). */ + mutatePricing( + host: UsageHostRef, + base: DesktopPricingSnapshot, + mutation: PricingMutation, + ): Promise; +} diff --git a/apps/desktop/src/renderer/features/usage/pricing-services-context.tsx b/apps/desktop/src/renderer/features/usage/pricing-services-context.tsx new file mode 100644 index 00000000000..0759a3414fd --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/pricing-services-context.tsx @@ -0,0 +1,33 @@ +/* + * 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 { createServicesContext } from '../../application/contracts/feature-services.js'; +import type { UsagePricingServices } from './pricing-ports.js'; + +// One app-root provider supplies the transport adapter. Each operation receives +// the settings-selected Host explicitly; the controller separately receives its +// generation key so it can fence stale work without remounting the provider. +const { Provider, useServices } = + createServicesContext('UsagePricingServicesProvider'); + +export const UsagePricingServicesProvider = Provider; + +export function useUsagePricingServices(): UsagePricingServices { + return useServices(); +} diff --git a/apps/desktop/src/renderer/features/usage/pricing-view-model.ts b/apps/desktop/src/renderer/features/usage/pricing-view-model.ts new file mode 100644 index 00000000000..22db6fc2488 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/pricing-view-model.ts @@ -0,0 +1,148 @@ +/* + * 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. + */ + +/** + * Pricing draft conversion and field validation. The UI reads the Host's + * effective entries directly; only editable drafts need a different shape. + */ + +import { normalizePricingModelKey } from '@maka/core/usage-stats/pricing'; +import type { PricingConfig } from '@maka/core/usage-stats/types'; + +export interface PricingDraft { + readonly modelKey: string; + /** Raw input, including incomplete decimals; an empty string means not set. */ + readonly input: string; + readonly output: string; + readonly cacheRead: string; + readonly cacheWrite: string; +} + +/** In-memory user input survives the Settings Host/loading gates remounting the view. */ +export interface PricingEditorDraft { + readonly mode: 'catalog' | 'manual' | 'edit'; + readonly draft: PricingDraft; + readonly cacheOpen: boolean; +} + +/** + * The editor draft pre-filled from an existing row — shared by the Edit flow and + * the Add flow's catalog pick. `cacheOpen` is true iff the row carries either + * cache rate (an explicit `0` counts; only `undefined` is "Not set"). + */ +export function draftFromPricing(pricing: PricingConfig): { + readonly draft: PricingDraft; + readonly cacheOpen: boolean; +} { + return { + draft: { + modelKey: pricing.modelKey, + input: String(pricing.inputUsdPer1M), + output: String(pricing.outputUsdPer1M), + cacheRead: pricing.cacheReadUsdPer1M === undefined ? '' : String(pricing.cacheReadUsdPer1M), + cacheWrite: pricing.cacheWriteUsdPer1M === undefined ? '' : String(pricing.cacheWriteUsdPer1M), + }, + cacheOpen: pricing.cacheReadUsdPer1M !== undefined || pricing.cacheWriteUsdPer1M !== undefined, + }; +} + +export type PricingRateErrorCode = 'required' | 'invalid_rate'; +export type PricingKeyErrorCode = 'required' | 'key_too_long' | 'duplicate'; + +export interface PricingDraftErrors { + modelKey?: PricingKeyErrorCode; + input?: PricingRateErrorCode; + output?: PricingRateErrorCode; + cacheRead?: 'invalid_rate'; + cacheWrite?: 'invalid_rate'; +} + +export interface PricingDraftValidation { + readonly errors: PricingDraftErrors; + readonly hasErrors: boolean; + /** The canonical config to send, present iff `hasErrors` is false. */ + readonly config: PricingConfig | null; +} + +export function validatePricingDraft( + draft: PricingDraft, + options: { + readonly mode: 'add' | 'edit'; + readonly existingKeys: readonly string[]; + /** Required in edit mode — the fixed identity key. */ + readonly lockedModelKey?: string; + }, +): PricingDraftValidation { + const errors: PricingDraftErrors = {}; + + let modelKey: string | null = null; + if (options.mode === 'edit') { + modelKey = options.lockedModelKey ?? null; + } else { + const normalized = normalizePricingModelKey(draft.modelKey); + if (!normalized.ok) { + errors.modelKey = draft.modelKey.trim() === '' ? 'required' : 'key_too_long'; + } else if (options.existingKeys.includes(normalized.value)) { + errors.modelKey = 'duplicate'; + } else { + modelKey = normalized.value; + } + } + + const input = parseRate(draft.input); + const output = parseRate(draft.output); + const cacheRead = parseRate(draft.cacheRead); + const cacheWrite = parseRate(draft.cacheWrite); + if (input === null) errors.input = 'required'; + else if (!isValidRate(input)) errors.input = 'invalid_rate'; + if (output === null) errors.output = 'required'; + else if (!isValidRate(output)) errors.output = 'invalid_rate'; + if (cacheRead !== null && !isValidRate(cacheRead)) errors.cacheRead = 'invalid_rate'; + if (cacheWrite !== null && !isValidRate(cacheWrite)) errors.cacheWrite = 'invalid_rate'; + + const hasErrors = Object.keys(errors).length > 0; + const config: PricingConfig | null = + !hasErrors && modelKey !== null && input !== null && output !== null + ? { + modelKey, + inputUsdPer1M: input, + outputUsdPer1M: output, + ...(cacheRead !== null ? { cacheReadUsdPer1M: cacheRead } : {}), + ...(cacheWrite !== null ? { cacheWriteUsdPer1M: cacheWrite } : {}), + } + : null; + + return { errors, hasErrors, config }; +} + +function parseRate(text: string): number | null { + const trimmed = text.trim(); + if (trimmed === '') return null; + // Accept decimal/scientific notation (including String()'s tiny rates), but + // never coerce malformed text, hexadecimal, or a negative price to zero. + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(trimmed)) return NaN; + const value = Number(trimmed); + // A nonzero rate below Number's range must not silently become free. + if (value === 0 && /[1-9]/.test(trimmed.split(/e/i)[0]!)) return NaN; + return value; +} + +function isValidRate(value: number): boolean { + return Number.isFinite(value) && value >= 0; +} diff --git a/apps/desktop/src/renderer/features/usage/services-context.tsx b/apps/desktop/src/renderer/features/usage/services-context.tsx index a5968d9c4bb..ef6cc2812f2 100644 --- a/apps/desktop/src/renderer/features/usage/services-context.tsx +++ b/apps/desktop/src/renderer/features/usage/services-context.tsx @@ -27,6 +27,8 @@ import { useRef, useState, type ReactNode, + type Dispatch, + type SetStateAction, } from 'react'; import { useMountedRef, useToast } from '@maka/ui'; import { resolveUsageRange } from '@maka/core/model-call-usage-projection'; @@ -37,6 +39,7 @@ import type { UsageScreenQuery, } from '@maka/core/settings'; import type { UsageServices } from './ports.js'; +import type { PricingEditorDraft } from './pricing-view-model.js'; interface UsageSnapshot { readonly installation: number; @@ -55,6 +58,10 @@ interface UsageScopeValue { readonly services: UsageServices; readonly snapshot: UsageSnapshot | null; readonly targetKey: string; + /** True only while the rendered Host generation is still authoritative. */ + isCurrentTarget(): boolean; + readonly pricingEditor: PricingEditorDraft | null; + readonly setPricingEditor: Dispatch>; readonly state: 'ready' | 'loading' | 'stale' | 'error'; readonly error: string | null; readonly failure: CapacityFailure | null; @@ -80,6 +87,9 @@ export const UsageFeatureScope = forwardRef< const toast = useToast(); const mountedRef = useMountedRef(); const [snapshot, setSnapshot] = useState(null); + const [pricingEditor, setPricingEditor] = useState(null); + const targetCurrentRef = useRef(true); + const renderedTargetKeyRef = useRef(props.targetKey); const [state, setState] = useState('ready'); const [error, setError] = useState(null); const [failure, setFailure] = useState(null); @@ -91,6 +101,10 @@ export const UsageFeatureScope = forwardRef< const pagingRef = useRef(false); const resolvedRef = useRef<{ range: UsageRange; query: UsageScreenQuery } | null>(null); const { targetKey, services, loadErrorTitle, describeError } = props; + const isCurrentTarget = useCallback( + () => targetCurrentRef.current && renderedTargetKeyRef.current === targetKey, + [targetKey], + ); const clear = () => { ticketRef.current += 1; blockedRef.current = true; @@ -105,6 +119,8 @@ export const UsageFeatureScope = forwardRef< }; if (targetKey !== renderedTargetKey) { setRenderedTargetKey(targetKey); + renderedTargetKeyRef.current = targetKey; + targetCurrentRef.current = true; clear(); } @@ -231,7 +247,12 @@ export const UsageFeatureScope = forwardRef< [snapshot, services, mountedRef, describeError], ); - useImperativeHandle(ref, () => ({ fenceTarget: clear })); + useImperativeHandle(ref, () => ({ + fenceTarget: () => { + targetCurrentRef.current = false; + clear(); + }, + })); const value = useMemo( () => ({ services, @@ -244,6 +265,9 @@ export const UsageFeatureScope = forwardRef< pagingProgress, reload, loadMore, + isCurrentTarget, + pricingEditor, + setPricingEditor, }), [ services, @@ -256,6 +280,8 @@ export const UsageFeatureScope = forwardRef< pagingProgress, reload, loadMore, + isCurrentTarget, + pricingEditor, ], ); return {props.children}; @@ -268,6 +294,12 @@ function useUsageScope(): UsageScopeValue { export function useUsageServices(): UsageServices { return useUsageScope().services; } +/** Only user input persists above the Host gate; pricing authority stays view-owned. */ +export function usePricingEditorDraft() { + const { pricingEditor, setPricingEditor } = useUsageScope(); + return [pricingEditor, setPricingEditor] as const; +} + export function useUsageStats(_range: UsageRange) { const { snapshot, ...scope } = useUsageScope(); return { diff --git a/apps/desktop/src/renderer/features/usage/testing.ts b/apps/desktop/src/renderer/features/usage/testing.ts index 882e0f3bf4f..301796ae7c7 100644 --- a/apps/desktop/src/renderer/features/usage/testing.ts +++ b/apps/desktop/src/renderer/features/usage/testing.ts @@ -17,4 +17,21 @@ * under the License. */ +// Test-only entry point for the Usage feature (issue #4425 / #2015). External +// test consumers import feature internals through this barrel so the +// renderer-architecture ratchet's "features import via index/testing/stories" +// rule stays satisfied. + +export { + draftFromPricing, + validatePricingDraft, + type PricingDraft, +} from './pricing-view-model.js'; +export { PricingEditor, formatCache, formatUsd } from './ui/pricing-editor.js'; +export { UsagePricingServicesProvider } from './pricing-services-context.js'; +export { UsageFeatureScope } from './services-context.js'; +export type { UsagePricingServices } from './pricing-ports.js'; +export type { UsageHostRef } from './ports.js'; +export { getPricingSettingsCopy } from '../../locales/settings-pricing-copy.js'; + export {useUsageStats} from './services-context.js'; diff --git a/apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx b/apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx new file mode 100644 index 00000000000..a9ed67d52ff --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx @@ -0,0 +1,633 @@ +/* + * 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 { useCallback, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { EmptyState, Heading, Skeleton, Text } from '@astryxdesign/core'; +import { Collapsible } from '@astryxdesign/core/Collapsible'; +import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; +import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; +import { Typeahead, createStaticSource, type SearchableItem } from '@astryxdesign/core/Typeahead'; +import { Banner, Button, HStack, TextInput, VStack } from '@maka/ui'; +import { ICON_SIZE, BarChart3, Pencil, Plus, RefreshCcw, RotateCcw, Search, Trash2 } from '@maka/ui/icons'; +import type { PricingSettingsCopy } from '../../../locales/settings-pricing-copy.js'; +import type { EffectivePricingEntry } from '@maka/runtime-host/protocol'; +import { usePricingController } from '../controller/pricing-controller.js'; +import type { UsagePricingTarget } from '../pricing-ports.js'; +import type { PricingDraftErrors } from '../pricing-view-model.js'; +import { UsageStatsTable, type UsageColumn } from './usage-stats-table.js'; + +/** A built-in catalog row as a Typeahead item (its `label` is the model key). */ +type CatalogItem = SearchableItem<{ row: EffectivePricingEntry }>; + +export function PricingEditor(props: { + readonly describeError: (error: unknown) => string; + readonly target: UsagePricingTarget | null; +}) { + const c = usePricingController({ + describeError: props.describeError, + target: props.target, + }); + const { copy } = c; + + const columns: UsageColumn[] = [ + { header: copy.headers[0], width: 300 }, + { header: copy.headers[1], width: 152 }, + // Rate columns carry a "… / 1M" header; the shared table's default numeric + // width (88) truncates the wider ones (esp. the cache headers, and even more + // so in English: "Cache read / 1M" / "Cache write / 1M"). Size each to show + // its full header — the panel is wide enough that this adds no scroll. + { header: copy.headers[2], numeric: true, width: 116 }, + { header: copy.headers[3], numeric: true, width: 116 }, + { header: copy.headers[4], numeric: true, width: 140 }, + { header: copy.headers[5], numeric: true, width: 140 }, + { header: copy.actionsHeader, width: 104 }, + ]; + + // The table shows only the user's overrides (#2015 / #2218 direction). The + // ~1.4k built-in catalog is never rendered as a table — it is reachable only + // through the Add flow's Typeahead picker. + const rows = c.overrideRows.map((row) => [ + row.pricing.modelKey, + pricingSourceLabel(row, copy), + formatUsd(row.pricing.inputUsdPer1M), + formatUsd(row.pricing.outputUsdPer1M), + formatCache(row.pricing.cacheReadUsdPer1M, copy), + formatCache(row.pricing.cacheWriteUsdPer1M, copy), + c.openEdit(row, trigger)} + onReset={(trigger) => c.openReset(row, trigger)} + />, + ]); + + return ( + + + + {copy.title} + {copy.subtitle} + + + + ); +} + +function PricingEditorDialog(props: { + controller: ReturnType; +}) { + const c = props.controller; + const { copy, draft, validation, editor } = c; + const formId = useId(); + const dialogRef = usePricingDialogFocus(); + const isEdit = editor?.mode === 'edit'; + const title = isEdit ? copy.editTitle : copy.addTitle; + // Show field errors only after a save attempt so a fresh Add form is quiet. + const [submitAttempts, setSubmitAttempts] = useState(0); + const catalogInvalid = submitAttempts > 0 && validation.errors.modelKey !== undefined; + // Typeahead exposes its field ref, but no native input props. Apply Pricing's + // required/error semantics to the combobox before the dialog focuses errors. + const bindCatalogField = useCallback((field: HTMLDivElement | null) => { + const input = field?.querySelector('[role="combobox"]'); + if (!input) return; + input.setAttribute('aria-required', 'true'); + if (catalogInvalid) input.setAttribute('aria-invalid', 'true'); + else input.removeAttribute('aria-invalid'); + }, [catalogInvalid]); + useLayoutEffect(() => { + if (submitAttempts > 0) dialogRef.current?.querySelector('[aria-invalid="true"]')?.focus(); + }, [submitAttempts, dialogRef]); + // Selection is the draft's identity, including while a Host reload has no + // catalog yet. Keeping a second selected-item state lets the two drift. + const picked = useMemo( + () => draft.modelKey ? { id: draft.modelKey, label: draft.modelKey } : null, + [draft.modelKey], + ); + const catalogSource = useMemo( + () => + createStaticSource( + c.catalogRows.map((row) => ({ id: row.pricing.modelKey, label: row.pricing.modelKey, auxiliaryData: { row } })), + ), + [c.catalogRows], + ); + + function submit() { + // Count each explicit submission so repeated Save clicks focus the error + // immediately; later typing must never consume a deferred focus request. + setSubmitAttempts((count) => count + 1); + if (validation.errors.cacheRead || validation.errors.cacheWrite) c.setCacheOpen(true); + void c.save(); + } + const fieldStatus = (message: string | undefined) => + submitAttempts > 0 && message ? ({ type: 'error' as const, message }) : undefined; + const errorMessage = (code: PricingDraftErrors[keyof PricingDraftErrors]): string | undefined => + code === undefined + ? undefined + : code === 'required' + ? copy.errorRequired + : code === 'invalid_rate' + ? copy.errorInvalidRate + : code === 'key_too_long' + ? copy.errorKeyTooLong + : copy.errorDuplicate; + + return ( + { + if (!open) c.closeEditor(); + }} + aria-label={title} + purpose="form" + width={480} + maxHeight="calc(100dvh - 64px)" + > + { if (!open) c.closeEditor(); }} />} + content={ + + { event.preventDefault(); submit(); }}> + {isEdit ? ( + // Editing an existing override: the key is fixed, shown read-only. + c.setField('modelKey', value)} + label={copy.modelKeyLabel} + isReadOnly + width="100%" + /> + ) : editor?.mode === 'catalog' ? ( + // Add via the built-in catalog: Typeahead renders only the top + // matches (never the ~1.4k-row list), and a pick pre-fills the + // built-in price. Its `value.label` is the model key it commits. + + + ref={bindCatalogField} + label={copy.catalogPickerLabel} + isRequired + searchSource={catalogSource} + value={picked} + onChange={(item) => { + if (item) { + c.pickCatalogModel(item.auxiliaryData!.row); + } else { + c.clearModel(); + } + }} + placeholder={copy.catalogPickerPlaceholder} + emptySearchResultsText={copy.catalogEmptyResults} + startIcon={ + ); +} + +/** Keep focus in an open dialog when a pending action disables its button or + * review removes the focused control. Preserve any focus the user moved. */ +function usePricingDialogFocus() { + const ref = useRef(null); + useLayoutEffect(() => { + const dialog = ref.current; + if (dialog?.open && dialog.ownerDocument.activeElement === dialog.ownerDocument.body) { + dialog.focus(); + } + }); + return ref; +} + +function PricingWriteNotice(props: { + writeState: ReturnType['writeState']; + latestEntry: EffectivePricingEntry | null; + copy: PricingSettingsCopy; + onRefresh(): void; + refreshBusy: boolean; +}) { + const { writeState, latestEntry, copy } = props; + switch (writeState.kind) { + case 'conflict': { + // An `outcome_unknown` conflict is uncertain, not a confirmed external + // change — it must not be described as one. + const uncertain = writeState.reason === 'outcome_unknown'; + const latest = ` ${latestPricingDescription(latestEntry, copy)}`; + return ( + + ); + } + case 'refresh_failed': + case 'reconcile_unavailable': { + const committed = writeState.kind === 'refresh_failed'; + return ( + } + /> + ); + } + case 'idle': + return null; + } +} + +function latestPricingDescription(entry: EffectivePricingEntry | null, copy: PricingSettingsCopy): string { + return entry ? copy.conflictLatest( + pricingSourceLabel(entry, copy), + formatUsd(entry.pricing.inputUsdPer1M), + formatUsd(entry.pricing.outputUsdPer1M), + formatCache(entry.pricing.cacheReadUsdPer1M, copy), + formatCache(entry.pricing.cacheWriteUsdPer1M, copy), + ) : copy.latestUnpriced; +} + +/** Skeleton rows that mirror the real table's column count for a zero-shift load. + * Height 16 (a DESIGN.md-allowed bar height) and a small row count matching the + * overrides surface's typical ready state (a handful of custom rows). */ +function pricingSkeletonRows(columnCount: number): Array> { + return Array.from({ length: 3 }, () => + Array.from({ length: columnCount }, (_unused, column) => ( + + )), + ); +} + +function pricingSourceLabel(row: EffectivePricingEntry, copy: PricingSettingsCopy): string { + if (row.source === 'builtin') return copy.sourceBuiltin; + return row.resetEffect === 'restore_builtin' ? copy.sourceCustomFallback : copy.sourceCustomOnly; +} + +// Display formatting must round-trip the canonical value without losing +// precision, and a positive rate must never render as `$0` (#2015). Raw +// interpolation uses JS shortest-round-trip `Number.toString`, so `2.5` stays +// `$2.5` and `0.075` stays `$0.075` — never `.toFixed`-collapsed to `$0`. +export function formatUsd(value: number): string { + return `$${value}`; +} + +// An omitted cache rate ("not set", no cache charge) stays distinct from an +// explicit `0` (#2015): only `undefined` maps to the not-set copy. +export function formatCache(value: number | undefined, copy: PricingSettingsCopy): string { + return value === undefined ? copy.cacheNotSet : `$${value}`; +} diff --git a/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx index d8af9036304..b0245916a55 100644 --- a/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx +++ b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx @@ -52,6 +52,7 @@ import { RefreshCcw, Search, } from '@maka/ui/icons'; +import { PricingEditor } from './pricing-editor.js'; import { getUsageSettingsCopy, type UsageSettingsCopy, @@ -65,6 +66,7 @@ import { useUsageStats, type UsagePagingProgress, } from '../services-context.js'; +import type { UsageHostRef } from '../ports.js'; type UsageActiveTab = UsageSettings['activeTab']; @@ -112,6 +114,8 @@ function TokenTooltipContent(props: { export function UsageSettingsView(props: { settings: UsageSettings; describeError(error: unknown): string; + /** Settings-selected Runtime Host, threaded to the Pricing tab (per-Host overrides). */ + runtimeHost: UsageHostRef | undefined; onOpenSession?(sessionId: string): void; }) { const services = useUsageServices(); @@ -135,6 +139,7 @@ export function UsageSettingsView(props: { pagingProgress, loadMore, screenVersion, + isCurrentTarget, } = useUsageStats(persistedUsage.range); const [refreshing, setRefreshing] = useState(false); const usageRefreshGuard = useActionGuard<'refresh'>(); @@ -216,12 +221,11 @@ export function UsageSettingsView(props: { ); }, [stats, usageDraft.status, normalizedModelFilter]); - const tabCounts: Record = { + const tabCounts: Record, number> = { requests: stats?.navigation?.activityTotal ?? stats?.logs.length ?? 0, providers: stats?.byProvider.length ?? 0, models: stats?.byModel.length ?? 0, tools: stats?.byTool.length ?? 0, - pricing: stats?.pricing.length ?? 0, }; function updateUsage(patch: Partial): Promise { @@ -271,7 +275,7 @@ export function UsageSettingsView(props: { return ( <> - {state === 'stale' || state === 'error' ? ( + {usageDraft.activeTab !== 'pricing' && (state === 'stale' || state === 'error') ? ( ) : null} - {usageIncomplete ? ( + {usageIncomplete && usageDraft.activeTab !== 'pricing' ? ( ) : null} -
-
- void setRange(value as UsageRange)} - > - {(['24h', '7d', '30d', 'all'] as const).map((value, index) => ( - - ))} - -
+ {/* #2015 acceptance #2: the Pricing tab is not time-scoped, so the Usage + range/summary toolbar is hidden there — the date range cannot be + mistaken for a Pricing scope. */} + {usageDraft.activeTab !== 'pricing' ? ( +
+
+ void setRange(value as UsageRange)} + > + {(['24h', '7d', '30d', 'all'] as const).map((value, index) => ( + + ))} + +
-
- - - - )} - > - {formatCompactTokenCount(stats.summary.totalTokens)} - - ) : '—'} - detail={stats ? copy.tokenDetail( - formatCompactTokenCount(stats.summary.inputTokens), - formatCompactTokenCount(stats.summary.outputTokens), - ) : undefined} - /> - - )} - > - {formatCompactTokenCount(stats.summary.cacheTokens)} - - ) : '—'} - detail={stats ? copy.cacheDetail( - formatCompactTokenCount(stats.summary.cacheMiss), - formatCompactTokenCount(stats.summary.cacheRead), - formatCompactTokenCount(stats.summary.cacheCreation), - ) : undefined} - /> +
+ + + + )} + > + {formatCompactTokenCount(stats.summary.totalTokens)} + + ) : '—'} + detail={stats ? copy.tokenDetail( + formatCompactTokenCount(stats.summary.inputTokens), + formatCompactTokenCount(stats.summary.outputTokens), + ) : undefined} + /> + + )} + > + {formatCompactTokenCount(stats.summary.cacheTokens)} + + ) : '—'} + detail={stats ? copy.cacheDetail( + formatCompactTokenCount(stats.summary.cacheMiss), + formatCompactTokenCount(stats.summary.cacheRead), + formatCompactTokenCount(stats.summary.cacheCreation), + ) : undefined} + /> +
-
+ ) : null}
@@ -385,7 +394,7 @@ export function UsageSettingsView(props: { {tabCounts.providers}} /> {tabCounts.models}} /> {tabCounts.tools}} /> - {tabCounts.pricing}} /> +
@@ -438,7 +447,12 @@ export function UsageSettingsView(props: { {usageDraft.activeTab === 'pricing' ? (
- +
) : null}
@@ -592,8 +606,9 @@ function UsageRequestsPanel(props: {
) : undefined} columns={[ - // Leave room for the localized timestamp and cell padding with Linux fonts. - { header: props.copy.tables.requestHeaders[0], width: 176 }, + // Localized dates can include a day-period marker; leave room for + // that text and native system-font metrics in the fixed table column. + { header: props.copy.tables.requestHeaders[0], width: 224 }, { header: props.copy.tables.requestHeaders[1], width: 72 }, { header: props.copy.tables.requestHeaders[2], grow: true }, { header: props.copy.tables.requestHeaders[3], width: 168 }, @@ -751,22 +766,6 @@ function UsageToolsPanel(props: { stats: UsageStats | null; copy: UsageSettingsC ); } -function UsagePricingPanel(props: { stats: UsageStats | null; copy: UsageSettingsCopy }) { - return ( - [row.provider, row.model, `$${row.inputPerMTokUsd}`, `$${row.outputPerMTokUsd}`])} - empty={{ Icon: BarChart3, title: props.copy.tables.noPricing, body: props.copy.tables.pricingEmptyBody }} - /> - ); -} - // ── Request-log cell helpers ──────────────────────────────────────────────── function usageRequestKindLabel(kind: UsageStats['logs'][number]['kind'], copy: UsageSettingsCopy) { diff --git a/apps/desktop/src/renderer/locales/settings-pricing-copy.ts b/apps/desktop/src/renderer/locales/settings-pricing-copy.ts new file mode 100644 index 00000000000..eaa72608d84 --- /dev/null +++ b/apps/desktop/src/renderer/locales/settings-pricing-copy.ts @@ -0,0 +1,359 @@ +/* + * 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 type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +/** User-visible copy for the editable Pricing settings surface. */ +export type PricingSettingsCopy = { + title: string; + subtitle: string; + refresh: string; + add: string; + addNeedsSnapshot: string; + loading: string; + loadFailedTitle: string; + loadFailedBody: string; + retry: string; + emptyTitle: string; + emptyBody: string; + tableAria: string; + // catalog picker (Add flow) + catalogPickerLabel: string; + catalogPickerPlaceholder: string; + catalogEmptyResults: string; + manualEntryToggle: string; + catalogToggle: string; + builtinPrefillHint: string; + headers: readonly [string, string, string, string, string, string]; + actionsHeader: string; + sourceBuiltin: string; + sourceCustomFallback: string; + sourceCustomOnly: string; + cacheNotSet: string; + edit: string; + reset: string; + delete: string; + editAria(modelKey: string): string; + resetAria(modelKey: string): string; + deleteAria(modelKey: string): string; + // editor + addTitle: string; + editTitle: string; + modelKeyLabel: string; + modelKeyPlaceholder: string; + keyHelp: string; + inputLabel: string; + outputLabel: string; + rateHelp: string; + cacheSection: string; + cacheReadLabel: string; + cacheWriteLabel: string; + cacheHelp: string; + cancel: string; + save: string; + // field errors + errorRequired: string; + errorInvalidRate: string; + errorKeyTooLong: string; + errorDuplicate: string; + // outcomes + saved: string; + synchronized: string; + conflictTitle: string; + conflictTitleUnknown: string; + conflictBody: string; + conflictBodyUnknown: string; + conflictLatest( + source: string, + input: string, + output: string, + cacheRead: string, + cacheWrite: string, + ): string; + latestUnpriced: string; + reviewSave: string; + hostChangedTitle: string; + hostChangedBody: string; + reviewHostChange: string; + refreshFailedTitle: string; + refreshFailedBody: string; + reconcileTitle: string; + reconcileBody: string; + writeBlockedReason: string; + saveFailed: string; + // reset / delete confirm + resetTitle: string; + resetBody(modelKey: string): string; + deleteTitle: string; + deleteBody(modelKey: string): string; + confirmReset: string; + confirmDelete: string; + reviewReset: string; + reviewDelete: string; + resetFailed: string; +}; + +const SETTINGS_PRICING_COPY = { + 'zh-CN': { + title: '定价配置', + subtitle: + '美元 / 每百万 token。用于新激活的模型调用;进行中的运行沿用其开始时的价格。历史费用不会重算,最终以供应商结算为准。', + refresh: '刷新', + add: '添加定价', + addNeedsSnapshot: '需先加载定价后才能添加。', + loading: '正在加载定价…', + loadFailedTitle: '无法加载定价', + loadFailedBody: '读取运行时主机的定价快照失败,请重试。', + retry: '重试', + emptyTitle: '暂无自定义定价', + emptyBody: '尚未覆盖任何模型价格。点击「添加定价」,从内置目录中选择一个模型。', + tableAria: '自定义模型定价表', + catalogPickerLabel: '选择模型', + catalogPickerPlaceholder: '搜索模型名…', + catalogEmptyResults: '无匹配的内置模型', + manualEntryToggle: '模型不在列表中?手动输入', + catalogToggle: '从目录选择', + builtinPrefillHint: '已按内置价预填,可按需修改。', + headers: ['模型', '来源', '输入 / 1M', '输出 / 1M', '缓存读 / 1M', '缓存写 / 1M'], + actionsHeader: '操作', + sourceBuiltin: '内置', + sourceCustomFallback: '自定义 · 可回退', + sourceCustomOnly: '仅自定义', + cacheNotSet: '未设置(Maka 估算不计缓存费用)', + edit: '编辑', + reset: '重置', + delete: '删除', + editAria: (modelKey: string) => `编辑「${modelKey}」定价`, + resetAria: (modelKey: string) => `重置「${modelKey}」定价`, + deleteAria: (modelKey: string) => `删除「${modelKey}」定价`, + addTitle: '添加定价', + editTitle: '编辑定价', + modelKeyLabel: '模型键', + modelKeyPlaceholder: '例如 anthropic:claude-sonnet-4-5', + keyHelp: '粘贴用量记录中的精确运行时查找键(区分大小写,不要用连接别名)。', + inputLabel: '输入价格', + outputLabel: '输出价格', + rateHelp: '美元 / 每百万 token;0 表示免费(如本地模型)。', + cacheSection: '缓存价格(可选)', + cacheReadLabel: '缓存读取', + cacheWriteLabel: '缓存写入', + cacheHelp: '留空表示未设置(不计缓存费用),与显式填 0 不同。', + cancel: '取消', + save: '保存', + errorRequired: '必填', + errorInvalidRate: '请输入有效价格(≥ 0)', + errorKeyTooLong: '模型键过长(上限 128 字符)', + errorDuplicate: '该模型已在列表中,请直接编辑对应行', + saved: '定价已保存', + synchronized: '当前定价已与你的修改一致', + conflictTitle: '定价已被其他修改更新', + conflictTitleUnknown: '无法确认上次修改的结果', + conflictBody: '该模型的价格已被其他修改更新。请核对最新值后,基于最新版本再次保存。', + conflictBodyUnknown: '上次修改可能已生效、也可能未生效。请核对最新值后,再决定是否基于最新版本重新保存。', + conflictLatest: (source, input, output, cacheRead, cacheWrite) => + `当前最新:${source};输入 ${input} / 输出 ${output} / 缓存读 ${cacheRead} / 缓存写 ${cacheWrite}`, + latestUnpriced: '当前主机尚未设置该模型的价格。', + reviewSave: '核对并保存', + hostChangedTitle: '运行时主机已变化', + hostChangedBody: '草稿已保留。请等待新主机的定价加载完成,核对后再继续保存。', + reviewHostChange: '已核对新主机定价', + refreshFailedTitle: '已保存,但无法加载最新定价', + refreshFailedBody: '保存已完成,但未能读取最新定价。请刷新后再进行修改。', + reconcileTitle: '无法确认结果', + reconcileBody: '未能确认这次修改的结果。请刷新定价后再进行修改。', + writeBlockedReason: '需先刷新最新定价后才能修改。', + saveFailed: '保存定价失败', + resetTitle: '重置定价', + resetBody: (modelKey: string) => `将删除「${modelKey}」的自定义价格,恢复为内置定价。`, + deleteTitle: '删除定价', + deleteBody: (modelKey: string) => + `将删除「${modelKey}」的定价;新激活的调用将变为未定价(不计入 Maka 的费用估算,与显式填 0 不同),进行中的运行沿用其开始时的快照。`, + confirmReset: '重置', + confirmDelete: '删除', + reviewReset: '核对并重置', + reviewDelete: '核对并删除', + resetFailed: '操作失败', + }, + 'zh-TW': { + title: '定價設定', + subtitle: + '美元 / 每百萬 token。用於新啟用的模型呼叫;進行中的執行沿用其開始時的價格。歷史費用不會重算,最終以供應商結算為準。', + refresh: '重新整理', + add: '新增定價', + addNeedsSnapshot: '需先載入定價後才能新增。', + loading: '正在載入定價…', + loadFailedTitle: '無法載入定價', + loadFailedBody: '讀取執行時主機的定價快照失敗,請重試。', + retry: '重試', + emptyTitle: '暫無自訂定價', + emptyBody: '尚未覆寫任何模型價格。點選「新增定價」,從內建目錄中選擇一個模型。', + tableAria: '自訂模型定價表', + catalogPickerLabel: '選擇模型', + catalogPickerPlaceholder: '搜尋模型名稱…', + catalogEmptyResults: '無相符的內建模型', + manualEntryToggle: '模型不在清單中?手動輸入', + catalogToggle: '從目錄選擇', + builtinPrefillHint: '已依內建價格預填,可視需要修改。', + headers: ['模型', '來源', '輸入 / 1M', '輸出 / 1M', '快取讀 / 1M', '快取寫 / 1M'], + actionsHeader: '操作', + sourceBuiltin: '內建', + sourceCustomFallback: '自訂 · 可回退', + sourceCustomOnly: '僅自訂', + cacheNotSet: '未設定(Maka 估算不計快取費用)', + edit: '編輯', + reset: '重設', + delete: '刪除', + editAria: (modelKey: string) => `編輯「${modelKey}」定價`, + resetAria: (modelKey: string) => `重設「${modelKey}」定價`, + deleteAria: (modelKey: string) => `刪除「${modelKey}」定價`, + addTitle: '新增定價', + editTitle: '編輯定價', + modelKeyLabel: '模型鍵', + modelKeyPlaceholder: '例如 anthropic:claude-sonnet-4-5', + keyHelp: '貼上用量記錄中的精確執行時查找鍵(區分大小寫,請勿使用連線別名)。', + inputLabel: '輸入價格', + outputLabel: '輸出價格', + rateHelp: '美元 / 每百萬 token;0 表示免費(如本機模型)。', + cacheSection: '快取價格(選填)', + cacheReadLabel: '快取讀取', + cacheWriteLabel: '快取寫入', + cacheHelp: '留空表示未設定(不計快取費用),與明確填入 0 不同。', + cancel: '取消', + save: '儲存', + errorRequired: '必填', + errorInvalidRate: '請輸入有效價格(≥ 0)', + errorKeyTooLong: '模型鍵過長(上限 128 個字元)', + errorDuplicate: '該模型已在清單中,請直接編輯對應的項目', + saved: '定價已儲存', + synchronized: '目前定價已與你的修改一致', + conflictTitle: '定價已被其他修改更新', + conflictTitleUnknown: '無法確認上次修改的結果', + conflictBody: '該模型的價格已被其他修改更新。請核對最新值後,基於最新版本再次儲存。', + conflictBodyUnknown: '上次修改可能已生效,也可能未生效。請核對最新值後,再決定是否基於最新版本重新儲存。', + conflictLatest: (source, input, output, cacheRead, cacheWrite) => + `目前最新:${source};輸入 ${input} / 輸出 ${output} / 快取讀 ${cacheRead} / 快取寫 ${cacheWrite}`, + latestUnpriced: '目前主機尚未設定該模型的價格。', + reviewSave: '核對並儲存', + hostChangedTitle: '執行時主機已變更', + hostChangedBody: '草稿已保留。請等待新主機的定價載入完成,核對後再繼續儲存。', + reviewHostChange: '已核對新主機定價', + refreshFailedTitle: '已儲存,但無法載入最新定價', + refreshFailedBody: '儲存已完成,但無法讀取最新定價。請重新整理後再進行修改。', + reconcileTitle: '無法確認結果', + reconcileBody: '無法確認這次修改的結果。請重新整理定價後再進行修改。', + writeBlockedReason: '需先重新整理最新定價後才能修改。', + saveFailed: '儲存定價失敗', + resetTitle: '重設定價', + resetBody: (modelKey: string) => `將刪除「${modelKey}」的自訂價格,還原為內建定價。`, + deleteTitle: '刪除定價', + deleteBody: (modelKey: string) => + `將刪除「${modelKey}」的定價;新啟用的呼叫將變為未定價(不計入 Maka 的費用估算,與明確填入 0 不同),進行中的執行沿用其開始時的快照。`, + confirmReset: '重設', + confirmDelete: '刪除', + reviewReset: '核對並重設', + reviewDelete: '核對並刪除', + resetFailed: '操作失敗', + }, + en: { + title: 'Pricing', + subtitle: + 'USD per 1M tokens. Applies to newly activated model work; an active run keeps its starting prices. Historical costs are not recalculated. Provider billing is authoritative.', + refresh: 'Refresh', + add: 'Add price', + addNeedsSnapshot: 'Pricing must load before you can add an override.', + loading: 'Loading pricing…', + loadFailedTitle: 'Could not load pricing', + loadFailedBody: 'Reading the Runtime Host pricing snapshot failed. Try again.', + retry: 'Retry', + emptyTitle: 'No custom pricing', + emptyBody: + 'You haven’t overridden any model prices yet. Click "Add price" and pick a model from the built-in catalog.', + tableAria: 'Custom model pricing table', + catalogPickerLabel: 'Select model', + catalogPickerPlaceholder: 'Search models…', + catalogEmptyResults: 'No matching built-in models', + manualEntryToggle: 'Model not listed? Enter it manually', + catalogToggle: 'Choose from catalog', + builtinPrefillHint: 'Pre-filled with the built-in price; adjust as needed.', + headers: ['Model', 'Source', 'Input / 1M', 'Output / 1M', 'Cache read / 1M', 'Cache write / 1M'], + actionsHeader: 'Actions', + sourceBuiltin: 'Built-in', + sourceCustomFallback: 'Custom · has fallback', + sourceCustomOnly: 'Custom-only', + cacheNotSet: 'Not set (no cache charge in Maka estimates)', + edit: 'Edit', + reset: 'Reset', + delete: 'Delete', + editAria: (modelKey: string) => `Edit pricing for ${modelKey}`, + resetAria: (modelKey: string) => `Reset pricing for ${modelKey}`, + deleteAria: (modelKey: string) => `Delete pricing for ${modelKey}`, + addTitle: 'Add price', + editTitle: 'Edit price', + modelKeyLabel: 'Model key', + modelKeyPlaceholder: 'e.g. anthropic:claude-sonnet-4-5', + keyHelp: + 'Paste the exact Runtime lookup key from your usage records (case-sensitive; not the connection slug).', + inputLabel: 'Input price', + outputLabel: 'Output price', + rateHelp: 'USD per 1M tokens; 0 means free (e.g. local models).', + cacheSection: 'Cache prices (optional)', + cacheReadLabel: 'Cache read', + cacheWriteLabel: 'Cache write', + cacheHelp: 'Leave blank for "Not set" (no cache charge) — distinct from an explicit 0.', + cancel: 'Cancel', + save: 'Save', + errorRequired: 'Required', + errorInvalidRate: 'Enter a valid price (≥ 0)', + errorKeyTooLong: 'Model key is too long (128 characters max)', + errorDuplicate: 'This model is already listed — edit its row instead', + saved: 'Pricing saved', + synchronized: 'Pricing already matches your change', + conflictTitle: 'Pricing changed elsewhere', + conflictTitleUnknown: "Couldn't confirm the last change", + conflictBody: "This model's price was changed elsewhere. Review the latest value, then save again against the latest revision.", + conflictBodyUnknown: 'The last change may or may not have applied. Review the latest value, then decide whether to save again against the latest revision.', + conflictLatest: (source, input, output, cacheRead, cacheWrite) => + `Latest: ${source}; input ${input} / output ${output} / cache read ${cacheRead} / cache write ${cacheWrite}`, + latestUnpriced: 'This model has no price on the current Host.', + reviewSave: 'Review & save', + hostChangedTitle: 'Runtime Host changed', + hostChangedBody: 'Your draft was preserved. Wait for pricing from the new Host, then review it before saving.', + reviewHostChange: 'Reviewed new Host pricing', + refreshFailedTitle: 'Saved, but the latest pricing could not be loaded', + refreshFailedBody: 'The save completed but the latest prices could not be loaded. Refresh before changing pricing again.', + reconcileTitle: "Couldn't confirm the result", + reconcileBody: "The result of this change could not be confirmed. Reload pricing before changing it again.", + writeBlockedReason: 'Refresh the latest pricing before making changes.', + saveFailed: 'Failed to save pricing', + resetTitle: 'Reset pricing', + resetBody: (modelKey: string) => `This removes the custom price for ${modelKey} and restores its built-in pricing.`, + deleteTitle: 'Delete pricing', + deleteBody: (modelKey: string) => + `This deletes pricing for ${modelKey}; newly activated work becomes unpriced (excluded from Maka's cost estimates — distinct from an explicit $0), while an active run keeps its starting snapshot.`, + confirmReset: 'Reset', + confirmDelete: 'Delete', + reviewReset: 'Review & reset', + reviewDelete: 'Review & delete', + resetFailed: 'Action failed', + }, +} satisfies UiCatalog; + +export function getPricingSettingsCopy(locale: UiLocale): PricingSettingsCopy { + return SETTINGS_PRICING_COPY[locale]; +} diff --git a/apps/desktop/src/renderer/locales/settings-usage-copy.ts b/apps/desktop/src/renderer/locales/settings-usage-copy.ts index bd981af8f58..266348fa28f 100644 --- a/apps/desktop/src/renderer/locales/settings-usage-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-usage-copy.ts @@ -31,11 +31,11 @@ export type UsageSettingsCopy = { summaryOnly: string; showDetails: string; filteredEmpty: string; filteredEmptyHelp: string; requestEmpty: string; costUnavailable: string; incompleteTitle: string; incompleteBody: string; tables: { - providersAria: string; modelsAria: string; toolsAria: string; pricingAria: string; requestsAria: string; - providerHeaders: string[]; modelHeaders: string[]; toolHeaders: string[]; pricingHeaders: string[]; requestHeaders: string[]; - noPricing: string; modelKind: string; toolKind: string; unknown: string; untitledSession: string; openSession(label: string): string; success: string; error: string; aborted: string; + providersAria: string; modelsAria: string; toolsAria: string; requestsAria: string; + providerHeaders: string[]; modelHeaders: string[]; toolHeaders: string[]; requestHeaders: string[]; + modelKind: string; toolKind: string; unknown: string; untitledSession: string; openSession(label: string): string; success: string; error: string; aborted: string; providerEmptyTitle: string; providerEmptyBody: string; modelEmptyTitle: string; modelEmptyBody: string; - toolEmptyTitle: string; toolEmptyBody: string; pricingEmptyBody: string; + toolEmptyTitle: string; toolEmptyBody: string; }; }; const settingsUsageCopyZhCn: UsageSettingsCopy = { @@ -53,14 +53,13 @@ const settingsUsageCopyZhCn: UsageSettingsCopy = { costUnavailable: '费用未知', incompleteTitle: '统计可能不完整', incompleteBody: '部分记录未能读取、尚未纳入统计或超出展示上限,实际用量可能高于此处显示。', tables: { - providersAria: '使用统计供应商统计表', modelsAria: '使用统计模型统计表', toolsAria: '使用统计工具统计表', pricingAria: '使用统计定价配置表', requestsAria: '使用统计活动记录表', + providersAria: '使用统计供应商统计表', modelsAria: '使用统计模型统计表', toolsAria: '使用统计工具统计表', requestsAria: '使用统计活动记录表', providerHeaders: ['供应商', '调用', 'Token', '费用'], modelHeaders: ['模型', '调用', 'Token', '费用'], toolHeaders: ['工具', '调用', '成功', '错误', '平均耗时'], - pricingHeaders: ['供应商', '模型', '输入 / 1M', '输出 / 1M'], requestHeaders: ['时间', '类型', '对象', '任务', 'Token', '费用', '延迟', '状态'], - noPricing: '暂无定价覆盖配置', modelKind: '模型', toolKind: '工具', unknown: '未知', untitledSession: '未命名会话', openSession: (label) => `打开会话「${label}」`, success: '成功', error: '错误', aborted: '已中止', + requestHeaders: ['时间', '类型', '对象', '任务', 'Token', '费用', '延迟', '状态'], + modelKind: '模型', toolKind: '工具', unknown: '未知', untitledSession: '未命名会话', openSession: (label) => `打开会话「${label}」`, success: '成功', error: '错误', aborted: '已中止', providerEmptyTitle: '暂无供应商用量', providerEmptyBody: '完成一次模型调用后,这里会按供应商聚合调用数、Token 与费用。', modelEmptyTitle: '暂无模型用量', modelEmptyBody: '完成一次模型调用后,这里会按模型聚合调用数、Token 与费用。', toolEmptyTitle: '暂无工具调用', toolEmptyBody: '智能体调用工具后,这里会按工具聚合调用次数、成功、错误与平均耗时。', - pricingEmptyBody: '未配置定价覆盖时,费用按内置模型定价表结算;在此可为特定模型登记自定义价格。', }, }; const settingsUsageCopyZhTw: UsageSettingsCopy = { @@ -78,14 +77,13 @@ const settingsUsageCopyZhTw: UsageSettingsCopy = { incompleteBody: '部分記錄可能無法讀取、尚未納入統計或超出顯示上限,實際用量可能高於此處顯示。', showDetails: '顯示明細', filteredEmpty: '沒有符合篩選條件的請求記錄', filteredEmptyHelp: '調整或清除篩選條件後可檢視全部請求記錄。', requestEmpty: '暫無請求記錄', tables: { - providersAria: '使用統計供應商統計表', modelsAria: '使用統計模型統計表', toolsAria: '使用統計工具統計表', pricingAria: '使用統計定價設定表', requestsAria: '使用統計請求記錄表', + providersAria: '使用統計供應商統計表', modelsAria: '使用統計模型統計表', toolsAria: '使用統計工具統計表', requestsAria: '使用統計請求記錄表', providerHeaders: ['供應商', '請求', 'Token', '費用'], modelHeaders: ['模型', '請求', 'Token', '費用'], toolHeaders: ['工具', '呼叫', '成功', '錯誤', '平均耗時'], - pricingHeaders: ['供應商', '模型', '輸入 / 1M', '輸出 / 1M'], requestHeaders: ['時間', '型別', '物件', '任務', 'Token', '費用', '延遲', '狀態'], - noPricing: '暫無定價覆蓋設定', modelKind: '模型', toolKind: '工具', unknown: '未知', openSession: (label) => `開啟 ${label}`, untitledSession: '未命名會話', success: '成功', error: '錯誤', aborted: '已中止', + requestHeaders: ['時間', '型別', '物件', '任務', 'Token', '費用', '延遲', '狀態'], + modelKind: '模型', toolKind: '工具', unknown: '未知', openSession: (label) => `開啟 ${label}`, untitledSession: '未命名會話', success: '成功', error: '錯誤', aborted: '已中止', providerEmptyTitle: '暫無供應商用量', providerEmptyBody: '完成一次模型請求後,這裡會按供應商聚合請求數、Token 與費用。', modelEmptyTitle: '暫無模型用量', modelEmptyBody: '完成一次模型請求後,這裡會按模型聚合請求數、Token 與費用。', toolEmptyTitle: '暫無工具呼叫', toolEmptyBody: '智慧體呼叫工具後,這裡會按工具聚合呼叫次數、成功、錯誤與平均耗時。', - pricingEmptyBody: '未設定定價覆蓋時,費用按內建模型定價表結算;在此可為特定模型登記自訂價格。', }, }; const settingsUsageCopyEn: UsageSettingsCopy = { @@ -103,14 +101,13 @@ const settingsUsageCopyEn: UsageSettingsCopy = { costUnavailable: 'Cost unavailable', incompleteTitle: 'These numbers may be incomplete', incompleteBody: 'Some records could not be read, are not folded in yet, or exceed the display limit, so real usage may be higher than shown.', tables: { - providersAria: 'Usage by provider', modelsAria: 'Usage by model', toolsAria: 'Usage by tool', pricingAria: 'Usage pricing configuration', requestsAria: 'Usage activity log', + providersAria: 'Usage by provider', modelsAria: 'Usage by model', toolsAria: 'Usage by tool', requestsAria: 'Usage activity log', providerHeaders: ['Provider', 'Calls', 'Tokens', 'Cost'], modelHeaders: ['Model', 'Calls', 'Tokens', 'Cost'], toolHeaders: ['Tool', 'Calls', 'Success', 'Errors', 'Average duration'], - pricingHeaders: ['Provider', 'Model', 'Input / 1M', 'Output / 1M'], requestHeaders: ['Time', 'Type', 'Target', 'Task', 'Tokens', 'Cost', 'Latency', 'Status'], - noPricing: 'No pricing overrides', modelKind: 'Model', toolKind: 'Tool', unknown: 'Unknown', untitledSession: 'Untitled session', openSession: (label) => `Open session "${label}"`, success: 'Success', error: 'Error', aborted: 'Aborted', + requestHeaders: ['Time', 'Type', 'Target', 'Task', 'Tokens', 'Cost', 'Latency', 'Status'], + modelKind: 'Model', toolKind: 'Tool', unknown: 'Unknown', untitledSession: 'Untitled session', openSession: (label) => `Open session "${label}"`, success: 'Success', error: 'Error', aborted: 'Aborted', providerEmptyTitle: 'No provider usage', providerEmptyBody: 'After a model call, provider call counts, tokens, and costs appear here.', modelEmptyTitle: 'No model usage', modelEmptyBody: 'After a model call, call counts, tokens, and costs appear here by model.', toolEmptyTitle: 'No tool calls', toolEmptyBody: 'After an agent calls a tool, calls, successes, errors, and average duration appear here by tool.', - pricingEmptyBody: 'Without pricing overrides, costs use the built-in model pricing table. Add custom prices here for specific models.', }, }; diff --git a/apps/desktop/src/renderer/platform/desktop/create-usage-pricing-services.ts b/apps/desktop/src/renderer/platform/desktop/create-usage-pricing-services.ts new file mode 100644 index 00000000000..5cd6e67f22e --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-usage-pricing-services.ts @@ -0,0 +1,39 @@ +/* + * 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 type { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { UsagePricingServices } from '../../features/usage'; + +export type DesktopUsagePricingBridge = Pick; + +// Desktop adapter for the editable Pricing surface (#2015). It owns the only +// `window.maka.settings.pricing` bridge access, keeping it in the platform zone +// so no new bridge path lands in a frozen legacy-closure file. The `host` is the +// settings-selected Runtime Host threaded from the feature, so pricing reads and +// writes target the same Host as the rest of the settings page. The pricing +// bridge requires that Host explicitly, so this path cannot fall back to the +// app's active Host. +export function createDesktopUsagePricingServices( + bridge: DesktopUsagePricingBridge = window.maka, +): UsagePricingServices { + return { + loadPricing: (host) => bridge.settings.pricing.load(host), + mutatePricing: (host, base, mutation) => bridge.settings.pricing.mutate(base, mutation, host), + }; +} diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index 4518b066517..906cbccb0fd 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -1193,7 +1193,7 @@ function SettingsPageBody(props: { case 'usage': // State lives in the persistent `UsageScopeMount` above the loading gate; // this view is disposable and reads it from context. - return ; + return ; case 'bot-chat': return ( settingsActionErrorMessage(error, locale)} + runtimeHost={props.runtimeHost} onOpenSession={props.onOpenSession} /> diff --git a/apps/desktop/src/renderer/styles/settings/usage.css b/apps/desktop/src/renderer/styles/settings/usage.css index 9924e230e80..22ab802b52a 100644 --- a/apps/desktop/src/renderer/styles/settings/usage.css +++ b/apps/desktop/src/renderer/styles/settings/usage.css @@ -106,6 +106,12 @@ min-width: 0; } +/* Astryx stacks own pricing layout; allow their table and heading to shrink. */ +.settingsPricing, +.settingsPricingHeading { + min-width: 0; +} + /* 任务 column: the session-name link truncates inside its fixed column width and surfaces the full name via the button tooltip. StyleX owns the button's own layout; these rules only cap its width and ellipsis the label text (descendant diff --git a/apps/desktop/src/shared/desktop-pricing-decode.ts b/apps/desktop/src/shared/desktop-pricing-decode.ts new file mode 100644 index 00000000000..8c05daeb3a1 --- /dev/null +++ b/apps/desktop/src/shared/desktop-pricing-decode.ts @@ -0,0 +1,109 @@ +/* + * 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. + */ + +// Runtime guard for the Pricing Settings types declared in `desktop-pricing.d.ts`. +// It is imported only by the Main IPC layer (never the renderer), so it stays a +// plain `.ts` outside the renderer-root/legacy-AppShell closure the architecture +// ratchet tracks — the types themselves are declaration-only for that reason. + +import { + comparePricingModelKeys, + validateCanonicalPricingConfig, +} from '@maka/core/usage-stats/pricing'; +import type { EffectivePricingEntry } from '@maka/runtime-host/protocol'; +import type { DesktopPricingSnapshot } from './desktop-pricing.js'; + +export class DesktopPricingSnapshotDecodeError extends Error { + constructor(message: string) { + super(`Invalid pricing snapshot: ${message}`); + this.name = 'DesktopPricingSnapshotDecodeError'; + } +} + +/** + * Validate a renderer-supplied `base` snapshot at the Main IPC boundary. The + * renderer must not synthesize `revision`/`hostEpoch`/`connectionId`; this only + * proves the shape it round-trips is well formed. A well-formed but *foreign* + * base (wrong Host epoch/connection) is still rejected downstream by the + * adapter's stale guard, and a merely stale revision degrades to a + * `revision_conflict` — both intended, not errors. + */ +export function decodeDesktopPricingSnapshot(value: unknown): DesktopPricingSnapshot { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new DesktopPricingSnapshotDecodeError('snapshot must be an object'); + } + const record = value as Record; + if (typeof record.hostEpoch !== 'string' || record.hostEpoch === '') { + throw new DesktopPricingSnapshotDecodeError('hostEpoch must be a non-empty string'); + } + if (typeof record.connectionId !== 'string' || record.connectionId === '') { + throw new DesktopPricingSnapshotDecodeError('connectionId must be a non-empty string'); + } + if ( + typeof record.revision !== 'number' || + !Number.isInteger(record.revision) || + record.revision < 0 + ) { + throw new DesktopPricingSnapshotDecodeError('revision must be a non-negative integer'); + } + if (!Array.isArray(record.entries)) { + throw new DesktopPricingSnapshotDecodeError('entries must be an array'); + } + const entries = record.entries.map(decodeEffectivePricingEntry); + for (let index = 1; index < entries.length; index += 1) { + if ( + comparePricingModelKeys( + entries[index - 1]!.pricing.modelKey, + entries[index]!.pricing.modelKey, + ) !== -1 + ) { + throw new DesktopPricingSnapshotDecodeError('entries must be in canonical key order'); + } + } + return { + hostEpoch: record.hostEpoch, + connectionId: record.connectionId, + revision: record.revision, + entries, + }; +} + +function decodeEffectivePricingEntry(value: unknown): EffectivePricingEntry { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new DesktopPricingSnapshotDecodeError('entry must be an object'); + } + const record = value as Record; + const pricing = validateCanonicalPricingConfig(record.pricing); + if (!pricing.ok) { + throw new DesktopPricingSnapshotDecodeError(`entry pricing is invalid (${pricing.error})`); + } + if (record.source === 'builtin') { + return { pricing: pricing.value, source: 'builtin' }; + } + if (record.source === 'custom') { + if ( + record.resetEffect !== 'restore_builtin' && + record.resetEffect !== 'become_unpriced' + ) { + throw new DesktopPricingSnapshotDecodeError('custom entry has an invalid resetEffect'); + } + return { pricing: pricing.value, source: 'custom', resetEffect: record.resetEffect }; + } + throw new DesktopPricingSnapshotDecodeError('entry source must be "builtin" or "custom"'); +} diff --git a/apps/desktop/src/shared/desktop-pricing.d.ts b/apps/desktop/src/shared/desktop-pricing.d.ts new file mode 100644 index 00000000000..d4f6c5333a6 --- /dev/null +++ b/apps/desktop/src/shared/desktop-pricing.d.ts @@ -0,0 +1,75 @@ +/* + * 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. + */ + +// Cross-boundary Pricing Settings *types* shared by the Desktop adapter (main), +// the preload bridge, and the renderer. They live here — not in `main/` — so the +// renderer and preload can name them without importing the Runtime Host client. +// +// This is a declaration-only file (`.d.ts`) on purpose: the preload bridge +// contract (`bridge-contract.d.ts`) reaches these types from the renderer's +// import graph, and a declaration file is excluded from the legacy-AppShell / +// renderer-root transitive closure the architecture ratchet tracks (a runtime +// `.ts` in `shared/` would be pulled in as new closure debt). The runtime guard +// that validates a round-tripped snapshot lives beside it in +// `desktop-pricing-decode.ts`, imported only by the Main IPC layer. +// +// The adapter (`runtime-host-client.ts`) is the sole owner of the snapshot's +// `revision`/`hostEpoch`/`connectionId`; the renderer only ever round-trips a +// snapshot it loaded back as the CAS `base`. + +import type { EffectivePricingEntry, PricingMutation } from '@maka/runtime-host/protocol'; + +/** One revision-consistent page of effective pricing, stamped to its Host connection. */ +export interface DesktopPricingSnapshot { + readonly hostEpoch: string; + readonly connectionId: string; + readonly revision: number; + readonly entries: readonly EffectivePricingEntry[]; +} + +export interface DesktopPricingMutationInput { + readonly base: DesktopPricingSnapshot; + readonly mutation: PricingMutation; +} + +/** + * Every terminal state of a pricing mutation. `saved`/`synchronized`/ + * `review_required` carry a fresh authoritative snapshot; `saved_refresh_failed` + * and `reconciliation_unavailable` cannot, so the renderer keeps its draft and + * disables further writes until it can reload. + */ +export type DesktopPricingMutationOutcome = + | { + readonly kind: 'saved'; + readonly disposition: 'committed' | 'unchanged'; + readonly snapshot: DesktopPricingSnapshot; + } + | { + readonly kind: 'saved_refresh_failed'; + readonly disposition: 'committed' | 'unchanged'; + } + | { + readonly kind: 'synchronized' | 'review_required'; + readonly reason: 'revision_conflict' | 'outcome_unknown'; + readonly snapshot: DesktopPricingSnapshot; + } + | { + readonly kind: 'reconciliation_unavailable'; + readonly reason: 'revision_conflict' | 'outcome_unknown'; + }; diff --git a/apps/desktop/stories/settings/pricing-editor.stories.tsx b/apps/desktop/stories/settings/pricing-editor.stories.tsx new file mode 100644 index 00000000000..c29cb22ad88 --- /dev/null +++ b/apps/desktop/stories/settings/pricing-editor.stories.tsx @@ -0,0 +1,381 @@ +/* + * 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 { useEffect, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; +import { ToastProvider } from '@maka/ui'; +import { createDefaultSettings } from '@maka/core/settings'; +import { + PricingEditor, + UsagePricingServicesProvider, + UsageFeatureScope, + type UsageHostRef, + type UsagePricingServices, +} from '../../src/renderer/features/usage/testing'; +import type { DesktopPricingSnapshot } from '../../src/shared/desktop-pricing'; + +// The Pricing tab (#2015 / PR #4164) is per-Host: it loads against the settings- +// SELECTED Runtime Host threaded to it as a prop, not the app's active Host. A +// concrete Host is required — with none selected the tab shows its no-Host state +// (covered by the feature unit tests, not a reachable settings-surface state). +const STORY_HOST: UsageHostRef = { profileId: 'story-profile', hostId: 'story-host' }; +const GENERATION_KEY = `${STORY_HOST.profileId}:${STORY_HOST.hostId}:e1`; +const USAGE_SERVICES = { + loadUsageStats: async () => null, + updateUsageSettings: async () => createDefaultSettings().usage, +}; + +const describeError = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +// A revision-consistent effective snapshot: two user overrides (可回退 = +// restore_builtin, and become_unpriced = Delete) — the only rows the +// overrides-only table shows — plus two built-ins that feed the Add flow's +// catalog picker (never the table). One override is a free local model (0/0) so +// the zero-rate formatting renders. Sources/reset effects are the raw Host +// fields — the editor derives the labels and action set, so this fixture shows +// the classification rather than asserting it. +const MIXED_SNAPSHOT: DesktopPricingSnapshot = { + hostEpoch: 'story-epoch', + connectionId: 'story-connection', + revision: 7, + entries: [ + { source: 'builtin', pricing: { modelKey: 'openai:gpt-5', inputUsdPer1M: 1.25, outputUsdPer1M: 10 } }, + { + source: 'builtin', + pricing: { + modelKey: 'anthropic:claude-opus-4', + inputUsdPer1M: 15, + outputUsdPer1M: 75, + cacheReadUsdPer1M: 1.5, + cacheWriteUsdPer1M: 18.75, + }, + }, + { + source: 'custom', + resetEffect: 'restore_builtin', + pricing: { modelKey: 'zai:glm-4.7', inputUsdPer1M: 0.6, outputUsdPer1M: 2.2 }, + }, + { + source: 'custom', + resetEffect: 'become_unpriced', + pricing: { modelKey: 'local:qwen3-coder', inputUsdPer1M: 0, outputUsdPer1M: 0 }, + }, + ], +}; + +const EMPTY_SNAPSHOT: DesktopPricingSnapshot = { + hostEpoch: 'story-epoch', + connectionId: 'story-connection', + revision: 1, + entries: [], +}; + +function pricingServices( + load: UsagePricingServices['loadPricing'], + mutate: UsagePricingServices['mutatePricing'] = async () => { + throw new Error('This pricing story does not configure writes'); + }, +): UsagePricingServices { + return { loadPricing: load, mutatePricing: mutate }; +} + +function EditablePricingPanel() { + const [services] = useState(() => { + let snapshot = MIXED_SNAPSHOT; + return pricingServices(async () => snapshot, async (_host, _base, mutation) => { + // This browser fixture supports editing its existing custom rows and + // deleting the local-only row. Host CAS/reconciliation has lower-tier tests. + if (mutation.kind === 'upsert') { + snapshot = { + ...snapshot, revision: snapshot.revision + 1, + entries: snapshot.entries.map((row) => row.pricing.modelKey === mutation.pricing.modelKey + ? { ...row, pricing: mutation.pricing } : row), + }; + } else { + snapshot = { + ...snapshot, revision: snapshot.revision + 1, + entries: snapshot.entries.filter((row) => row.pricing.modelKey !== mutation.modelKey), + }; + } + return { kind: 'saved', disposition: 'committed', snapshot }; + }); + }); + return ; +} + +function PricingTabPanel(props: { services: UsagePricingServices; generationKey?: string }) { + const generationKey = props.generationKey ?? GENERATION_KEY; + return ( + + + + {/* The Usage → 定价配置 tab panel wrapper the surface really renders the + editor inside. The surrounding settings-surface chrome (modal, nav + sidebar, the centered content column that bounds this width) is + exercised by Product/Settings/Pages; this story isolates the tab's + own content, capped at a representative content-column width so the + table is not reviewed stretched to the full 1280 render frame. */} +
+
+ true }} + /> +
+
+
+
+
+ ); +} + +const meta = { + title: 'Product/Settings/Pricing', +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +// Real path: 设置 → 使用统计 → 定价配置 on a Host with a couple of user overrides. +// The overrides-only table shows the custom rows (自定义, with Reset or Delete per +// their reset effect); the built-in catalog is reached only via the Add picker. +export const Populated: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.queryByText('内置', { exact: true })).not.toBeInTheDocument(); + await userEvent.click(await canvas.findByRole('button', { name: '编辑「zai:glm-4.7」定价' })); + const dialogElement = await canvas.findByRole('dialog', { name: '编辑定价' }); + const dialog = within(dialogElement); + const input = dialog.getByRole('textbox', { name: /输入价格/ }); + await userEvent.clear(input); + await userEvent.type(input, '0.75'); + // The smoke runner also repeats this with real Enter input. userEvent's + // implicit-submit implementation does not resolve an external form button. + await userEvent.click(dialog.getByRole('button', { name: '保存' })); + await waitFor(() => expect(canvas.queryByRole('dialog')).not.toBeInTheDocument()); + await expect(canvas.getByText('$0.75', { exact: true })).toBeVisible(); + await expect(canvas.getByRole('button', { name: '编辑「zai:glm-4.7」定价' })).toHaveFocus(); + + // Deletion removes the opening action, so focus must return to stable Add. + await userEvent.click(canvas.getByRole('button', { name: '删除「local:qwen3-coder」定价' })); + const deletion = within(await canvas.findByRole('alertdialog', { name: '删除定价' })); + await userEvent.click(deletion.getByRole('button', { name: '删除' })); + await waitFor(() => expect(canvas.queryByRole('alertdialog')).not.toBeInTheDocument()); + await expect(canvas.queryByText('local:qwen3-coder', { exact: true })).not.toBeInTheDocument(); + await expect(canvas.getByRole('button', { name: '添加定价' })).toHaveFocus(); + }, +}; + +// Real path: 设置 → 使用统计 → 定价配置 on a Host with no user overrides yet — the +// common default (built-ins are never listed). The table area shows the +// overrides-empty state prompting the user to Add one from the catalog. +export const Empty: Story = { + render: () => EMPTY_SNAPSHOT)} />, +}; + +// Real path: 设置 → 使用统计 → 定价配置 on first open, before the Host's pricing +// snapshot resolves. The table reserves its geometry with skeleton rows so real +// rows land with no layout shift. +export const Loading: Story = { + render: () => ( + new Promise(() => {}))} /> + ), +}; + +// Real path: 设置 → 使用统计 → 定价配置 when reading the Host's pricing snapshot +// fails. The panel shows a load-failed empty state with a Retry action instead +// of the table. +export const LoadFailed: Story = { + render: () => ( + { + throw new Error('Runtime Host pricing snapshot unreachable'); + })} + /> + ), +}; + +// Real path: Add price → manual fallback. The form accepts the exact Runtime +// lookup key as one copy/paste-safe value instead of making the user reconstruct +// it from separate provider/model fields. +export const ManualExactKey: Story = { + render: () => MIXED_SNAPSHOT)} />, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const add = await canvas.findByRole('button', { name: '添加定价' }); + await userEvent.click(add); + let dialog = within(await canvas.findByRole('dialog', { name: '添加定价' })); + const model = dialog.getByRole('combobox'); + await expect(model).toHaveAttribute('aria-required', 'true'); + await expect(model).not.toHaveAttribute('aria-invalid', 'true'); + // An empty catalog submission must focus the model before the empty rates. + await userEvent.click(dialog.getByRole('button', { name: '保存' })); + await expect(model).toHaveAttribute('aria-invalid', 'true'); + await expect(model).toHaveAccessibleDescription(/必填/); + await expect(model).toHaveFocus(); + await userEvent.type(dialog.getByRole('textbox', { name: /输入价格/ }), '1'); + const output = dialog.getByRole('textbox', { name: /输出价格/ }); + await userEvent.type(output, '2'); + await expect(output).toHaveFocus(); + // With valid rates, the model is the only error and still receives focus. + await userEvent.click(dialog.getByRole('button', { name: '保存' })); + await expect(model).toHaveFocus(); + await userEvent.type(model, 'openai:gpt-5'); + await userEvent.click(await within(document.body).findByRole('option', { name: 'openai:gpt-5' })); + await expect(model).not.toHaveAttribute('aria-invalid', 'true'); + await expect(dialog.getByRole('textbox', { name: /输入价格/ })).toHaveValue('1.25'); + await userEvent.click(dialog.getByRole('button', { name: '取消' })); + await waitFor(() => expect(canvas.queryByRole('dialog')).not.toBeInTheDocument()); + await expect(add).toHaveFocus(); + await userEvent.click(add); + dialog = within(await canvas.findByRole('dialog', { name: '添加定价' })); + await userEvent.click(dialog.getByRole('button', { name: '模型不在列表中?手动输入' })); + await userEvent.type(dialog.getByRole('textbox', { name: /^模型键/ }), 'acme:coder-v3'); + }, +}; + +// Real path: Edit an override → enter an invalid cache price → collapse cache +// prices → Save. The invalid field must become visible and receive focus. +export const Validation: Story = { + render: () => MIXED_SNAPSHOT)} />, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole('button', { name: '编辑「zai:glm-4.7」定价' })); + const dialog = within(await canvas.findByRole('dialog', { name: '编辑定价' })); + const cacheToggle = dialog.getByRole('button', { name: /缓存价格/ }); + await userEvent.click(cacheToggle); + const cacheRead = dialog.getByRole('textbox', { name: /缓存读取/ }); + await userEvent.type(cacheRead, 'invalid'); + await userEvent.click(cacheToggle); + await userEvent.click(dialog.getByRole('button', { name: '保存' })); + await expect(cacheToggle).toHaveAttribute('aria-expanded', 'true'); + await expect(cacheRead).toHaveAttribute('aria-invalid', 'true'); + await expect(cacheRead).toBeVisible(); + await waitFor(() => expect(cacheRead).toHaveFocus()); + await userEvent.click(dialog.getByRole('button', { name: '保存' })); + await expect(cacheRead).toHaveFocus(); + const output = dialog.getByRole('textbox', { name: /输出价格/ }); + await userEvent.clear(output); + await userEvent.type(output, '23'); + await expect(output).toHaveValue('23'); + await expect(output).toHaveFocus(); + await expect(cacheRead).toHaveValue('invalid'); + // Leave the visual state at the error, as another explicit Save would. + await userEvent.click(dialog.getByRole('button', { name: '保存' })); + await expect(cacheRead).toHaveFocus(); + }, +}; + +// Real path: Edit an override → another client changes its prices → Save. The +// Host returns the current snapshot, while the user's draft remains for review. +export const Conflict: Story = { + render: () => MIXED_SNAPSHOT, async () => ({ + kind: 'review_required', + reason: 'revision_conflict', + snapshot: { + ...MIXED_SNAPSHOT, + revision: 8, + entries: MIXED_SNAPSHOT.entries.map((row) => row.pricing.modelKey === 'zai:glm-4.7' + ? { ...row, pricing: { ...row.pricing, inputUsdPer1M: 7, outputUsdPer1M: 8, cacheReadUsdPer1M: 0, cacheWriteUsdPer1M: 0.4 } } + : row), + }, + }))} />, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole('button', { name: '编辑「zai:glm-4.7」定价' })); + const dialogElement = await canvas.findByRole('dialog', { name: '编辑定价' }); + const dialog = within(dialogElement); + const input = dialog.getByRole('textbox', { name: /输入价格/ }); + await userEvent.clear(input); + await userEvent.type(input, '1.25'); + await userEvent.click(dialog.getByRole('button', { name: '保存' })); + await expect(await dialog.findByText('定价已被其他修改更新')).toBeVisible(); + await expect(input).toHaveValue('1.25'); + const latest = dialog.getByText(/当前最新:/); + await expect(latest).toHaveTextContent('$7'); + await expect(latest).toHaveTextContent('$0.4'); + await expect(dialog.getByRole('button', { name: '核对并保存' })).not.toHaveAttribute('aria-disabled', 'true'); + await expect(dialogElement.contains(document.activeElement)).toBe(true); + }, +}; + +// Real path: Edit an override → the dispatched write loses its result and the +// recovery read fails. The open draft offers Refresh and blocks further writes. +export const Uncertain: Story = { + render: () => MIXED_SNAPSHOT, async () => ({ + kind: 'reconciliation_unavailable', reason: 'outcome_unknown', + }))} />, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole('button', { name: '编辑「zai:glm-4.7」定价' })); + const dialogElement = await canvas.findByRole('dialog', { name: '编辑定价' }); + const dialog = within(dialogElement); + const input = dialog.getByRole('textbox', { name: /输入价格/ }); + await userEvent.clear(input); + await userEvent.type(input, '1.25'); + await userEvent.click(dialog.getByRole('button', { name: '保存' })); + await expect(await dialog.findByText('无法确认结果')).toBeVisible(); + await expect(input).toHaveValue('1.25'); + await expect(dialog.getByRole('button', { name: '保存' })).toHaveAttribute('aria-disabled', 'true'); + await expect(dialog.getByRole('button', { name: '刷新' })).toBeVisible(); + await expect(dialogElement.contains(document.activeElement)).toBe(true); + }, +}; + +// Model the asynchronous Host event while the native modal makes outside UI inert. +const hostReplacement = new EventTarget(); +function HostReviewPricingPanel() { + const [generation, setGeneration] = useState(1); + const [services] = useState(() => pricingServices(async () => MIXED_SNAPSHOT)); + useEffect(() => { + const replace = () => setGeneration((current) => current + 1); + hostReplacement.addEventListener('replace', replace); + return () => hostReplacement.removeEventListener('replace', replace); + }, []); + return ; +} + +// Real path: edit a price → selected Host reconnects → review its fresh pricing. +// The focused review action disappears; only a real browser exposes the resulting +// native focus loss. State/recovery semantics are covered by the component tests. +export const HostReview: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole('button', { name: '编辑「zai:glm-4.7」定价' })); + const dialogElement = await canvas.findByRole('dialog', { name: '编辑定价' }); + const dialog = within(dialogElement); + hostReplacement.dispatchEvent(new Event('replace')); + const review = await dialog.findByRole('button', { name: '已核对新主机定价' }); + await waitFor(() => expect(review).not.toHaveAttribute('aria-disabled', 'true')); + await userEvent.click(review); + await waitFor(() => expect(review).not.toBeInTheDocument()); + await expect(dialogElement.contains(document.activeElement)).toBe(true); + await expect(dialog.getByRole('textbox', { name: /输入价格/ })).toHaveValue('0.6'); + }, +}; diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 7bb0d08c284..7e89365c318 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -82,6 +82,8 @@ import { TaskEntryServicesProvider } from '../../src/renderer/features/task-entr import { createFakeTaskEntryServices } from '../../src/renderer/features/task-entry/testing'; import { ConnectionSettingsServicesProvider } from '../../src/renderer/features/connection-settings'; import { RuntimeHostManagementServicesProvider } from '../../src/renderer/features/runtime-host-management'; +import { UsagePricingServicesProvider, type UsagePricingServices } from '../../src/renderer/features/usage'; +import type { DesktopPricingMutationInput } from '../../src/shared/desktop-pricing'; import { SessionBundleServicesProvider, type SessionBundleServices, @@ -382,7 +384,6 @@ const usageStats: UsageStats = { }, { tool: 'Bash', calls: 120, success: 118, errors: 2, avgDurationMs: 840 }, ], - pricing: [{ provider: 'zai-coding-plan', model: 'glm-4.7', inputPerMTokUsd: 0, outputPerMTokUsd: 0 }], provenance: STORY_USAGE_PROVENANCE, }; @@ -403,7 +404,6 @@ const emptyUsageStats: UsageStats = { byProvider: [], byModel: [], byTool: [], - pricing: [], provenance: EMPTY_USAGE_PROVENANCE, }; @@ -1784,6 +1784,33 @@ const withUsageLongTailBridge = withUsageStoryBridge(usageStats, { activeTab: 'requests', }); +const settingsPricingServices: UsagePricingServices = { + loadPricing: async (host) => ({ + hostEpoch: `epoch-${host.hostId}`, + connectionId: `connection-${host.hostId}`, + revision: 1, + entries: [], + }), + mutatePricing: async () => { throw new Error('Pricing writes are not configured in this story'); }, +}; + +const withUsagePricingHostsBridge = withScopedMakaBridge({ + ...makaBridge, + runtimeHostProfiles: generationStoryRuntimeHostProfilesBridge, + settings: { + ...makaBridge.settings, + get: async () => mergeSettings(createDefaultSettings(), { usage: { activeTab: 'pricing' } }), + }, +}); +let pricingStoryMutation: (DesktopPricingMutationInput & { hostId: string }) | undefined; +const hostSwitchPricingServices: UsagePricingServices = { + ...settingsPricingServices, + mutatePricing: async (host, base, mutation) => { + pricingStoryMutation = { hostId: host.hostId, base, mutation }; + return { kind: 'saved_refresh_failed', disposition: 'committed' }; + }, +}; + const subagentStorySettings = mergeSettings(createDefaultSettings(), { subagents: { presets: [ @@ -1960,6 +1987,7 @@ function renderedLinkColors(renderedLink: HTMLElement) { type SettingsStoryProps = { reopenable?: boolean; section: SettingsSection; + pricingServices?: UsagePricingServices; connections?: LlmConnection[]; defaultSlug?: string | null; openProviderCatalog?: boolean; @@ -2079,6 +2107,7 @@ function SettingsStoryFrame(props: SettingsStoryProps) { + {open && } + @@ -2760,6 +2790,73 @@ export const UsageEmpty: Story = { decorators: [withUsageEmptyBridge], render: () => , }; + +// Real path: Settings → Usage → Pricing → Add, then select another Host. +// Settings keys its content by Host identity; the user draft must outlive that +// real remount and require review against the replacement pricing authority. +export const UsagePricingHostSwitch: Story = { + decorators: [withUsagePricingHostsBridge], + render: () => { + resetGenerationStoryBridge(runtimeHostProfilesWithRemote); + pricingStoryMutation = undefined; + return ; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const navigation = await canvas.findByRole('navigation', { name: '使用统计视图' }); + await userEvent.click(within(navigation).getByRole('button', { name: '定价配置' })); + await expect(canvas.queryByRole('group', { name: '使用统计范围与刷新' })).not.toBeInTheDocument(); + await expect(canvas.queryByRole('group', { name: '使用统计汇总指标' })).not.toBeInTheDocument(); + const add = await canvas.findByRole('button', { name: '添加定价' }); + await waitFor(() => expect(add).not.toHaveAttribute('aria-disabled', 'true')); + await userEvent.click(add); + const dialog = within(await canvas.findByRole('dialog', { name: '添加定价' })); + await userEvent.click(dialog.getByRole('button', { name: '模型不在列表中?手动输入' })); + await userEvent.type(dialog.getByRole('textbox', { name: /^模型键/ }), 'acme:host-draft'); + await userEvent.type(dialog.getByRole('textbox', { name: /输入价格/ }), '1.25'); + await userEvent.type(dialog.getByRole('textbox', { name: /输出价格/ }), '2.75'); + // The lifecycle event arrives while output still has focus, before blur. + const oldInput = dialog.getByRole('textbox', { name: /输出价格/ }); + await expect(oldInput).toHaveFocus(); + + // An offline event removes the active Host and unmounts the entire page. + // The Settings selector then allows switching to the still-ready Remote. + const listener = generationStoryProfileListener; + if (!listener) throw new Error('Settings did not subscribe to Host lifecycle changes'); + listener({ + epoch: 'pricing-local-offline', profileId: 'local', profileName: 'Local', + profileKind: 'local', profileAccess: 'owner', readiness: 'unavailable', isDefault: true, + }); + await waitFor(() => expect(oldInput.isConnected).toBe(false)); + await userEvent.click(canvas.getByRole('combobox', { name: 'Runtime Host' })); + await userEvent.click(await within(document.body).findByRole('option', { name: 'Remote' })); + const restoredDialog = await canvas.findByRole('dialog', { name: '添加定价' }); + const restored = within(restoredDialog); + await expect(restored.getByRole('textbox', { name: /^模型键/ })).toHaveValue('acme:host-draft'); + await expect(restored.getByRole('textbox', { name: /输入价格/ })).toHaveValue('1.25'); + await expect(restored.getByRole('textbox', { name: /输出价格/ })).toHaveValue('2.75'); + const save = restored.getByRole('button', { name: '保存' }); + await expect(save).toHaveAttribute('aria-disabled', 'true'); + await userEvent.click(save); + await expect(pricingStoryMutation).toBeUndefined(); + const review = restored.getByRole('button', { name: '已核对新主机定价' }); + await waitFor(() => expect(review).not.toHaveAttribute('aria-disabled', 'true')); + await userEvent.click(review); + await expect(restoredDialog.contains(document.activeElement)).toBe(true); + await userEvent.click(save); + await waitFor(() => expect(pricingStoryMutation?.hostId).toBe('storybook-remote-host')); + await expect(pricingStoryMutation?.base.hostEpoch).toBe('epoch-storybook-remote-host'); + await expect(pricingStoryMutation?.mutation).toEqual({ + kind: 'upsert', pricing: { modelKey: 'acme:host-draft', inputUsdPer1M: 1.25, outputUsdPer1M: 2.75 }, + }); + await restored.findByText('已保存,但无法加载最新定价'); + await expect(restoredDialog.contains(document.activeElement)).toBe(true); + await userEvent.click(restored.getByRole('button', { name: '取消' })); + await waitFor(() => expect(restoredDialog.isConnected).toBe(false)); + await expect(canvas.getByRole('button', { name: '添加定价' })).toHaveFocus(); + await expect(canvas.queryByText('暂无自定义定价')).not.toBeInTheDocument(); + }, +}; // Real path: 设置 → 使用统计 → 供应商统计, with traffic from one provider. export const UsageSingleProvider: Story = { decorators: [withUsageSingleProviderBridge], diff --git a/docs/archive/pricing-ablation-2026-09-30.md b/docs/archive/pricing-ablation-2026-09-30.md new file mode 100644 index 00000000000..b42b233f149 --- /dev/null +++ b/docs/archive/pricing-ablation-2026-09-30.md @@ -0,0 +1,44 @@ + + +# Pricing 消融实验结论(2026-09-30) + +在合并提交 `b043e2cf2` 上进行了 30 项单独消融和 3 项组合消融。 +27 项机制的删除会触发具体行为回归;3 项局部简化及其组合通过观察面。 +这不是性能基准,也不证明其他实现方式不可能替代这些机制。 + +保留 CAS 快照、Host 与弹窗生命周期、同步输入、请求顺序、写入恢复、 +价格语义校验,以及 Chromium 验证过的 ARIA 和焦点保护。采用三处简化: + +- 分页严格前进且响应必须匹配请求 offset,已排除历史游标,无需另存 Set。 +- 有效 reload 开始时已清错,成功路径无需再次清空。 +- `setEditor(null)` 通过模型身份变化清除已结算冲突,关闭弹窗无需重复清理。 + +实验补入的持续回归覆盖同一 Host 下卸载后的迟到保存、刷新乱序、分页自环/ +回退/重复键、恢复内置定价的反例,以及 Host review 按钮消失时的原生焦点。 +这些测试保留在现有 Pricing 组件、client 和 Storybook 测试中并随 CI 运行。 +当时的生产代码验证结果为 124 项 Node 测试、12 个浏览器场景及 1 项 Electron +跨进程持久化测试通过;后续覆盖和验证结果以对应提交的 CI 为准。 + +2026-10-10 根据评审精简 PR:未接入 CI 的实验运行器、固定探针和大 JSON +已移出当前树。原始脚本、数据和完整报告仍可从提交 `17e0d3db3` 的 +`scripts/experiments/` 与 `docs/archive/pricing-ablation-2026-09-30.*` 获取。 +原工作区另保留在 `perf-results/pricing-ablation-20260930/reviewer-archive-20261010/`, +该目录不上传。历史实验应在原提交及其锁文件上复现,不能把旧源码散列和 +测试计数当作最新代码的验证结果。 diff --git a/docs/archive/pricing-ablation-2026-10-16.md b/docs/archive/pricing-ablation-2026-10-16.md new file mode 100644 index 00000000000..29ae80491df --- /dev/null +++ b/docs/archive/pricing-ablation-2026-10-16.md @@ -0,0 +1,67 @@ + + +# Pricing 消融实验(2026-10-16) + +结论:可做局部精简。采用 6 项调整,3 个生产文件共增加 24 行、删除 51 行, +净减 27 行;移除一个独立 React 状态及重复处理,保留经反证确认必要的保护。 + +实验基线为合并提交 `c00c9c77465e78acab22023d02cc204dd1588729`,包含主线 +`331340aba5c7de5fc7d53e2441123f40f03ba34b`。本次同步了 13 个主线提交, +无文本冲突。另按源码重新生成架构账本,将主线已有的 AppShell token 统计 +从 7285 修正为 7280;未放宽架构规则。 + +先按新锁文件安装依赖,再以同一基线分别执行 11 项独立消融和 1 项组合实验。 +每项恢复原始源码、应用单项修改、重新编译 Desktop,再运行同一组 125 项 +Pricing/Usage 测试。所有变体均编译成功;下面的失败均为行为断言失败。 +测试本身没有修改。另运行了 47 项核心/Host 定价测试。 + +| 独立调整 | Node 结果 | 决定及依据 | +| --- | --- | --- | +| 从「有 editor、无 mutationBase」推导 needsReview | 125/125 | 采用;Host 切换与重挂载后必须重新确认,确认后固定写入基准,无需第二个状态 | +| loadError 字符串改为 loadFailed 标记 | 125/125 | 采用;界面只读取失败与否,始终显示本地化的通用错误提示,原错误详情未被使用 | +| 合并 IPC 校验和派发的两段相同 catch | 125/125 | 采用;仍执行全部校验,仍返回相同失败 Result | +| 加载与已加载状态共用 Pricing 表格 JSX | 125/125 | 采用;保留各自的 ARIA 标签、骨架行和真实行 | +| 两种恢复提示共用 Banner JSX | 125/125 | 采用;保留「已提交但刷新失败」与「结果未明」各自的文案和状态 | +| 移除 UI 的额外关闭守卫,交给 controller | 125/125 | 采用;controller 的同步单次写入守卫仍阻止关闭 | +| 写入时用最新列表快照代替打开弹窗时的快照 | 122/125 | 拒绝;后台刷新期间的新增、编辑、重置均丢失原 CAS 基准 | +| 移除同步 editorRef 写入 | 124/125 | 拒绝;同一次渲染前的输入与提交发送旧文本 | +| 移除异步结果的弹窗归属判断 | 115/125 | 拒绝;取消后的重置、切换模型后的基准与重复检测、冲突归属发生回归 | +| 移除保存结果对在途刷新的屏蔽 | 124/125 | 拒绝;迟到刷新覆盖已提交结果 | +| 移除弹窗焦点修复 | 125/125 | 拒绝;真实 Chromium 的 Host review 场景在 1280px、480px 均失败,按钮消失后焦点落到弹窗外 | + +6 项保留调整的组合仍通过 125 项测试,以及 19 个浏览器场景:9 个 Pricing +故事各覆盖宽、窄视口,加上 Settings Host 切换故事。基线也通过同样的 +19 个场景。最终 Electron 测试通过真实 preload/Main 保存、刷新读取及删除。 + +在同一机器、固定时钟、隐藏输入光标并关闭动画的截图工具下比较 18 张明暗主题 +截图:16 张像素完全一致;Validation 的明、暗截图分别有 12、11 个像素差异 +(约 0.001%,限于同一 2×281 区域内的离散像素)。同版本 A/A 复拍也出现 +暗色 Validation 同位置的 11 像素差异,其余 17 张一致。已目视检查,无可见 +布局变化;未改比较阈值,也不将此次结果记为像素零差异。 + +最终构建、全工作区类型检查、lint、格式、Desktop/UI knip、严格主线架构检查、 +UI 清单、E2E 预算、协议守卫及相关脚本测试均通过。代码精简不增加依赖, +不修改协议编解码或 wire shape。本实验验证行为和视觉回归,不是性能基准。 + +运行器、各变体补丁、源码散列、逐项日志和截图保留在本地忽略目录 +`perf-results/pricing-ablation-20261016/`,不向产品或 CI 增加实验代码。 +基线与单项/组合测试清单见其中的 `run.mjs`、`results.json`;浏览器结果见 +`baseline.browser.json`、`no-focus.browser.json`、`combined.browser.json`。 +已采纳代码的完整差异可相对上述基线提交查看。 diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 2d98c9f6c29..8032caa26ab 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.3` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 340 files — blocker 0, reimplementation 0, polish 4, aligned 336. +**Totals:** 342 files — blocker 0, reimplementation 0, polish 4, aligned 338. ## Exclusions (explicit) @@ -138,8 +138,10 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/task-entry/ui/project-registration-boundary.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/usage/pricing-services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/usage/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/usage/ui/metric-card.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx` | other | Banner, Button, Collapsible, Dialog, DialogHeader, EmptyState, HStack, Heading, Layout, LayoutContent, LayoutFooter, Skeleton, Text, TextInput, Typeahead, VStack | aligned — uses Astryx (Banner, Button, Collapsible, Dialog, DialogHeader, EmptyState, HStack, Heading) | aligned | | `apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx` | other | Banner, Button, SegmentedControl, SegmentedControlItem, Selector, Switch, Tab, TabList, TextInput, Tooltip | aligned — uses Astryx (Banner, Button, SegmentedControl, SegmentedControlItem, Selector, Switch, Tab, TabList) | aligned | | `apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx` | other | Card, EmptyState, Table | aligned — uses Astryx (Card, EmptyState, Table) | aligned | | `apps/desktop/src/renderer/features/workbar/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index c1f0a489378..d4f53529924 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -107,8 +107,10 @@ apps/desktop/src/renderer/features/task-entry/services-context.tsx apps/desktop/src/renderer/features/task-entry/ui/project-registration-boundary.tsx apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx +apps/desktop/src/renderer/features/usage/pricing-services-context.tsx apps/desktop/src/renderer/features/usage/services-context.tsx apps/desktop/src/renderer/features/usage/ui/metric-card.tsx +apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx apps/desktop/src/renderer/features/workbar/services-context.tsx diff --git a/docs/images/pr/pricing-overrides-only/after.png b/docs/images/pr/pricing-overrides-only/after.png new file mode 100644 index 00000000000..96726d130f3 Binary files /dev/null and b/docs/images/pr/pricing-overrides-only/after.png differ diff --git a/docs/images/pr/pricing-overrides-only/before.png b/docs/images/pr/pricing-overrides-only/before.png new file mode 100644 index 00000000000..1ac16a9b972 Binary files /dev/null and b/docs/images/pr/pricing-overrides-only/before.png differ diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 554ccf6469a..973312cc6d0 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -684,12 +684,6 @@ export interface UsageStats { errors: number; avgDurationMs: number; }>; - pricing: Array<{ - provider: string; - model: string; - inputPerMTokUsd: number; - outputPerMTokUsd: number; - }>; /** * Coverage/legacy/unreadable/pending accounting behind these totals, so the * page can qualify a cost that reads low (unpriced/unreadable/pending) rather @@ -720,6 +714,13 @@ export interface UsageActivityPage { } export interface UsageScreen extends UsageStats, UsageActivityPage { + /** Retained for wire compatibility; Desktop Pricing loads its own Host snapshot. */ + pricing: Array<{ + provider: string; + model: string; + inputPerMTokUsd: number; + outputPerMTokUsd: number; + }>; activityTotal: number; query: UsageScreenQuery; } diff --git a/packages/runtime-host/protocol-compatible-changes/pricing-reconciliation-helpers.json b/packages/runtime-host/protocol-compatible-changes/pricing-reconciliation-helpers.json new file mode 100644 index 00000000000..e3bb17d4dd9 --- /dev/null +++ b/packages/runtime-host/protocol-compatible-changes/pricing-reconciliation-helpers.json @@ -0,0 +1,5 @@ +{ + "epoch": 222, + "files": ["packages/runtime-host/src/protocol/usage-pricing.ts"], + "reason": "Adds shared pure pricing reconciliation types and helpers without changing any protocol codec, validation rule, operation payload, or wire message shape. Older and newer peers exchange identical pricing frames." +} diff --git a/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts b/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts index 37ceb87aec5..98100d21042 100644 --- a/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts @@ -41,6 +41,9 @@ import { decodeUsageQueryInput, encodePricingQueryResult, encodeProtocolMessage, + createPricingReconciliationTarget, + pricingReconciliationTargetMatches, + pricingReconciliationTargetModelKey, PRICING_PAGE_MAX_BYTES, PRICING_PAGE_MAX_ITEMS, RUNTIME_HOST_MAX_MESSAGE_BYTES, @@ -63,6 +66,48 @@ const CONNECTION_CONTEXT: ConnectionContext = { }; describe('Usage/Pricing protocol', () => { + test('pricing reconciliation captures reset semantics and exact custom provenance', () => { + const builtin: EffectivePricingEntry = { + source: 'builtin', + pricing: { modelKey: 'openai:gpt-5', inputUsdPer1M: 1, outputUsdPer1M: 2 }, + }; + const resettable = customPricingEntry('anthropic:claude', 'restore_builtin'); + const upsert = createPricingReconciliationTarget([builtin, resettable], { + kind: 'upsert', + pricing: builtin.pricing, + }); + assert.equal(pricingReconciliationTargetModelKey(upsert), 'openai:gpt-5'); + assert.equal( + pricingReconciliationTargetMatches(upsert, [builtin, resettable]), + false, + 'equal bundled rates are not a committed custom override', + ); + assert.equal( + pricingReconciliationTargetMatches(upsert, [ + { ...builtin, source: 'custom', resetEffect: 'restore_builtin' }, + resettable, + ]), + true, + ); + + const reset = createPricingReconciliationTarget([builtin, resettable], { + kind: 'delete', + modelKey: resettable.pricing.modelKey, + }); + assert.deepEqual(reset, { + kind: 'delete', + modelKey: 'anthropic:claude', + expected: 'builtin', + }); + assert.equal( + pricingReconciliationTargetMatches(reset, [ + builtin, + { source: 'builtin', pricing: resettable.pricing }, + ]), + true, + ); + }); + test('decodes exact bounded usage queries', () => { assert.deepEqual( decodeUsageQueryInput({ diff --git a/packages/runtime-host/src/protocol/usage-pricing.ts b/packages/runtime-host/src/protocol/usage-pricing.ts index d9dfd135d9a..54b39fe87a1 100644 --- a/packages/runtime-host/src/protocol/usage-pricing.ts +++ b/packages/runtime-host/src/protocol/usage-pricing.ts @@ -24,6 +24,7 @@ import { assertUsageScreenResult, } from './usage-screen.js'; import { + canonicalPricingConfigsEqual, comparePricingModelKeys, normalizePricingModelKey, validateCanonicalPricingConfig, @@ -307,6 +308,57 @@ export type PricingMutation = | { readonly kind: 'upsert'; readonly pricing: PricingConfig } | { readonly kind: 'delete'; readonly modelKey: string }; +export type PricingReconciliationTarget = + | { readonly kind: 'upsert'; readonly pricing: Readonly } + | { + readonly kind: 'delete'; + readonly modelKey: string; + readonly expected: 'builtin' | 'unpriced' | 'no_override'; + }; + +/** Capture the intended end state before dispatch, while the CAS base is known. */ +export function createPricingReconciliationTarget( + baseEntries: readonly EffectivePricingEntry[], + mutation: PricingMutation, +): PricingReconciliationTarget { + if (mutation.kind === 'upsert') return { kind: 'upsert', pricing: mutation.pricing }; + const baseEntry = baseEntries.find(({ pricing }) => pricing.modelKey === mutation.modelKey); + const expected = + baseEntry?.source === 'custom' + ? baseEntry.resetEffect === 'restore_builtin' + ? 'builtin' + : 'unpriced' + : 'no_override'; + return { kind: 'delete', modelKey: mutation.modelKey, expected }; +} + +/** Compare a later authoritative projection with a previously captured target. */ +export function pricingReconciliationTargetMatches( + target: PricingReconciliationTarget, + entries: readonly EffectivePricingEntry[], +): boolean { + const current = entries.find( + ({ pricing }) => pricing.modelKey === pricingReconciliationTargetModelKey(target), + ); + if (target.kind === 'upsert') { + return ( + current?.source === 'custom' && canonicalPricingConfigsEqual(current.pricing, target.pricing) + ); + } + switch (target.expected) { + case 'builtin': + return current?.source === 'builtin'; + case 'unpriced': + return current === undefined; + case 'no_override': + return current === undefined || current.source === 'builtin'; + } +} + +export function pricingReconciliationTargetModelKey(target: PricingReconciliationTarget): string { + return target.kind === 'upsert' ? target.pricing.modelKey : target.modelKey; +} + export interface PricingMutateInput { readonly expectedRevision: number; readonly mutation: PricingMutation; diff --git a/scripts/protocol-epoch-check.mjs b/scripts/protocol-epoch-check.mjs index 9c712b4affc..5049a2c7340 100644 --- a/scripts/protocol-epoch-check.mjs +++ b/scripts/protocol-epoch-check.mjs @@ -27,8 +27,9 @@ // current base branch. An incompatible change must move the epoch. A compatible // extension may keep it only when a declaration added against the base names every // changed protocol file, keeping that exception explicit and reviewable. The -// `--staged` pre-commit mode judges one commit against HEAD and cannot see the base, -// so it also honors a declaration the branch amends; the merge result decides. +// `--staged` normally judges one commit against HEAD and also honors a declaration +// the branch amends. During a merge it uses the incoming parent, so historical +// declarations stay unchanged and only declarations added against that base count. import { execFileSync } from 'node:child_process'; import { dirname, resolve } from 'node:path'; @@ -185,17 +186,31 @@ function stagedFile(file, exec = execFileSync) { } export function evaluateStagedEpochCheck(exec = execFileSync) { + let base = 'HEAD'; + try { + base = git(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'], exec).trim(); + } catch (error) { + if (error.status !== 1) throw error; + } const changedProtocolFiles = git( - ['diff', '--cached', '--no-renames', '--name-only', 'HEAD', '--', PROTOCOL_DIR], + ['diff', '--cached', '--no-renames', '--name-only', base, '--', PROTOCOL_DIR], exec, ) .split('\n') .filter(Boolean) - .filter((file) => !isStagedHeaderOnlyChange(file, exec)); + .filter((file) => !isStagedHeaderOnlyChange(file, exec, base)); // `M` too: a branch amends the declaration it added. The merge-result check counts // only declarations added against the base, so editing a landed one grants nothing. const declarations = git( - ['diff', '--cached', '--diff-filter=AM', '--name-only', 'HEAD', '--', COMPATIBLE_CHANGE_DIR], + [ + 'diff', + '--cached', + base === 'HEAD' ? '--diff-filter=AM' : '--diff-filter=A', + '--name-only', + base, + '--', + COMPATIBLE_CHANGE_DIR, + ], exec, ) .split('\n') @@ -208,7 +223,7 @@ export function evaluateStagedEpochCheck(exec = execFileSync) { ); } return evaluateEpochCheck({ - baseEpoch: epochAtRevision('HEAD', exec), + baseEpoch: epochAtRevision(base, exec), headEpoch, changedProtocolFiles, compatibleProtocolFiles, @@ -249,11 +264,11 @@ export function isHeaderOnlyChange(file, base, head, exec = execFileSync) { } } -export function isStagedHeaderOnlyChange(file, exec = execFileSync) { +export function isStagedHeaderOnlyChange(file, exec = execFileSync, base = 'HEAD') { const style = classifyPath(file).style; if (!style) return false; try { - const before = git(['show', `HEAD:${file}`], exec); + const before = git(['show', `${base}:${file}`], exec); const after = stagedFile(file, exec); return applyHeader(before, style) === after; } catch { diff --git a/scripts/protocol-epoch-check.test.mjs b/scripts/protocol-epoch-check.test.mjs index 2c40990a5df..50a6e9a03cd 100644 --- a/scripts/protocol-epoch-check.test.mjs +++ b/scripts/protocol-epoch-check.test.mjs @@ -27,6 +27,7 @@ import { compatibleProtocolFilesBetween, COMPATIBLE_CHANGE_DIR, EPOCH_FILE, + PROTOCOL_DIR, epochAtRevision, evaluateEpochCheck, evaluateStagedEpochCheck, @@ -526,3 +527,59 @@ test('offers a paste-ready declaration when a protocol change has no epoch to sh reason: '', }); }); + +test('a staged merge preserves historical declarations and checks against the incoming parent', () => { + const repo = mkdtempSync(join(tmpdir(), 'maka-protocol-epoch-merge-')); + const epochPath = join(repo, EPOCH_FILE); + const historical = `${COMPATIBLE_CHANGE_DIR}historical.json`; + const current = `${COMPATIBLE_CHANGE_DIR}feature.json`; + const feature = `${PROTOCOL_DIR}feature.ts`; + const runGit = (...args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8' }); + const runInFixture = (file, args, options) => + execFileSync(file, args, { ...options, cwd: repo, encoding: 'utf8' }); + const declaration = (epoch, files) => + JSON.stringify({ epoch, files, reason: 'Pure helper only' }); + try { + runGit('init', '--initial-branch=main'); + runGit('config', 'user.email', 'epoch-guard@example.invalid'); + runGit('config', 'user.name', 'Epoch Guard Test'); + runGit('config', 'commit.gpgSign', 'false'); + mkdirSync(dirname(epochPath), { recursive: true }); + mkdirSync(join(repo, COMPATIBLE_CHANGE_DIR), { recursive: true }); + writeFileSync(epochPath, 'export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 27 as const;\n'); + writeFileSync(join(repo, historical), declaration(27, [EPOCH_FILE])); + runGit('add', '.'); + runGit('commit', '-m', 'base'); + runGit('checkout', '-b', 'feature'); + writeFileSync(epochPath, 'export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 28 as const;\n'); + writeFileSync(join(repo, feature), 'export const feature = 1;\n'); + writeFileSync(join(repo, historical), declaration(28, [EPOCH_FILE])); + runGit('add', '.'); + runGit('commit', '-m', 'feature with an incorrectly repinned historical declaration'); + runGit('checkout', 'main'); + writeFileSync(epochPath, 'export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 28 as const;\n'); + runGit('add', '.'); + runGit('commit', '-m', 'independent epoch bump'); + runGit('checkout', 'feature'); + runGit('merge', '--no-commit', 'main'); + runGit('restore', '--source=main', '--staged', '--worktree', '--', historical); + + // Restoring main's metadata must not ask us to rewrite its epoch again. + // It also must not authorize the feature's protocol addition at main's epoch. + const undeclared = evaluateStagedEpochCheck(runInFixture); + assert.equal(undeclared.ok, false); + assert.match(undeclared.reason, /feature\.ts/); + writeFileSync(join(repo, current), declaration(28, [feature])); + runGit('add', '.'); + assert.equal(evaluateStagedEpochCheck(runInFixture).ok, true); + + // Editing a declaration already present on the incoming branch grants no + // exemption, just as in the final CI merge-result check. + runGit('rm', '-f', current); + writeFileSync(join(repo, historical), declaration(28, [feature])); + runGit('add', '.'); + assert.equal(evaluateStagedEpochCheck(runInFixture).ok, false); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); diff --git a/scripts/storybook-visual-smoke.mjs b/scripts/storybook-visual-smoke.mjs index b678020eeda..a9e884f10ff 100644 --- a/scripts/storybook-visual-smoke.mjs +++ b/scripts/storybook-visual-smoke.mjs @@ -167,6 +167,31 @@ export function catalogJobs( const jobs = Object.values(entries) .filter((entry) => entry?.type === 'story' && typeof entry.id === 'string') .flatMap((entry) => { + // Usage timestamp fitting depends on localized dates and day-period + // markers. Reuse the wide/narrow stories' real geometry assertions. + if ( + entry.id === 'product-settings-pages--usage-long-tail' || + entry.id === 'product-settings-pages--usage-narrow' + ) { + return ['zh-CN', 'zh-TW', 'en'].map((locale) => ({ + storyId: entry.id, + colorScheme: 'light', + forcedColors: 'none', + palette: 'default', + locale, + })); + } + // Reuse the Pricing states at both widths so validation/recovery banners + // and footer actions are exercised in a narrow window, without duplicate stories. + if (entry.id.startsWith('product-settings-pricing--')) { + return [RENDER_VIEWPORT, { width: 480, height: 720 }].map((viewport) => ({ + storyId: entry.id, + colorScheme: 'light', + forcedColors: 'none', + palette: 'default', + viewport, + })); + } // Exercise both sides of the rail's responsive boundary in the actual // browser viewport. A Storybook toolbar default does not resize smoke. if (entry.id === PROMPT_RAIL_CLEARANCE_STORY_ID) { @@ -325,6 +350,57 @@ export async function smokeStory(page, baseUrl, job, options = {}) { } } + if (job.storyId === 'product-settings-pricing--populated' && browserFailures.length === 0) { + // userEvent implements implicit submit by querying descendants of form, + // missing the Pricing dialog's associated footer button. Real key input + // catches a broken form association without simulating submit ourselves. + const edit = page.getByRole('button', { name: '编辑「zai:glm-4.7」定价' }); + await edit.click(); + const dialog = page.getByRole('dialog', { name: '编辑定价' }); + const input = dialog.getByRole('textbox', { name: /输入价格/ }); + await input.fill('0.875'); + await input.press('Enter'); + await dialog.waitFor({ state: 'hidden' }); + await page.getByRole('cell', { name: '$0.875', exact: true }).waitFor({ state: 'visible' }); + } + + if (job.storyId.startsWith('product-settings-pricing--')) { + const geometry = await page.evaluate(() => { + const failures = []; + if (document.documentElement.scrollWidth > window.innerWidth) + failures.push('page overflows horizontally'); + const dialog = document.querySelector('dialog[open]'); + if (dialog) { + const rect = dialog.getBoundingClientRect(); + if ( + rect.left < 0 || + rect.right > window.innerWidth || + rect.top < 0 || + rect.bottom > window.innerHeight + ) + failures.push('dialog extends beyond the viewport'); + for (const button of dialog.querySelectorAll('.astryx-layout-footer button')) { + const action = button.getBoundingClientRect(); + if ( + action.left < rect.left || + action.right > rect.right || + action.top < rect.top || + action.bottom > rect.bottom + ) + failures.push('footer action is clipped'); + } + const focusedInvalid = dialog.querySelector('[aria-invalid="true"]:focus'); + if (focusedInvalid) { + const input = focusedInvalid.getBoundingClientRect(); + if (input.top < rect.top || input.bottom > rect.bottom) + failures.push('focused invalid field is clipped'); + } + } + return failures; + }); + browserFailures.push(...geometry); + } + const result = await page.evaluate(() => { const root = document.querySelector('#storybook-root'); const failures = [...(window.__makaStorybookSmoke?.failures ?? [])];