From f1dcd93931b322d587a7253856f44625a6afbce0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 14:15:20 -0700 Subject: [PATCH 01/33] feat(web): Nightly tells you to get the beta mobile app (#15070) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/NightlyMobileBeta.tsx | 159 ++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 16 +- apps/web/src/routes/__root.tsx | 3 + docs/user/install.md | 8 + 4 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/NightlyMobileBeta.tsx diff --git a/apps/web/src/components/NightlyMobileBeta.tsx b/apps/web/src/components/NightlyMobileBeta.tsx new file mode 100644 index 000000000000..45e11c27e159 --- /dev/null +++ b/apps/web/src/components/NightlyMobileBeta.tsx @@ -0,0 +1,159 @@ +import { parseSemver } from "@t3tools/shared/semver"; +import { useNavigate } from "@tanstack/react-router"; +import * as Schema from "effect/Schema"; +import { SmartphoneIcon } from "lucide-react"; +import { useEffect } from "react"; + +import { APP_VERSION, HOSTED_APP_CHANNEL } from "../branding"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage"; +import { AndroidIcon, AppleIcon } from "./Icons"; +import { SettingsRow } from "./settings/settingsLayout"; +import { Button } from "./ui/button"; +import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover"; +import { QRCodeSvg } from "./ui/qr-code"; +import { toastManager } from "./ui/toast"; + +// Orchestrator V2 ships to Nightly first. The App Store and Google Play apps are +// still V1 and cannot connect to a V2 server, so Nightly users need the V2 beta +// app. Delete this file when the store apps move to V2. See #14871. + +/** True on Nightly desktop, `npx t3@nightly`, and the hosted Nightly app. */ +export const IS_NIGHTLY_BUILD = + parseSemver(APP_VERSION)?.prerelease[0] === "nightly" || HOSTED_APP_CHANNEL === "nightly"; + +const IOS_TESTFLIGHT_URL = "https://testflight.apple.com/join/XgaxaRtd"; +const ANDROID_BETA_GROUP_URL = "https://groups.google.com/g/t3-code-v2-beta"; +const ANDROID_PLAY_TESTING_URL = "https://play.google.com/apps/testing/com.t3tools.t3code"; + +const ROW_ID = "nightly-mobile-beta"; +const NOTICE_DISMISSED_STORAGE_KEY = "t3code:nightly-mobile-beta-notice-dismissed:v1"; + +// Guards against a second toast from a remount or a Strict Mode effect replay. +let noticeShown = false; + +function isNoticeDismissed(): boolean { + try { + return getLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, Schema.Boolean) === true; + } catch { + return false; + } +} + +function dismissNotice() { + try { + setLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, true, Schema.Boolean); + } catch { + // Storage is unavailable. The notice shows again on the next launch. + } +} + +/** + * One-time Nightly toast that points to the beta mobile app. Any close (the + * corner button, swipe, Dismiss, or the action) hides it for good on this client. + */ +export function NightlyMobileBetaNotice() { + const navigate = useNavigate(); + + useEffect(() => { + if (!IS_NIGHTLY_BUILD || noticeShown || isNoticeDismissed()) return; + noticeShown = true; + const toastId = toastManager.add({ + title: "Nightly needs the beta mobile app", + description: + "Nightly uses the new orchestrator. The App Store and Google Play versions of T3 Code cannot connect to it.", + timeout: 0, + onClose: dismissNotice, + actionProps: { + children: "Get the beta app", + onClick: () => { + toastManager.close(toastId); + void navigate({ to: "/settings/general", hash: ROW_ID }); + }, + }, + data: { + leadingIcon: , + actionLayout: "stacked-end", + secondaryActionProps: { + children: "Dismiss", + onClick: () => toastManager.close(toastId), + }, + secondaryActionVariant: "ghost", + }, + }); + }, [navigate]); + + return null; +} + +/** QR code plus a copy button for one beta link. */ +function BetaLinkQr({ url, label }: { url: string; label: string }) { + const { copyToClipboard, isCopied } = useCopyToClipboard(); + return ( +
+
+ +
+ +
+ ); +} + +/** Settings → General → About row with the beta app links. Render it only for Nightly. */ +export function NightlyMobileBetaRow() { + return ( + + + }> + + iPhone + + +
+
+ TestFlight beta +

Scan with your iPhone camera.

+
+ +
+
+
+ + }> + + Android + + +
+
+ Google Play beta +

+ Use the same Google account for both steps. Step 2 can take up to an hour to + work after you join the group. +

+
+
+
+

1. Join the group

+ +
+
+

2. Become a tester

+ +
+
+
+
+
+ + } + /> + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8c5a3a935c5a..1104f875f6c5 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -52,6 +52,7 @@ import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; +import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, @@ -282,6 +283,9 @@ function AboutVersionSection() { const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL; + // Show the beta app links as soon as someone picks Nightly, before the update installs. + const showNightlyMobileBeta = + IS_NIGHTLY_BUILD || (hasDesktopBridge && selectedUpdateChannel === "nightly"); const handleUpdateChannelChange = useCallback( (channel: DesktopUpdateChannel) => { @@ -502,6 +506,7 @@ function AboutVersionSection() { } /> ) : null} + {showNightlyMobileBeta ? : null} ); } @@ -3329,10 +3334,13 @@ export function GeneralSettingsPanel() { {isElectron || HOSTED_APP_CHANNEL ? ( ) : ( - } - description="Current version of the application." - /> + <> + } + description="Current version of the application." + /> + {IS_NIGHTLY_BUILD ? : null} + )} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index b5cefd1342ea..c8963ea635ee 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -28,6 +28,7 @@ import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; +import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast"; import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; @@ -242,6 +243,8 @@ function RootRouteView() { ) : null} {primaryEnvironmentAuthenticated ? : null} + {/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */} + {appShell} {/* Above the router: a theme draft is judged by walking the app, so the editor has to survive navigation away from settings. */} diff --git a/docs/user/install.md b/docs/user/install.md index 3a9fb3296257..77b09863d746 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -98,6 +98,14 @@ Install T3 Code from the The phone connects to a server on another machine. Follow [remote access](./remote-access.md) to link it through T3 Connect or a pairing URL. +Nightly builds need the beta app. The store apps cannot connect to them. A Nightly build also +shows these links as QR codes in **Settings → General → Mobile app**. + +- **iPhone and iPad:** join the [TestFlight beta](https://testflight.apple.com/join/XgaxaRtd). +- **Android:** join the [beta group](https://groups.google.com/g/t3-code-v2-beta). With the same + Google account, open the [Google Play testing page](https://play.google.com/apps/testing/com.t3tools.t3code) + and become a tester. + If the app crashes during launch, open Settings → Diagnostics on the next launch that succeeds. It lists startup crashes from the last 7 days with the error and component stack that store crash reports leave out. Copy the report and paste it From a21702d0e58ad20263dc965d84472bb6d7eb38c8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 14:28:20 -0700 Subject: [PATCH 02/33] test(server): ACP adapter tests no longer race the prompt settle (#15330) Takes over #14876. Co-authored-by: tris203 Co-authored-by: Claude Opus 5.5 (1M context) --- .../Adapters/AcpAdapterV2.test.ts | 278 +++++------------- 1 file changed, 80 insertions(+), 198 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 146924586763..b6cb0be45acf 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -4925,9 +4925,13 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -4950,7 +4954,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -4986,28 +4989,13 @@ describe("AcpAdapterV2", () => { makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); // The still-running subagent defers finalize after session/prompt returns. - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const providerTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId }); let terminalStatus: string | null = null; while (terminalStatus === null) { @@ -5033,10 +5021,14 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5072,7 +5064,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -5107,28 +5098,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let subagentTurnItemId: string | null = null; let firstTerminalStatus: string | null = null; @@ -5193,10 +5169,14 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5230,7 +5210,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -5272,28 +5251,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -5352,7 +5316,6 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const secondPromptWireReturned = yield* Deferred.make(); const releaseSecondPromptCompletion = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); @@ -5361,7 +5324,12 @@ describe("AcpAdapterV2", () => { let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5395,7 +5363,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -5456,28 +5423,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -5601,7 +5553,6 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const baseClock = yield* Clock.Clock; const finalizationClockRead = yield* Deferred.make(); const releaseFinalizationClockRead = yield* Deferred.make(); @@ -5629,7 +5580,12 @@ describe("AcpAdapterV2", () => { let sessionUpdateHandler: | Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5668,7 +5624,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -5719,18 +5674,7 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); yield* TestClock.adjust("1 second"); assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); @@ -6451,14 +6395,18 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; const bufferedAssistantText = "BUFFERED_WAKE_AFTER_USER_ATTACH"; const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -6492,7 +6440,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -6548,28 +6495,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -6752,14 +6684,18 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const continuationRequests: Array = []; - const protocolEvents = yield* Queue.bounded(256); const promptWireReturned = yield* Deferred.make(); const releasePromptCompletion = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -6795,7 +6731,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -6875,18 +6810,13 @@ describe("AcpAdapterV2", () => { }, }); yield* Deferred.succeed(releasePromptCompletion, undefined); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -7223,7 +7153,6 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; const instanceId = ProviderInstanceId.make("acp-test"); const childSessionId = "mock-child-session-pending-continuation"; @@ -7231,7 +7160,12 @@ describe("AcpAdapterV2", () => { let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -7265,7 +7199,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -7321,28 +7254,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -7506,14 +7424,18 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; const instanceId = ProviderInstanceId.make("acp-test"); const childSessionId = "mock-child-session-root-then-child"; let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -7547,7 +7469,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -7603,28 +7524,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -7760,12 +7666,16 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; let cancelCalled = false; let runtimeOrdinalSeen = 0; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -7812,7 +7722,6 @@ describe("AcpAdapterV2", () => { runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); return { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }; }, - protocolEvents, wrapCancel: (cancel) => Effect.sync(() => { cancelCalled = true; @@ -7853,28 +7762,13 @@ describe("AcpAdapterV2", () => { ); // The still-running subagent defers finalize after session/prompt returns, // so the interrupt below hits a settled turn held open for background work. - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); assert.isFalse( cancelCalled, "settled soft steer must not send session/cancel (the real Grok CLI kills background subagents on cancel)", @@ -13725,10 +13619,14 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -13762,7 +13660,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -13799,32 +13696,17 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ - providerThread, - providerTurnId: firstProviderTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: firstProviderTurnId, + requestRuntimeRestart: true, + }); let subagentStatus: string | null = null; let firstTerminalStatus: string | null = null; From b05adb70b00feac0883b2e364256100cfab4b6ca Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 14:55:47 -0700 Subject: [PATCH 03/33] feat(usage): fold preview model IDs into the model they belong to (#15333) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/usage/UsageService.ts | 18 +- .../server/src/usage/usageAggregation.test.ts | 41 ++- apps/server/src/usage/usageAggregation.ts | 35 +- .../components/usage/UsagePriceOverrides.tsx | 331 +++++++++++------- .../components/usage/usagePriceTable.test.ts | 59 +++- .../src/components/usage/usagePriceTable.ts | 38 ++ .../usage/usagePriceTargets.test.ts | 32 ++ .../src/components/usage/usagePriceTargets.ts | 34 +- docs/user/usage.md | 5 + packages/client-runtime/src/state/server.ts | 15 +- packages/contracts/src/environment.ts | 2 + packages/contracts/src/settings.ts | 11 + packages/shared/src/serverSettings.ts | 9 + 14 files changed, 471 insertions(+), 160 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 5381645e9fcf..16a4a93b3f20 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -233,6 +233,7 @@ export const make = Effect.gen(function* () { environmentThemes: true, usageLimitSources: true, usagePriceOverrides: true, + usageModelAliases: true, threadPinning: true, threadPinReorder: true, threadActiveReorder: true, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index dc3121fc0024..ca56c36028bb 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -53,7 +53,7 @@ import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEn import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; import { readAntigravityUsage } from "./antigravityUsageReader.ts"; import { readCursorAccountUsage } from "./cursorUsageReader.ts"; -import { UsageAggregator } from "./usageAggregation.ts"; +import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, @@ -821,6 +821,7 @@ export const make = Effect.gen(function* () { ...hourlyWindow, rates, priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), + modelAliases: resolveModelAliases(settings.usageModelAliases), }); const sources: UsageSource[] = []; @@ -925,17 +926,13 @@ export const make = Effect.gen(function* () { }); /** - * In-flight scans by window and custom prices, so concurrent identical requests (the usage + * In-flight scans by window and usage settings, so concurrent identical requests (the usage * page open on two clients at once) share one scan instead of racing over * the same corpus twice. */ const inflightScans = new Map>(); - const scanKey = ( - input: UsageSummaryInput, - priceOverrides: ServerSettingsValue["usagePriceOverrides"], - cursorKeychainUsageEnabled: boolean, - ): string => + const scanKey = (input: UsageSummaryInput, settings: ServerSettingsValue): string => JSON.stringify([ input.timeZone, input.sinceDay, @@ -943,13 +940,14 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, - priceOverrides, - cursorKeychainUsageEnabled, + settings.usagePriceOverrides, + settings.usageModelAliases, + settings.cursorKeychainUsageEnabled, ]); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { const settings = yield* readSettings; - const key = scanKey(input, settings.usagePriceOverrides, settings.cursorKeychainUsageEnabled); + const key = scanKey(input, settings); const deferred = yield* Effect.uninterruptible( Effect.gen(function* () { const existing = inflightScans.get(key); diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 9fea9cd96644..a27a9b101cc3 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { UsageAggregator } from "./usageAggregation.ts"; +import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import type { RateTable } from "./usagePricing.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -253,6 +253,25 @@ describe("UsageAggregator", () => { expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false); }); + it("folds a mapped model into its target and prices it there", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + modelAliases: resolveModelAliases({ "example-preview": "claude-fable-5" }), + }); + aggregator.add(record()); + aggregator.add(record({ model: "example-preview", rateModel: "example-preview-high" })); + const result = aggregator.finish(); + + expect(result.buckets).toHaveLength(1); + expect(result.buckets[0]?.model).toBe("claude-fable-5"); + expect(result.buckets[0]?.records).toBe(2); + expect(result.buckets[0]?.costUsd).toBeCloseTo(0.00925, 9); + expect(result.buckets[0]?.unpricedRecords).toBe(0); + }); + it("separates providers and models into their own buckets", () => { const result = aggregate([ record(), @@ -263,3 +282,23 @@ describe("UsageAggregator", () => { expect(result.buckets).toHaveLength(3); }); }); + +describe("resolveModelAliases", () => { + it("follows chains to the final model and drops chains that enter a loop", () => { + expect( + resolveModelAliases({ + "preview[1m]": "preview", + preview: "example-model", + loop: "back", + back: "loop", + intoLoop: "loop", + self: "self", + }), + ).toEqual( + new Map([ + ["preview[1m]", "example-model"], + ["preview", "example-model"], + ]), + ); + }); +}); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 0652ca4b7dcf..f2923871a323 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -89,11 +89,35 @@ export interface AggregateOptions { readonly untilDay: string; readonly rates: RateTable; readonly priceOverrides?: RateTable; + /** From {@link resolveModelAliases}. Mapped records bucket and price as their target. */ + readonly modelAliases?: ReadonlyMap; readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; readonly untilTimeMs?: number; } +/** + * Resolves user model mappings to their final target, so `a -> b` and + * `b -> c` both land on `c`. A model whose chain enters a loop is left + * unmapped. + */ +export function resolveModelAliases( + aliases: Readonly>, +): ReadonlyMap { + const resolved = new Map(); + for (const model of Object.keys(aliases)) { + const seen = new Set([model]); + let target = aliases[model]!; + while (Object.hasOwn(aliases, target) && !seen.has(target)) { + seen.add(target); + target = aliases[target]!; + } + // Stopping on a mapped model means the chain entered a loop. + if (!Object.hasOwn(aliases, target)) resolved.set(model, target); + } + return resolved; +} + export interface AggregateResult { readonly buckets: readonly UsageBucket[]; /** Records dropped because an earlier record carried the same dedupe key. */ @@ -147,7 +171,8 @@ export class UsageAggregator { * can derive per-window facts (distinct sessions, for one) from the records * that landed rather than everything the mtime prefilter happened to admit. */ - add(record: UsageRecord, sourcePath?: string): boolean { + add(input: UsageRecord, sourcePath?: string): boolean { + const record = this.#mapModel(input); if (record.dedupeKey !== null) { if (this.#seen.has(record.dedupeKey)) { this.#duplicatesDropped += 1; @@ -217,6 +242,14 @@ export class UsageAggregator { return true; } + /** The target's own rate applies, so a provider-specific `rateModel` is dropped. */ + #mapModel(record: UsageRecord): UsageRecord { + const model = this.#options.modelAliases?.get(record.model); + if (model === undefined) return record; + const { rateModel: _rateModel, ...rest } = record; + return { ...rest, model }; + } + /** * Records arrive file by file in time order, so most land in the bucket the * previous record used. Checking that first skips building and hashing a diff --git a/apps/web/src/components/usage/UsagePriceOverrides.tsx b/apps/web/src/components/usage/UsagePriceOverrides.tsx index c9af1c0e1770..0b52687c81b0 100644 --- a/apps/web/src/components/usage/UsagePriceOverrides.tsx +++ b/apps/web/src/components/usage/UsagePriceOverrides.tsx @@ -34,6 +34,7 @@ import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { USAGE_PRICE_FIELDS } from "./usagePriceForm"; import { isEmptyUsagePriceDraft, + usageAliasCell, usagePriceCell, usagePriceTableChanges, usagePriceTableErrors, @@ -63,6 +64,10 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] => environmentId, label: environment.entry.target.label, prices: settings?.usagePriceOverrides ?? null, + aliases: + environment.serverConfig?.environment.capabilities.usageModelAliases === true + ? (settings?.usageModelAliases ?? null) + : null, unavailable: environment.connection.phase !== "connected" ? "Offline" @@ -91,7 +96,10 @@ type SaveAttempt = { >; }; -/** Edits custom model prices. `initialModel` opens with a new row for that model. */ +/** + * Edits custom model prices and mappings. A mapped model's usage counts as its + * target model. `initialModel` opens with a new row for that model. + */ export function UsagePriceOverrides({ usage, initialSelectedEnvironmentIds, @@ -108,12 +116,13 @@ export function UsagePriceOverrides({ const selected = environments.filter( (environment) => selectedIds === null || selectedIds.has(environment.environmentId), ); - // A model that already has a custom price somewhere is edited in its existing row. + // A model that already has a custom price or mapping is edited in its existing row. const [drafts, setDrafts] = useState(() => initialModel === undefined || selected.some( (environment) => - environment.prices !== null && Object.hasOwn(environment.prices, initialModel), + Object.hasOwn(environment.prices ?? {}, initialModel) || + Object.hasOwn(environment.aliases ?? {}, initialModel), ) ? [] : [{ id: "new:initial", model: initialModel, isNew: true, values: {} }], @@ -124,7 +133,12 @@ export function UsagePriceOverrides({ const nextRowId = useRef(0); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); const customModels = [ - ...new Set(selected.flatMap((environment) => Object.keys(environment.prices ?? {}))), + ...new Set( + selected.flatMap((environment) => [ + ...Object.keys(environment.prices ?? {}), + ...Object.keys(environment.aliases ?? {}), + ]), + ), ].sort(); const models = [ ...new Set([ @@ -191,19 +205,31 @@ export function UsagePriceOverrides({ ); setAttempt(null); }; + // An existing row edited back to its saved values has nothing left to save. + const matchesSaved = ( + row: UsagePriceDraft, + cell: { value: string; placeholder: string }, + value: string, + ) => + !row.isNew && + cell.placeholder !== "Mixed" && + cell.placeholder !== "Unavailable" && + value === cell.value; + const updateOrDropDraft = (draft: UsagePriceDraft) => { + if (!draft.isNew && Object.keys(draft.values).length === 0 && draft.alias === undefined) + setDrafts((previous) => previous.filter((entry) => entry.id !== draft.id)); + else updateDraft(draft); + }; const editCell = (row: UsagePriceDraft, field: UsagePriceField, value: string) => { const values = { ...row.values, [field]: value }; const original = usagePriceCell(selected, row.model, field); - if ( - !row.isNew && - original.placeholder !== "Mixed" && - original.placeholder !== "Unavailable" && - value === original.value - ) - delete values[field]; - if (!row.isNew && Object.keys(values).length === 0) - setDrafts((previous) => previous.filter((entry) => entry.id !== row.id)); - else updateDraft({ ...row, values }); + if (matchesSaved(row, original, value)) delete values[field]; + updateOrDropDraft({ ...row, values }); + }; + const editAlias = (row: UsagePriceDraft, value: string) => { + const { alias: _alias, ...rest } = row; + const original = usageAliasCell(selected, row.model); + updateOrDropDraft(matchesSaved(row, original, value) ? rest : { ...rest, alias: value }); }; const save = async (retry = false) => { if (pending) return; @@ -214,7 +240,7 @@ export function UsagePriceOverrides({ (destination) => environments.find( (environment) => environment.environmentId === destination.environmentId, - ) ?? { ...destination, prices: null, unavailable: "Environment removed" }, + ) ?? { ...destination, prices: null, aliases: null, unavailable: "Environment removed" }, ); const changes = new Map( targets.map((target) => [target.environmentId, usagePriceTableChanges(target, edits)]), @@ -257,11 +283,11 @@ export function UsagePriceOverrides({ if (!pending) onOpenChange(open); }} > - + Custom model prices - Prices apply to all past and future usage on the environments you select. + Prices and mappings apply to all past and future usage on the environments you select. @@ -326,9 +352,10 @@ export function UsagePriceOverrides({ ) : ( <>
- +
- + + {USAGE_PRICE_FIELDS.map((field) => ( ))} @@ -337,6 +364,7 @@ export function UsagePriceOverrides({ Model ID + Map to {USAGE_PRICE_FIELDS.map((field) => ( {field.label} ))} @@ -360,132 +388,157 @@ export function UsagePriceOverrides({ {rows.length === 0 ? ( - +

{selected.some((environment) => environment.prices === null) ? "Some environment prices are unavailable." - : "No custom prices. Add a row to override automatic pricing."} + : "No custom prices or mappings. Add a row to set one."}

) : ( - rows.map((row) => ( - - - {row.isNew ? ( - { - if (node && focusRowRef.current === row.id) { - node.focus(); - focusRowRef.current = null; + rows.map((row) => { + const aliasCell = usageAliasCell(selected, row.model); + const alias = (row.alias ?? aliasCell.value).trim(); + return ( + + + {row.isNew ? ( + { + if (node && focusRowRef.current === row.id) { + node.focus(); + focusRowRef.current = null; + } + }} + aria-label="New model ID" + aria-invalid={ + (row.model.trim() !== "" && errors.has(row.id)) || undefined + } + list="usage-price-models" + placeholder="Model ID" + autoComplete="off" + spellCheck={false} + disabled={locked} + onChange={(event) => + updateDraft({ ...row, model: event.target.value }) } - }} - aria-label="New model ID" - aria-invalid={ - (row.model.trim() !== "" && errors.has(row.id)) || undefined - } - list="usage-price-models" - placeholder="Model ID" - autoComplete="off" - spellCheck={false} - disabled={locked} - onChange={(event) => - updateDraft({ ...row, model: event.target.value }) - } - /> + /> + ) : ( + + {row.model} + + )} + {errors.has(row.id) && + (row.model.trim() !== "" || + Object.values(row.values).some((value) => value !== "")) ? ( +

+ {errors.get(row.id)} +

+ ) : null} +
+ {row.removed ? ( + + + Automatic pricing after saving + + ) : ( - - {row.model} - + + editAlias(row, event.target.value)} + /> + )} - {errors.has(row.id) && - (row.model.trim() !== "" || - Object.values(row.values).some((value) => value !== "")) ? ( -

- {errors.get(row.id)} -

- ) : null} -
- {row.removed ? ( - - - Automatic pricing after saving - - - ) : ( - USAGE_PRICE_FIELDS.map((field) => { - const cell = usagePriceCell(selected, row.model, field.key); - return ( - - - editCell(row, field.key, event.target.value) - } - /> - - ); - }) - )} - - - } - disabled={locked} - aria-label={ - row.removed - ? `Undo reset for ${row.model}` - : row.isNew - ? "Remove new model" - : `Reset price for ${row.model} to automatic` - } - onClick={() => { - if (row.isNew) - setDrafts((previous) => - previous.filter((entry) => entry.id !== row.id), - ); - else if (row.removed) { - if (Object.keys(row.values).length === 0) + {row.removed ? null : alias !== "" ? ( + + + Counted as {alias} + + + ) : ( + USAGE_PRICE_FIELDS.map((field) => { + const cell = usagePriceCell(selected, row.model, field.key); + return ( + + + editCell(row, field.key, event.target.value) + } + /> + + ); + }) + )} + + + } + disabled={locked} + aria-label={ + row.removed + ? `Undo reset for ${row.model}` + : row.isNew + ? "Remove new model" + : `Reset price for ${row.model} to automatic` + } + onClick={() => { + if (row.isNew) setDrafts((previous) => previous.filter((entry) => entry.id !== row.id), ); - else updateDraft({ ...row, removed: false }); - } else updateDraft({ ...row, removed: true }); - }} - > - {row.isNew ? : } - - - {row.removed - ? "Undo reset" - : row.isNew - ? "Remove row" - : "Reset to automatic"} - - - -
- )) + else if (row.removed) + updateOrDropDraft({ ...row, removed: false }); + else updateDraft({ ...row, removed: true }); + }} + > + {row.isNew ? ( + + ) : ( + + )} + + + {row.removed + ? "Undo reset" + : row.isNew + ? "Remove row" + : "Reset to automatic"} + + + +
+ ); + }) )}
@@ -497,8 +550,20 @@ export function UsagePriceOverrides({