From 393d595984f66df08504e1659997e12ad551bce9 Mon Sep 17 00:00:00 2001 From: blinding-pixels Date: Sun, 27 Sep 2026 01:03:04 +0530 Subject: [PATCH 01/10] fix(accessibility): correct control announcements and sidebar traversal (#13491) Co-authored-by: eshaan Sood Co-authored-by: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../connection/ConnectionFormField.test.tsx | 42 ++++++++++ .../connection/ConnectionFormField.tsx | 10 ++- apps/web/src/components/AppSidebarLayout.tsx | 2 + apps/web/src/components/Sidebar.logic.test.ts | 30 +++++++ apps/web/src/components/Sidebar.logic.ts | 15 ++++ apps/web/src/components/Sidebar.tsx | 51 ++++++++++-- apps/web/src/components/ui/scroll-area.tsx | 4 + apps/web/src/components/ui/sidebar.tsx | 7 +- apps/web/src/components/ui/switch.test.tsx | 82 ++++++++++++++++--- 9 files changed, 219 insertions(+), 24 deletions(-) create mode 100644 apps/mobile/src/features/connection/ConnectionFormField.test.tsx diff --git a/apps/mobile/src/features/connection/ConnectionFormField.test.tsx b/apps/mobile/src/features/connection/ConnectionFormField.test.tsx new file mode 100644 index 000000000000..343d44cf376b --- /dev/null +++ b/apps/mobile/src/features/connection/ConnectionFormField.test.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("react-native", () => ({ + View: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("../../components/AppText", () => ({ + AppText: ({ + accessibilityElementsHidden, + importantForAccessibility, + children, + }: { + accessibilityElementsHidden?: boolean; + importantForAccessibility?: string; + children: ReactNode; + }) => ( + + {children} + + ), + AppTextInput: ({ accessibilityLabel }: { accessibilityLabel?: string }) => ( + + ), +})); + +import { ConnectionFormField } from "./ConnectionFormField"; + +describe("ConnectionFormField accessibility", () => { + it.each(["Host", "Pairing code"])("exposes %s once as the input name", (label) => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain(``); + expect(markup).toContain(``); + }); +}); diff --git a/apps/mobile/src/features/connection/ConnectionFormField.tsx b/apps/mobile/src/features/connection/ConnectionFormField.tsx index 0d5e21148e54..b492dd9ce43c 100644 --- a/apps/mobile/src/features/connection/ConnectionFormField.tsx +++ b/apps/mobile/src/features/connection/ConnectionFormField.tsx @@ -3,7 +3,7 @@ import { View } from "react-native"; import { AppText, AppTextInput, type AppTextInputProps } from "../../components/AppText"; import { cn } from "../../lib/cn"; -type ConnectionFormFieldProps = Omit & { +type ConnectionFormFieldProps = Omit & { readonly label: string; readonly className?: string; }; @@ -12,12 +12,16 @@ type ConnectionFormFieldProps = Omit & { export function ConnectionFormField({ label, className, ...inputProps }: ConnectionFormFieldProps) { return ( - + {label} diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 7e3eaad1125b..05d965d95d06 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -304,6 +304,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { side="left" collapsible="offcanvas" data-app-sidebar="" + role="navigation" + aria-label={isOnSettings ? "Settings" : "Threads"} resizable={{ maxWidth: sidebarMaximumWidth, minWidth: THREAD_SIDEBAR_MIN_WIDTH, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index b4b07805596d..fede3c183448 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -23,6 +23,7 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, + resolveSidebarRowAccessibility, resolveSidebarThreadStatus, resolveThreadStatusPill, resolveWorkingStartedAt, @@ -69,6 +70,35 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("resolveSidebarRowAccessibility", () => { + it.each([ + { + title: "Can you audit the UI?", + statusLabel: "Working", + projectDisplayName: "T3 Code", + isActive: true, + expected: { label: "Can you audit the UI?, Working, T3 Code", current: "page" }, + }, + { + title: "The audit is done", + statusLabel: null, + projectDisplayName: "T3 Code", + isActive: false, + expected: { label: "The audit is done, T3 Code", current: undefined }, + }, + { + title: "Untitled task", + statusLabel: null, + projectDisplayName: null, + isActive: false, + expected: { label: "Untitled task", current: undefined }, + }, + ])("leads with the title without folding row actions into its name: %j", (input) => { + const { expected, ...state } = input; + expect(resolveSidebarRowAccessibility(state)).toEqual(expected); + }); +}); + describe("animateSidebarLayoutChanges", () => { const baseArgs: Parameters[0] = { active: null, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 3e12090f3228..2796b2f7f885 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -42,6 +42,21 @@ export function shouldNavigateAfterThreadPark(input: { const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; + +export function resolveSidebarRowAccessibility(input: { + readonly title: string; + readonly statusLabel: string | null; + readonly projectDisplayName: string | null; + readonly isActive: boolean; +}): { readonly label: string; readonly current: "page" | undefined } { + return { + // The title is the row's identity and must lead when users scan tasks. + // Only static context belongs here; nested action labels remain separate controls. + label: [input.title, input.statusLabel, input.projectDisplayName].filter(Boolean).join(", "), + current: input.isActive ? "page" : undefined, + }; +} + // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. Each prewarmed // thread holds a live, fully hydrated detail subscription (all messages and diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4926df4948ae..90f65ea2b941 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -173,6 +173,7 @@ import { resolveAdjacentThreadId, resolveSidebarDropTarget, resolveSidebarDropVerb, + resolveSidebarRowAccessibility, type SidebarDropVerb, resolveSidebarThreadStatus, searchSidebarThreads, @@ -728,6 +729,12 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { promptPreview.length > 0 ? promptPreview : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; + const accessibility = resolveSidebarRowAccessibility({ + title: preview, + statusLabel: "Unsent draft", + projectDisplayName: props.projectDisplayName, + isActive: props.isActive, + }); const handleActivate = useCallback(() => onNavigate(draftId), [draftId, onNavigate]); const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { @@ -755,6 +762,8 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
+ {preview}
@@ -790,7 +800,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
-
{preview}
+
+ {preview} +
@@ -1447,6 +1459,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null; + const accessibility = resolveSidebarRowAccessibility({ + title: thread.title, + statusLabel: topStatus?.label ?? null, + projectDisplayName: props.projectDisplayName, + isActive: props.isActive, + }); + const title = isRenaming ? ( ) : ( ); + const accessibleTitle = isRenaming ? null : {thread.title}; // Stacks show their layer count; multiple unrelated links show their total count. // Plain clicks open T3; individual PR links also support opening the host in a new tab. @@ -1593,6 +1614,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ref={rowRef} role="button" tabIndex={0} + aria-label={accessibility.label} + aria-current={accessibility.current} data-testid="sidebar-row-slim" aria-busy={isRegeneratingTitle || undefined} className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")} @@ -1603,6 +1626,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> } > + {accessibleTitle} {/* Settled history recedes: dimmed favicon at rest, restored on hover so the tail stays scannable when you're hunting. */} } > + {accessibleTitle}
{draftIndicator} @@ -2008,6 +2035,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; }) { const { thread } = props; + const accessibility = resolveSidebarRowAccessibility({ + title: thread.title, + statusLabel: null, + projectDisplayName: props.projectDisplayName, + isActive: props.isRouteActive, + }); const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], @@ -2084,12 +2117,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { // which owns all keyboard interaction for the listbox. tabIndex={-1} aria-selected={props.isHighlighted} - aria-current={props.isRouteActive ? "page" : undefined} - aria-label={ - props.projectDisplayName - ? `${thread.title}, ${props.projectDisplayName}` - : thread.title - } + aria-current={accessibility.current} + aria-label={accessibility.label} onMouseMove={props.onHighlight} onClick={props.onSelect} className={cn( @@ -4573,7 +4602,7 @@ export default function Sidebar() { } > - + {isSearchingThreads ? ( threadSearchResults.length > 0 ? (
    0 && "flex-1", diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index 7792b9606e41..ce9d41edaa5d 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -30,6 +30,7 @@ function ScrollArea({ hideScrollbars = false, chainVerticalScroll = false, radius = "inherit", + viewportTabIndex, ...props }: ScrollAreaPrimitive.Root.Props & { scrollFade?: boolean; @@ -41,6 +42,8 @@ function ScrollArea({ chainVerticalScroll?: boolean; /** The viewport clips to the parent's radius; "none" for a region flush to an edge. */ radius?: "inherit" | "none"; + /** Override Base UI's focusable viewport when focusable descendants provide scroll access. */ + viewportTabIndex?: number; }) { return ( - + {children}
@@ -597,6 +598,10 @@ function SidebarContent({ hideScrollbars scrollFade scrollFadePadding={false} + // Thread rows provide keyboard access to this scroll region. Keeping + // Base UI's viewport out of the tab order lets its presentational role + // flatten in WebKit instead of becoming a VoiceOver interaction group. + viewportTabIndex={-1} className="h-auto min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]]:[--fade-size:0.75rem]" >
[0]>) { - const html = renderToStaticMarkup(); - return html.match(/]*role="switch"[^>]*>/)?.[0] ?? ""; +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +function ControlledSwitch({ + initialChecked, + initialMixed = false, +}: { + initialChecked: boolean; + initialMixed?: boolean; +}) { + const [checked, setChecked] = useState(initialChecked); + const [mixed, setMixed] = useState(initialMixed); + + return ( + { + setMixed(false); + setChecked(nextChecked); + }} + /> + ); } -describe("Switch accessibility", () => { - it("exposes the checked state to assistive tech", () => { - expect(renderSwitchRoot({ checked: true })).toContain('aria-checked="true"'); - expect(renderSwitchRoot({ checked: false })).toContain('aria-checked="false"'); +function switchRoot() { + const element = container.querySelector('[role="switch"]'); + if (!element) throw new Error("Switch was not rendered"); + return element; +} + +async function clickSwitch() { + await act(async () => switchRoot().click()); +} + +async function renderSwitch(initialChecked: boolean, initialMixed = false) { + await act(async () => { + root.render(); }); +} + +describe("Switch accessibility", () => { + it.each([ + { initialChecked: false, initialMixed: false, before: false, after: true }, + { initialChecked: true, initialMixed: false, before: true, after: false }, + { initialChecked: false, initialMixed: true, before: "mixed", after: true }, + ])("exposes $before before activation and $after after activation", async (state) => { + await renderSwitch(state.initialChecked, state.initialMixed); + expect(switchRoot().getAttribute("aria-checked")).toBe(String(state.before)); + + await clickSwitch(); - it("exposes the mixed state", () => { - expect(renderSwitchRoot({ checked: false, mixed: true })).toContain('aria-checked="mixed"'); + expect(switchRoot().getAttribute("aria-checked")).toBe(String(state.after)); + expect(container.querySelector('input[type="checkbox"]')?.checked).toBe( + state.after, + ); }); }); From 10bb59bf061252f5edadf96bc8139911b3fed7fd Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Sat, 26 Sep 2026 20:55:18 +0100 Subject: [PATCH 02/10] fix(usage): tolerate newer provider variants (#10076) --- docs/user/usage.md | 3 + packages/contracts/src/usage.ts | 8 ++- packages/shared/src/usageMerge.test.ts | 79 +++++++++++++++++++++++++- 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/docs/user/usage.md b/docs/user/usage.md index dc55aba41023..64f392d6e941 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -31,6 +31,9 @@ variable. Use absolute paths or `~/` paths in the account's environment settings environment paths depend on each project's working directory and cannot be reliably discovered by Usage. Accounts sharing a history directory count once. +When your app and server support different providers, usage totals may cover only the providers +your app understands. Update the app to include newly supported providers. + On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All environments are selected by default. The dropdown shows which environments are still scanning; results appear as each one responds. diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index a6431330558a..97db8594379a 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -11,12 +11,14 @@ */ import * as Schema from "effect/Schema"; -import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ForwardCompatibleArray, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; /** * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. The * client renders partial coverage when an environment reports an older version * rather than failing the whole page. + * Adding providers or other array-element variants is additive: unknown + * entries are skipped on decode and do not require a version bump. */ export const USAGE_CONTRACT_VERSION = 6 as const; @@ -201,8 +203,8 @@ export const UsageSummary = Schema.Struct({ timeZone: TrimmedNonEmptyString, sinceDay: UsageDay, untilDay: UsageDay, - buckets: Schema.Array(UsageBucket), - sources: Schema.Array(UsageSource), + buckets: ForwardCompatibleArray(UsageBucket), + sources: ForwardCompatibleArray(UsageSource), pricing: UsagePricing, /** Wall-clock cost of the scan, surfaced in diagnostics. */ scanDurationMs: NonNegativeInt, diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 178282238482..d7fc15144b7c 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -5,12 +5,16 @@ import { type UsageBucket, type UsageDay, type UsageProviderKind, - type UsageSummary, + UsageSummary, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; import { isModelCostUnknown, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; +const decodeSummary = Schema.decodeUnknownSync(UsageSummary); +const encodeSummary = Schema.encodeSync(UsageSummary); + function bucket(overrides: Partial = {}): UsageBucket { return { day: "2026-08-07" as UsageDay, @@ -374,6 +378,79 @@ describe("mergeUsage", () => { expect(merged.staleEnvironments).toEqual([]); }); + it("keeps known usage when newer providers and bucket variants cannot be decoded", () => { + const known = summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }]); + const decoded = decodeSummary({ + ...known, + buckets: [ + ...known.buckets, + { ...bucket(), provider: "future-provider", costUsd: 100 }, + { ...bucket(), costSource: "future-pricing", costUsd: 200 }, + ], + sources: [ + ...known.sources, + { + ...known.sources[0], + fingerprint: { + ...known.sources[0]?.fingerprint, + provider: "future-provider", + }, + }, + ], + }); + expect(decoded).toEqual(known); + const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(10); + expect(merged.totalTokens).toBe(1160); + expect(merged.staleEnvironments).toEqual([]); + }); + + it("normalizes model names during decoding before grouping usage", () => { + const decoded = decodeSummary( + summary( + [ + bucket({ provider: "codex", model: " gpt-5 " }), + bucket({ provider: "codex", model: "gpt-5" }), + ], + [{ provider: "codex", hostId: "mac", homePath: "/a" }], + ), + ); + + expect(decoded.buckets.map((entry) => entry.model)).toEqual(["gpt-5", "gpt-5"]); + const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION); + expect(merged.models).toHaveLength(1); + expect(merged.models[0]).toMatchObject({ model: "gpt-5", costUsd: 20, totalTokens: 2320 }); + }); + + it("keeps all supported providers when encoding a response", () => { + const current = summary( + [bucket(), bucket({ provider: "grok" })], + [ + { provider: "claude", hostId: "mac", homePath: "/a" }, + { provider: "grok", hostId: "mac", homePath: "/b" }, + ], + ); + expect(encodeSummary(current)).toEqual(current); + expect(decodeSummary(encodeSummary(current))).toEqual(current); + }); + + it("still rejects a malformed summary envelope", () => { + expect(() => decodeSummary({ ...summary([], []), buckets: null })).toThrow(); + }); + + it("excludes a future incompatible contract even when its buckets still decode", () => { + const decoded = decodeSummary( + summary( + [bucket()], + [{ provider: "claude", hostId: "mac", homePath: "/a" }], + USAGE_CONTRACT_VERSION + 1, + ), + ); + const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(0); + expect(merged.staleEnvironments).toEqual(["env-a"]); + }); + it("derives provider shares and cost quality", () => { const merged = mergeUsage( [ From d6802b4acfbb6bfb669e0fa225539920622f7670 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Sat, 26 Sep 2026 20:56:11 +0100 Subject: [PATCH 03/10] fix(usage): omit Cursor warning when no login is saved (#13820) --- apps/server/src/usage/UsageService.test.ts | 49 ++++++++++++++++++---- apps/server/src/usage/UsageService.ts | 2 + 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index a80a31f48ae6..15ea4b673f22 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -121,6 +121,46 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("omits Cursor account usage when no file login is saved", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + for (const platform of ["linux", "win32", "darwin"] as const) { + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: `usage-service-cursor-no-login-${platform}`, + home, + settings, + platform, + environment: { AGENT_CLI_CREDENTIAL_STORE: "file" }, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + assert.isFalse(summary.sources.some((source) => source.fingerprint.provider === "cursor")); + } + }).pipe(Effect.scoped), + ); + + it.live("keeps Cursor credential errors visible when a saved login cannot be read", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const authPath = NodePath.join(home, "config", "cursor", "auth.json"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(NodePath.dirname(authPath), { recursive: true }); + await NodeFSP.writeFile(authPath, "invalid json"); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-cursor-invalid-login", home, settings }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor"); + assert.strictEqual(cursor?.message, "Cursor credentials could not be read."); + }).pipe(Effect.scoped), + ); + it.live("does not read the macOS Cursor Keychain before account usage is enabled", () => Effect.gen(function* () { const { settings, home } = yield* setup; @@ -233,10 +273,7 @@ describe("UsageService", () => { const summary = yield* service.readSummary(WINDOW); assert.strictEqual(summary.buckets[0]?.provider, "opencode"); assert.isFalse(summary.buckets.some((bucket) => bucket.provider === "cursor")); - assert.strictEqual( - summary.sources.find((source) => source.fingerprint.provider === "cursor")?.status, - "missing", - ); + assert.isFalse(summary.sources.some((source) => source.fingerprint.provider === "cursor")); assert.strictEqual( summary.buckets[0]?.sourcePath, yield* Effect.promise(() => NodeFSP.realpath(root)), @@ -247,10 +284,6 @@ describe("UsageService", () => { ?.distinctSessions, 1, ); - assert.include( - summary.sources.find((source) => source.fingerprint.provider === "cursor")?.message ?? "", - "Cursor account history needs a Cursor CLI login", - ); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 6930c7307d8c..1e5cb6db20fe 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -635,6 +635,8 @@ export const make = Effect.gen(function* () { cursorUntilMs, ), ); + // No saved login means there is no account source to report, not a setup error. + if (account.missing && account.error === null) return scanned; if (account.accountKey !== null && account.error === null && !account.missing) { // The same account includes CLI and desktop history from every machine. // A stable remote fingerprint prevents connected environments counting it twice. From 3dae78f33b1e3c7534feb51ca41a407ab2536b83 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Sat, 26 Sep 2026 20:58:29 +0100 Subject: [PATCH 04/10] fix(usage): identify client version mismatches (#8208) --- .../src/features/usage/UsageRouteScreen.tsx | 8 +++- apps/web/src/components/usage/UsagePage.tsx | 32 +++++++++------- packages/shared/src/usageFormat.ts | 11 ++++++ packages/shared/src/usageMerge.test.ts | 37 +++++++++++++++++-- packages/shared/src/usageMerge.ts | 29 +++++++++++---- 5 files changed, 92 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 10878179d2ac..8fb2fe37aa8d 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -15,6 +15,7 @@ import { formatHourShort, formatPercent, formatTokens, + formatUsageContractMismatch, formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; @@ -753,7 +754,12 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string { environment.summary && !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION) ) { - return "Older server · excluded from usage totals"; + return formatUsageContractMismatch(environment.label, { + direction: + environment.summary.contractVersion < USAGE_CONTRACT_VERSION + ? "serverBehind" + : "clientBehind", + }); } if (!environment.isConnected) return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…"; diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 7d306d1a5c07..e978963ce9b5 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -20,6 +20,7 @@ import { isModelCostUnknown, type DailyTotals, type HourlyTotals, + type MergedUsage, } from "@t3tools/shared/usageMerge"; import { isElectron } from "../../env"; @@ -40,6 +41,7 @@ import { formatHourShort, formatPercent, formatTokens, + formatUsageContractMismatch, formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; @@ -314,7 +316,7 @@ export function UsagePage() { showUsageStatus={!showingLimits} isPartial={isPartial} duplicateSources={merged.duplicateSources} - staleEnvironments={merged.staleEnvironments} + contractMismatches={merged.contractMismatches} /> @@ -899,17 +901,21 @@ function Metric({ label, value }: { readonly label: string; readonly value: stri function UsageCoverageNotice({ environments, duplicateSources, - staleEnvironments, + contractMismatches, }: { readonly environments: readonly EnvironmentUsageStatus[]; readonly duplicateSources: readonly string[]; - readonly staleEnvironments: readonly string[]; + readonly contractMismatches: MergedUsage["contractMismatches"]; }) { const failed = environments.filter((environment) => environment.error !== null); - const stale = environments.filter((environment) => - staleEnvironments.includes(environment.environmentId), + const mismatchByEnvironment = new Map( + contractMismatches.map((mismatch) => [mismatch.environmentId, mismatch]), ); - if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0) { + const incompatible = environments.flatMap((environment) => { + const mismatch = mismatchByEnvironment.get(environment.environmentId); + return mismatch === undefined ? [] : [{ environment, mismatch }]; + }); + if (failed.length === 0 && incompatible.length === 0 && duplicateSources.length === 0) { return null; } @@ -918,9 +924,9 @@ function UsageCoverageNotice({ {failed.map((environment) => ( {environment.label} could not report usage. ))} - {stale.map((environment) => ( - - {environment.label} runs an older server version and is excluded from totals. + {incompatible.map(({ environment, mismatch }) => ( + + {formatUsageContractMismatch(environment.label, mismatch)} ))} {duplicateSources.length > 0 ? ( @@ -942,7 +948,7 @@ function UsageEnvironmentFilter({ showUsageStatus, isPartial, duplicateSources, - staleEnvironments, + contractMismatches, }: { readonly environments: readonly EnvironmentUsageStatus[]; readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; @@ -951,7 +957,7 @@ function UsageEnvironmentFilter({ readonly showUsageStatus: boolean; readonly isPartial: boolean; readonly duplicateSources: readonly string[]; - readonly staleEnvironments: readonly string[]; + readonly contractMismatches: MergedUsage["contractMismatches"]; }) { const [modelPricesOpen, setModelPricesOpen] = useState(false); const allSelected = selectedEnvironmentIds === null; @@ -966,7 +972,7 @@ function UsageEnvironmentFilter({ ).length; const hasIssue = selectedEnvironments.some((environment) => environment.error !== null) || - staleEnvironments.length > 0; + contractMismatches.length > 0; return ( <> @@ -1066,7 +1072,7 @@ function UsageEnvironmentFilter({ ) : null} diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 328e0cf814e7..83b2d53fb99f 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -6,6 +6,8 @@ */ import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools/contracts"; +import type { UsageContractMismatch } from "./usageMerge.ts"; + const CURRENCY = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", @@ -49,6 +51,15 @@ export function formatPercent(share: number, digits = 1): string { return `${percent.toFixed(digits)}%`; } +export function formatUsageContractMismatch( + environmentLabel: string, + mismatch: Pick, +): string { + return mismatch.direction === "serverBehind" + ? `${environmentLabel} runs an older server version and is excluded from totals.` + : `This client is older than the server on ${environmentLabel}; its usage is excluded from totals.`; +} + /** `2026-08-07` to `Aug 7`. */ export function formatDayShort(day: string): string { const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part)); diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index d7fc15144b7c..7db9da394dac 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -329,7 +329,7 @@ describe("mergeUsage", () => { expect(merged.contributingEnvironments).toEqual(["old"]); }); - it("excludes an environment reporting an older contract version", () => { + it("identifies an environment reporting an older contract version", () => { const merged = mergeUsage( [ environment( @@ -349,7 +349,38 @@ describe("mergeUsage", () => { ); expect(merged.costUsd).toBe(10); - expect(merged.staleEnvironments).toEqual(["env-b"]); + expect(merged.contractMismatches).toEqual([ + { + environmentId: "env-b", + direction: "serverBehind", + contractVersion: USAGE_MERGE_COMPATIBLE_SINCE - 1, + }, + ]); + }); + + it("identifies an environment reporting a newer contract version", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [bucket()], + [{ provider: "claude", hostId: "mac", homePath: "/a" }], + USAGE_CONTRACT_VERSION + 1, + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(0); + expect(merged.contractMismatches).toEqual([ + { + environmentId: "env-a", + direction: "clientBehind", + contractVersion: USAGE_CONTRACT_VERSION + 1, + }, + ]); }); it("keeps the previous compatible contract version so additive provider expansions still merge", () => { @@ -375,7 +406,7 @@ describe("mergeUsage", () => { ); expect(merged.costUsd).toBe(14); - expect(merged.staleEnvironments).toEqual([]); + expect(merged.contractMismatches).toEqual([]); }); it("keeps known usage when newer providers and bucket variants cannot be decoded", () => { diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index c4a56829a420..90c9a1ff4261 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -76,6 +76,12 @@ export interface CostQuality { readonly cacheSavingsUsd: number; } +export interface UsageContractMismatch { + readonly environmentId: EnvironmentId; + readonly direction: "serverBehind" | "clientBehind"; + readonly contractVersion: number; +} + export interface MergedUsage { readonly costUsd: number; readonly uncachedInputTokens: number; @@ -94,7 +100,7 @@ export interface MergedUsage { /** Environments whose data was dropped as a duplicate of another's. */ readonly duplicateSources: readonly string[]; readonly contributingEnvironments: readonly EnvironmentId[]; - readonly staleEnvironments: readonly EnvironmentId[]; + readonly contractMismatches: readonly UsageContractMismatch[]; } /** @@ -303,15 +309,15 @@ const EMPTY_MERGED: MergedUsage = { }, duplicateSources: [], contributingEnvironments: [], - staleEnvironments: [], + contractMismatches: [], }; /** * Merges every connected environment's summary. * - * `expectedContractVersion` guards against an environment running older server - * code: rather than blocking the page, incompatible data is excluded and its - * id is reported so the UI can say coverage is partial. Versions in + * `expectedContractVersion` guards against incompatible server code: rather + * than blocking the page, its data is excluded and the mismatch direction is + * reported so the UI can identify which side needs updating. Versions in * [{@link USAGE_MERGE_COMPATIBLE_SINCE}, expected] still merge, so an additive * provider expansion does not drop Claude/Codex totals from older servers. */ @@ -322,14 +328,21 @@ export function mergeUsage( if (environments.length === 0) return EMPTY_MERGED; const current: EnvironmentUsage[] = []; - const staleEnvironments: EnvironmentId[] = []; + const contractMismatches: UsageContractMismatch[] = []; for (const environment of environments) { if ( isCompatibleUsageContractVersion(environment.summary.contractVersion, expectedContractVersion) ) { current.push(environment); } else { - staleEnvironments.push(environment.environmentId); + contractMismatches.push({ + environmentId: environment.environmentId, + direction: + environment.summary.contractVersion < expectedContractVersion + ? "serverBehind" + : "clientBehind", + contractVersion: environment.summary.contractVersion, + }); } } @@ -544,6 +557,6 @@ export function mergeUsage( }, duplicateSources: duplicates, contributingEnvironments, - staleEnvironments, + contractMismatches, }; } From dd582dee36976ccfd2a896cf50c3ee957ca7b9be Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Sat, 26 Sep 2026 20:59:18 +0100 Subject: [PATCH 05/10] fix(web): stop mistaking offline servers for updates (#13083) --- apps/web/src/components/ChatView.tsx | 78 +++++++++------------------- 1 file changed, 25 insertions(+), 53 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3262ff9cc4a3..61f1770c4bef 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2705,68 +2705,40 @@ export default function ChatView(props: ChatViewProps) { unavailableConnection !== null && (unavailableConnection.phase === "connecting" || unavailableConnection.phase === "reconnecting"); - // Reconnecting to a version-skewed server with no update in flight - // usually means the server is restarting mid-update and a refresh wiped - // the in-memory update state. Fold the reconnect and version banners - // into one calm line instead of stacking "Failed to connect" on - // "versions differ". A failed update never folds: its error and retry - // action must stay visible. - const reconnectingThroughVersionSkew = - serverUpdateState.status === "idle" && environmentReconnecting && versionMismatch !== null; // While an update runs, transient connect blips are expected (the server // restarts) and the update banner already shows progress. Hard failure // phases still surface so the Reconnect action stays reachable. const suppressUnavailableBanner = - environmentReconnecting && - (updateRunning || (!reconnectingThroughVersionSkew && !reconnectWarningGraceElapsed)); + environmentReconnecting && (updateRunning || !reconnectWarningGraceElapsed); if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { - if (reconnectingThroughVersionSkew) { - items.push({ - id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: "default", - // Prioritize live connection progress among the notices. - priority: "urgent", - icon: ( -