From 2be7bda081b508aa32711888ccf1e1d33359079c Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 4 Sep 2026 23:10:40 +0100 Subject: [PATCH 01/18] feat: show provider usage limits with /usage-limits Sending /usage-limits in a thread opens the current model's limits above the composer, built from the same snapshot as Usage > Limits. The command resolves in the client without starting a turn, so nothing is written to the thread; the panel closes on dismiss or the next send. Web renders it as a composer notice so it stacks under warnings and uses the standard dismiss. Mobile docks an opaque card in the approval slot. The server only advertises the command for providers present in Limits. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- .../features/threads/ComposerUsageLimits.tsx | 88 ++++++++++++ .../src/features/threads/ThreadComposer.tsx | 29 +++- .../features/threads/ThreadDetailScreen.tsx | 30 ++++ .../src/features/usage/UsageLimitsSection.tsx | 42 ++++-- apps/server/src/server.test.ts | 124 ++++++++++------- apps/server/src/ws.ts | 28 +++- apps/web/src/components/ChatView.tsx | 49 +++++++ .../components/chat/ComposerUsageLimits.tsx | 90 ++++++++++++ apps/web/src/components/usage/UsageLimits.tsx | 31 +++-- docs/user/usage.md | 5 + packages/contracts/src/providerUsageLimits.ts | 21 +++ packages/shared/src/usageLimits.test.ts | 130 ++++++++++++++++++ packages/shared/src/usageLimits.ts | 111 ++++++++++++++- 13 files changed, 702 insertions(+), 76 deletions(-) create mode 100644 apps/mobile/src/features/threads/ComposerUsageLimits.tsx create mode 100644 apps/web/src/components/chat/ComposerUsageLimits.tsx diff --git a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx new file mode 100644 index 000000000000..e49b508ff110 --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx @@ -0,0 +1,88 @@ +import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; +import { providerLimitsLabel } from "@t3tools/shared/usageLimits"; +import { Pressable, ScrollView, useWindowDimensions, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { AccountLimits, ResetCredits } from "../usage/UsageLimitsSection"; + +const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; + +/** + * The /usage-limits result, docked above the composer. It is the Usage → Limits + * card one size down, so the two read as the same thing. The surface is opaque + * because nothing blurs the feed behind it. + */ +export function ComposerUsageLimits({ + report, + environmentId, + onClose, +}: { + readonly report: UsageLimitsReport; + readonly environmentId: EnvironmentId; + readonly onClose: () => void; +}) { + const now = Date.parse(report.createdAt); + const { height } = useWindowDimensions(); + const close = ( + + + + ); + return ( + + + {report.accounts.map((account, index) => { + const driverLabel = DRIVER_LABEL[account.driver] ?? String(account.driver); + return ( + DRIVER_LABEL[driver]) + : (account.sourceLabel ?? account.label) + } + detail={account.plan} + limits={account.limits} + now={now} + trailing={index === 0 ? close : undefined} + footer={ + account.instanceId && account.limits.resetCredits ? ( + + ) : undefined + } + /> + ); + })} + {report.notices.map((notice) => ( + + {notice} + + ))} + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index af3359ec8c79..1af078cc1c46 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -7,7 +7,9 @@ import type { ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, + UsageLimitsReport, } from "@t3tools/contracts"; +import { collectProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; import { @@ -20,7 +22,7 @@ import { useState, type RefObject, } from "react"; -import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; +import { ActivityIndicator, Alert, Platform, Pressable, View, type ViewStyle } from "react-native"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { composerAttachmentUploadBlockReason, @@ -124,6 +126,8 @@ export interface ThreadComposerProps { readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; + /** `/usage-limits` resolves locally; the host decides where the report shows. */ + readonly onShowUsageLimits: (report: UsageLimitsReport) => void; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; @@ -428,9 +432,25 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } onEditorFocusChange?.(false); }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]); - const { onSendMessage } = props; + const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props; const handleSend = useCallback(async () => { + // Answered locally from the last Limits snapshot; the agent never sees it. + if (isUsageLimitsCommand(props.draftMessage)) { + const report = collectProviderUsageLimits( + currentModelSelection.instanceId, + props.serverConfig?.providers ?? [], + props.serverConfig?.usageLimitSources ?? [], + Date.now(), + ); + if (report) { + onChangeDraftMessage(""); + onShowUsageLimits(report); + } else { + Alert.alert("Usage limits unavailable", "This provider does not currently report limits."); + } + return; + } if (voiceInput.blocksSubmission) return; const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; @@ -453,6 +473,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer inFlightThreadIdsRef.current.delete(threadKey); } }, [ + props.draftMessage, + props.serverConfig, + onChangeDraftMessage, + onShowUsageLimits, + currentModelSelection.instanceId, onSendMessage, props.environmentId, props.environmentLabel, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d0e553ebdcf9..e0dab3fc53c6 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -19,6 +19,7 @@ import type { RuntimeMode, ServerConfig as T3ServerConfig, ThreadId, + UsageLimitsReport, UserInputQuestion, } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; @@ -70,6 +71,7 @@ import type { ThreadFeedEntry, } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; +import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; import { FLOATING_WORKING_CONTROL_COVERAGE, @@ -359,6 +361,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = useState(null); const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; + // A /usage-limits snapshot for this thread and model; sending or switching clears it. + const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ + readonly key: string; + readonly report: UsageLimitsReport; + } | null>(null); + const usageLimitsKey = `${selectedThreadKey}:${props.selectedThread.modelSelection.instanceId}`; + const usageLimitsReport = + usageLimitsPanel?.key === usageLimitsKey ? usageLimitsPanel.report : null; + const showUsageLimits = useCallback( + (report: UsageLimitsReport) => setUsageLimitsPanel({ key: usageLimitsKey, report }), + [usageLimitsKey], + ); + const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []); const userInputCollapsed = activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; // The card's height RESERVES keyboard space at all times instead of @@ -614,6 +629,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ]); const handleSendMessage = useCallback(async () => { + setUsageLimitsPanel(null); const targetThreadKey = selectedThreadKey; const hasUserMessage = selectedThreadFeed.some( (entry) => entry.type === "message" && entry.message.role === "user", @@ -778,6 +794,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onScrollToEnd={handleScrollToEnd} /> + {usageLimitsReport && activeUserInputRequestId === null ? ( + + + + ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( = 90 - ? "h-full rounded-full bg-destructive" + ? "h-full rounded-full bg-red-500" : used >= 70 - ? "h-full rounded-full bg-warning" + ? "h-full rounded-full bg-amber-500" : "h-full rounded-full bg-foreground" } style={[ @@ -99,7 +99,7 @@ function WindowRow(props: { } /** One account: icon, name and plan on a single line, then its windows. */ -function AccountLimits(props: { +export function AccountLimits(props: { readonly driver: Driver; readonly label: string; readonly instanceLabel: string; @@ -107,14 +107,23 @@ function AccountLimits(props: { readonly limits: ServerProvider["usageLimits"]; readonly now: number; readonly first: boolean; + /** Tighter padding for the composer card. */ + readonly dense?: boolean; + /** Sits at the end of the heading row, such as a close control. */ + readonly trailing?: ReactNode; readonly footer?: ReactNode; }) { - const { limits, now } = props; + const { limits, now, dense = false } = props; const color = useBarColor(props.driver); if (!limits) return null; const notice = limitsNotice(limits); + const padding = dense ? "px-4 py-3" : "p-4"; return ( - + @@ -130,6 +139,7 @@ function AccountLimits(props: { ) : null} + {props.trailing} {notice ? ( {notice} @@ -157,13 +167,15 @@ const OUTCOME_TEXT: Record = { * credit the provider granted the user, so it goes through the native * confirm alert rather than firing on a bare tap. */ -function ResetCredits(props: { +export function ResetCredits(props: { readonly environmentId: EnvironmentId; readonly instanceId: ProviderInstanceId; readonly credits: ServerProviderResetCredits; readonly now: number; + /** A smaller pill for the composer card. */ + readonly dense?: boolean; }) { - const { environmentId, instanceId, credits, now } = props; + const { environmentId, instanceId, credits, now, dense = false } = props; const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false, }); @@ -217,10 +229,20 @@ function ResetCredits(props: { accessibilityState={{ disabled: busy }} disabled={busy} onPress={confirm} - className="rounded-full bg-subtle-strong px-3 py-1.5" + className={ + dense + ? "rounded-full bg-subtle-strong px-2.5 py-1" + : "rounded-full bg-subtle-strong px-3 py-1.5" + } > - - {busy ? "Using credit…" : "Use a reset credit"} + + {busy ? "Using…" : "Use reset"} ) : null} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index bebde7eded76..eac54bdf1f3a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -750,7 +750,7 @@ const buildAppUnderTest = (options?: { }), Layer.mock(UsageLimitSources.UsageLimitSources)({ current: Effect.succeed([]), - streamChanges: Stream.empty, + streamChanges: Stream.make([]), refresh: Effect.void, }), ), @@ -6135,58 +6135,84 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => - Effect.gen(function* () { - const nextProviders = [ - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - enabled: true, - installed: true, - version: "1.0.0", - status: "ready" as const, - auth: { status: "authenticated" as const }, - checkedAt: "2026-04-11T00:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - }, - ] as const; - - yield* buildAppUnderTest({ - layers: { - keybindings: { - loadConfigState: Effect.succeed({ - keybindings: [], - issues: [], - }), - streamChanges: Stream.empty, + it.effect.each([false, true])( + "routes websocket rpc subscribeServerConfig emits provider status updates (limits: %s)", + (hasLimits) => + Effect.gen(function* () { + const nextProviders = [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...(hasLimits + ? { + usageLimits: { + checkedAt: "2026-04-11T00:00:00.000Z", + windows: [ + { id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }, + ], + }, + } + : {}), }, - providerRegistry: { - getProviders: Effect.succeed([]), - streamChanges: Stream.succeed(nextProviders), + ] as const; + + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + streamChanges: Stream.empty, + }, + providerRegistry: { + getProviders: Effect.succeed([]), + streamChanges: Stream.succeed(nextProviders), + }, }, - }, - }); + }); - const wsUrl = yield* getWsServerUrl("/ws"); - const events = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), - ), - ); + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), + ), + ); - const [first, second] = Array.from(events); - assert.equal(first?.type, "snapshot"); - if (first?.type === "snapshot") { - assert.deepEqual(first.config.providers, []); - } - assert.deepEqual(second, { - version: 1, - type: "providerStatuses", - payload: { providers: nextProviders }, - }); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.providers, []); + } + assert.deepEqual(second, { + version: 1, + type: "providerStatuses", + payload: { + providers: hasLimits + ? [ + { + ...nextProviders[0], + slashCommands: [ + { + name: "usage-limits", + description: "Show this provider's usage limits", + }, + ], + }, + ] + : nextProviders, + }, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); it.effect( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5ecdd341c952..ddd98d9722a9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,4 @@ +import { withUsageLimitsCommands } from "@t3tools/shared/usageLimits"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -1217,7 +1218,10 @@ const makeWsRpcLayer = ( const loadServerConfig = Effect.gen(function* () { const keybindingsConfig = yield* keybindings.loadConfigState; - const providers = yield* providerRegistry.getProviders; + const providers = withUsageLimitsCommands( + yield* providerRegistry.getProviders, + yield* usageLimitSources.current, + ); const settings = ServerSettings.redactServerSettingsForClient( yield* serverSettings.getSettings, ); @@ -2671,7 +2675,27 @@ const makeWsRpcLayer = ( }, })), ); - const providerStatuses = providerRegistry.streamChanges.pipe( + const providerStatuses = Stream.zipLatestWith( + providerRegistry.streamChanges, + usageLimitSources.streamChanges.pipe( + // Quota updates already have their own stream. Republish the model + // catalog only when the set of source-backed providers changes. + Stream.changesWith((previous, next) => { + const drivers = (sources: typeof previous) => + new Set( + sources.flatMap((source) => + source.accounts.map((account) => account.driver), + ), + ); + const before = drivers(previous); + const after = drivers(next); + return ( + before.size === after.size && [...before].every((driver) => after.has(driver)) + ); + }), + ), + withUsageLimitsCommands, + ).pipe( Stream.map((providers) => ({ version: 1 as const, type: "providerStatuses" as const, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4ceba195e98b..8fc06bcc6b48 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,6 @@ +import type { UsageLimitsReport } from "@t3tools/contracts"; +import { collectProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; +import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; import { type AssistantCitation, type ApprovalRequestId, @@ -2512,6 +2515,25 @@ export default function ChatView(props: ChatViewProps) { ); const selectedProvider = selectedProviderEntry?.driverKind ?? requestedDriverKind; const activeProviderInstanceId = selectedProviderEntry?.instanceId ?? null; + // A /usage-limits snapshot for this thread and model; sending or switching clears it. + const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ + readonly key: string; + readonly report: UsageLimitsReport; + } | null>(null); + const usageLimitsKey = `${routeThreadKey}:${activeProviderInstanceId ?? ""}`; + const usageLimitsBanner = useMemo( + () => + usageLimitsPanel?.key === usageLimitsKey + ? // A fresh id per snapshot: the stack keeps the last dismissed id as "exiting". + usageLimitsBannerItem( + `usage-limits:${usageLimitsKey}:${usageLimitsPanel.report.createdAt}`, + usageLimitsPanel.report, + environmentId, + () => setUsageLimitsPanel(null), + ) + : null, + [environmentId, usageLimitsKey, usageLimitsPanel], + ); const activeProviderStatus = selectedProviderEntry?.snapshot ?? null; const { enabled: interactionModeEnabled, interactionMode } = resolveComposerInteractionMode({ planModeEnabled: settings.planModeEnabled, @@ -5598,8 +5620,11 @@ export default function ChatView(props: ChatViewProps) { resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; + // The user asked for this one, so it leads the notice tier instead of trailing it. + const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ + ...usageLimitsItems, ...systemComposerBannerItems, ...backgroundLivenessItems, ...resumeCompactionItems, @@ -5608,6 +5633,7 @@ export default function ChatView(props: ChatViewProps) { ]; } return [ + ...usageLimitsItems, ...systemComposerBannerItems, ...backgroundLivenessItems, ...resumeCompactionItems, @@ -5662,6 +5688,7 @@ export default function ChatView(props: ChatViewProps) { resumeCompactionBannerItem, showBranchMismatchBanner, systemComposerBannerItems, + usageLimitsBanner, wokeThreadBannerItem, ]); useEffect(() => { @@ -6075,6 +6102,28 @@ export default function ChatView(props: ChatViewProps) { }, ) => { e?.preventDefault(); + // Answered locally from the last Limits snapshot; the agent never sees it. + if (!directAnnotation && isUsageLimitsCommand(promptRef.current)) { + const report = activeProviderInstanceId + ? collectProviderUsageLimits( + activeProviderInstanceId, + providerStatuses, + serverConfig?.usageLimitSources ?? [], + Date.now(), + ) + : null; + if (report) { + setUsageLimitsPanel({ key: usageLimitsKey, report }); + promptRef.current = ""; + setComposerDraftPrompt(composerDraftTarget, ""); + composerRef.current?.resetCursorState(); + } else { + toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); + } + return; + } + setUsageLimitsPanel(null); + const notifyDirectAnnotationAttached = () => { if (!directAnnotation) return; toastManager.add( diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx new file mode 100644 index 000000000000..e5fd46d59e1c --- /dev/null +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -0,0 +1,90 @@ +import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; +import { limitsNotice, providerLimitsLabel } from "@t3tools/shared/usageLimits"; +import { GaugeIcon } from "lucide-react"; + +import { getDriverOption } from "../settings/providerDriverMeta"; +import { LimitWindows, ResetCredits } from "../usage/UsageLimits"; +import { ComposerBanner } from "./ComposerBanner"; +import type { ComposerBannerStackItem } from "./ComposerBannerStack"; + +function accountLabel(account: UsageLimitsReport["accounts"][number]): string { + return account.instanceId + ? providerLimitsLabel(account, (driver) => getDriverOption(driver)?.label) + : account.label; +} + +/** The /usage-limits result as a composer notice: it stacks under warnings and dismisses like one. */ +export function usageLimitsBannerItem( + id: string, + report: UsageLimitsReport, + environmentId: EnvironmentId, + onDismiss: () => void, +): ComposerBannerStackItem { + const [first] = report.accounts; + const single = report.accounts.length === 1 && first ? first : null; + const summary = single + ? [accountLabel(single), single.plan].filter(Boolean).join(" · ") + : `${report.accounts.length} accounts`; + return { + id, + variant: "info", + priority: "notice", + icon: , + title: "Usage limits", + description: summary, + dismissLabel: "Dismiss usage limits", + onDismiss, + children: , + }; +} + +function UsageLimitsBannerBody({ + report, + environmentId, +}: { + readonly report: UsageLimitsReport; + readonly environmentId: EnvironmentId; +}) { + const now = Date.parse(report.createdAt); + return ( + + + {report.accounts.map((account) => { + const notice = limitsNotice(account.limits); + return ( +
+ {report.accounts.length > 1 ? ( + + {[accountLabel(account), account.plan].filter(Boolean).join(" · ")} + + ) : null} + {notice ? ( + {notice} + ) : ( + + )} + {account.instanceId && account.limits.resetCredits ? ( + + ) : null} +
+ ); + })} + {report.notices.map((notice) => ( + + {notice} + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 77f85953984c..5584cfc169f5 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -153,24 +153,31 @@ function WindowBar({ ); } -/** One account's windows as rows: label and percent, bar, pace and countdown. */ -function LimitWindows({ +/** + * One account's windows as rows: label and percent, bar, pace and countdown. + * Compact rows fit the composer panel with narrower columns. + */ +export function LimitWindows({ driver, windows, now, + compact = false, }: { readonly driver: ServerProvider["driver"]; readonly windows: ReadonlyArray; readonly now: number; + readonly compact?: boolean; }) { const color = barColor(driver); return ( -
- {windows.map((window, index) => { - // Windows that reset together show the countdown once. - const previous = windows[index - 1]; - const sharesReset = - previous?.resetsAt !== undefined && previous.resetsAt === window.resetsAt; +
+ {windows.map((window) => { const pace = paceOf(window, now); const resetsIn = formatResetsIn(window, now); return ( @@ -182,9 +189,9 @@ function LimitWindows({ - + {pace ? : null} - {sharesReset ? "" : (resetsIn ?? "")} + {resetsIn ?? ""} ); @@ -292,7 +299,7 @@ const OUTCOME_TEXT: Record = { * Banked reset credits with a confirmed redeem action. Redeeming spends a * credit the provider granted the user, so it never fires on a bare click. */ -function ResetCredits({ +export function ResetCredits({ environmentId, instanceId, credits, @@ -341,7 +348,7 @@ function ResetCredits({ {summary} {credits.availableCount > 0 ? ( ) : null} {status ? {status} : null} diff --git a/docs/user/usage.md b/docs/user/usage.md index a0231856ef69..07b2043f7040 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -45,6 +45,11 @@ next reset. If a window looks stale, refresh Limits to re-check every provider and hub. +Send `/usage-limits` in a thread to check the current model's limits without leaving the +conversation. The result opens above the composer and closes when you dismiss it or send your next +message. It uses the same snapshot as **Usage → Limits**, so it does not run the agent or refresh +anything. The command is offered only for providers that appear under **Usage → Limits**. + API-key accounts may not report subscription limits. This also applies to Claude connections using a proxy through `ANTHROPIC_AUTH_TOKEN`. diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 0478b113d61d..554e68c1b200 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -121,3 +121,24 @@ export const ProviderConsumeResetCreditResult = Schema.Struct({ outcome: ProviderConsumeResetCreditOutcome, }); export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type; + +/** A point-in-time view of one provider's limits, built for the /usage-limits panel. */ +export const UsageLimitsReport = Schema.Struct({ + createdAt: IsoDateTime, + accounts: Schema.Array( + Schema.Struct({ + id: TrimmedNonEmptyString, + driver: ProviderDriverKind, + label: TrimmedNonEmptyString, + plan: Schema.optional(TrimmedNonEmptyString), + email: Schema.optional(TrimmedNonEmptyString), + sourceLabel: Schema.optional(TrimmedNonEmptyString), + instanceId: Schema.optional(ProviderInstanceId), + displayName: Schema.optional(Schema.String), + accentColor: Schema.optional(Schema.String), + limits: ServerProviderUsageLimits, + }), + ), + notices: Schema.Array(Schema.String), +}); +export type UsageLimitsReport = typeof UsageLimitsReport.Type; diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 83ac6906c61e..81ac6ae2485f 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -9,6 +9,9 @@ import { import { describe, expect, it } from "vite-plus/test"; import { + isUsageLimitsCommand, + collectProviderUsageLimits, + withUsageLimitsCommands, collectLimitSources, collectLimitsGroups, elapsedShare, @@ -304,3 +307,130 @@ describe("collectLimitSources", () => { ]); }); }); + +describe("/usage-limits", () => { + const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; + const selected = provider({ + usageLimits: limits, + auth: { status: "authenticated", email: "same@example.com" }, + }); + const sources = [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Accounts", + checkedAt: limits.checkedAt, + accounts: [ + { + id: "duplicate", + driver: selected.driver, + email: "SAME@example.com", + usageLimits: limits, + }, + { id: "oss", driver: selected.driver, plan: "Codex OSS", usageLimits: limits }, + { id: "other-provider", driver: ProviderDriverKind.make("claude"), usageLimits: limits }, + ], + }, + ]; + + it("keeps accounts and custom instances separate, filtering by driver", () => { + const report = collectProviderUsageLimits( + selected.instanceId, + [ + selected, + provider({ + instanceId: ProviderInstanceId.make("codex-work"), + displayName: "Work", + usageLimits: { ...limits, resetCredits: { availableCount: 2 } }, + }), + provider({ + driver: ProviderDriverKind.make("claude"), + instanceId: ProviderInstanceId.make("claude"), + usageLimits: limits, + }), + ], + sources, + now, + ); + expect(report?.createdAt).toBe("2026-09-03T12:00:00.000Z"); + expect(report?.accounts.map((account) => account.id)).toEqual([ + "codex", + "codex-work", + "hub:oss", + ]); + expect(report?.accounts[0]).toMatchObject({ + instanceId: selected.instanceId, + email: selected.auth.email, + }); + expect(report?.accounts[1]).toMatchObject({ + displayName: "Work", + limits: { resetCredits: { availableCount: 2 } }, + }); + expect(report?.accounts[2]).toMatchObject({ + label: "Accounts · oss", + sourceLabel: "CLI Proxy", + plan: "Codex OSS", + }); + expect(report?.notices).toEqual([]); + }); + + it("supports a source-only provider and keeps duplicates when the native probe failed", () => { + expect( + collectProviderUsageLimits(selected.instanceId, [provider({})], sources, now)?.accounts.map( + (account) => account.id, + ), + ).toEqual(["hub:duplicate", "hub:oss"]); + const failed = provider({ usageLimits: { ...limits, unavailable: { reason: "probeFailed" } } }); + expect( + collectProviderUsageLimits(selected.instanceId, [failed], sources, now)?.accounts.map( + (account) => account.id, + ), + ).toEqual(["codex", "hub:duplicate", "hub:oss"]); + expect(collectProviderUsageLimits(selected.instanceId, [provider({})], [], now)).toBeNull(); + expect( + collectProviderUsageLimits( + selected.instanceId, + [provider({ enabled: false, usageLimits: limits })], + [], + now, + ), + ).toBeNull(); + }); + + it("surfaces source errors only for sources that carry the selected driver", () => { + const failing = { ...sources[0]!, error: "token expired" }; + expect( + collectProviderUsageLimits(selected.instanceId, [selected], [failing], now)?.notices, + ).toEqual(["Accounts: token expired"]); + const claudeOnly = { ...failing, accounts: failing.accounts.slice(2) }; + expect( + collectProviderUsageLimits(selected.instanceId, [selected], [claudeOnly], now)?.notices, + ).toEqual([]); + }); + + it("advertises global and workspace commands only for providers present in Limits", () => { + const withWorkspace = provider({ + workspaceSnapshots: [ + { cwd: "/tmp/project", checkedAt: limits.checkedAt, slashCommands: [], skills: [] }, + ], + }); + const [supported] = withUsageLimitsCommands([withWorkspace], sources); + expect(supported?.slashCommands.map((command) => command.name)).toEqual(["usage-limits"]); + expect( + supported?.workspaceSnapshots?.[0]?.slashCommands.map((command) => command.name), + ).toEqual(["usage-limits"]); + expect(withUsageLimitsCommands([withWorkspace], [])[0]?.slashCommands).toEqual([]); + expect( + withUsageLimitsCommands([selected], [])[0]?.slashCommands.map((command) => command.name), + ).toEqual(["usage-limits"]); + }); +}); + +describe("isUsageLimitsCommand", () => { + it("recognizes only the standalone local action", () => { + expect(isUsageLimitsCommand(" /USAGE-LIMITS\n")).toBe(true); + expect(isUsageLimitsCommand("/usage-limits explain")).toBe(false); + expect(isUsageLimitsCommand("Explain /usage-limits")).toBe(false); + expect(isUsageLimitsCommand("/usage")).toBe(false); + }); +}); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 8341796e39c3..ff1483c2c30f 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -7,6 +7,9 @@ */ import { type EnvironmentId, + type UsageLimitsReport, + type ProviderInstanceId, + type ServerProviderSlashCommand, isProviderAvailable, type ServerProvider, type ServerProviderUsageLimits, @@ -15,6 +18,8 @@ import { type UsageLimitSourceSnapshots, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; @@ -144,7 +149,7 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined) /** The instance's configured name, else the driver's, else its raw kind. */ export function providerLimitsLabel( - provider: ServerProvider, + provider: Pick, driverLabel: (driver: ServerProvider["driver"]) => string | undefined, ): string { return provider.displayName?.trim() || driverLabel(provider.driver) || String(provider.driver); @@ -209,3 +214,107 @@ export function formatResetsIn(window: ServerProviderUsageWindow, now: number): if (resetsAt === null) return null; return resetsAt <= now ? "resets now" : `resets in ${formatDuration(resetsAt - now)}`; } + +/** Limit commands are served by T3 from the same snapshots as Usage → Limits. */ +export const USAGE_LIMITS_COMMAND = { + name: "usage-limits", + description: "Show this provider's usage limits", +} satisfies ServerProviderSlashCommand; + +/** Handled by the client without sending a turn; anything with arguments stays an ordinary prompt. */ +export function isUsageLimitsCommand(prompt: string): boolean { + return prompt.trim().toLowerCase() === "/usage-limits"; +} + +export function hasProviderUsageLimits( + driver: ServerProvider["driver"], + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, +): boolean { + return ( + providersWithLimits(providers).some((provider) => provider.driver === driver) || + sources.some((source) => source.accounts.some((account) => account.driver === driver)) + ); +} + +/** Advertise on workspace catalogs too, which replace the global command list. */ +export function withUsageLimitsCommands( + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, +): ServerProvider[] { + return providers.map((provider) => { + if (!hasProviderUsageLimits(provider.driver, providers, sources)) return provider; + const commands = (items: readonly ServerProviderSlashCommand[]) => [ + ...items.filter((command) => command.name !== USAGE_LIMITS_COMMAND.name), + USAGE_LIMITS_COMMAND, + ]; + return { + ...provider, + slashCommands: commands(provider.slashCommands), + ...(provider.workspaceSnapshots + ? { + workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ + ...snapshot, + slashCommands: commands(snapshot.slashCommands), + })), + } + : {}), + }; + }); +} + +/** A point-in-time report; never refreshes or guesses which pooled account serves a turn. */ +export function collectProviderUsageLimits( + instanceId: ProviderInstanceId, + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, + now: number, +): UsageLimitsReport | null { + const selected = providers.find((provider) => provider.instanceId === instanceId); + if (!selected || !hasProviderUsageLimits(selected.driver, providers, sources)) return null; + const native = providersWithLimits(providers).filter( + (provider) => provider.driver === selected.driver, + ); + const nativeAccounts = new Set( + native.flatMap((provider) => { + const key = accountKey(provider.driver, provider.auth.email); + return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable + ? [key] + : []; + }), + ); + const accounts: Array = []; + const notices: string[] = []; + for (const provider of native) { + if (!provider.usageLimits) continue; + accounts.push({ + id: provider.instanceId, + driver: provider.driver, + label: `${providerLimitsLabel(provider, () => undefined)} [${provider.instanceId}]`, + ...(provider.auth.label ? { plan: provider.auth.label } : {}), + instanceId: provider.instanceId, + ...(provider.displayName ? { displayName: provider.displayName } : {}), + ...(provider.accentColor ? { accentColor: provider.accentColor } : {}), + ...(provider.auth.email ? { email: provider.auth.email } : {}), + limits: provider.usageLimits, + }); + } + for (const source of sources) { + const matching = source.accounts.filter((account) => account.driver === selected.driver); + for (const account of matching) { + const key = accountKey(account.driver, account.email); + if (key && nativeAccounts.has(key)) continue; + accounts.push({ + id: `${source.id}:${account.id}`, + driver: account.driver, + label: `${source.label} · ${account.id}`, + sourceLabel: "CLI Proxy", + ...(account.plan ? { plan: account.plan } : {}), + ...(account.email ? { email: account.email } : {}), + limits: account.usageLimits, + }); + } + if (matching.length > 0 && source.error) notices.push(`${source.label}: ${source.error}`); + } + return { createdAt: DateTime.formatIso(DateTime.makeUnsafe(now)), accounts, notices }; +} From 7f5b75a881b7e562c1c018eea89e2d09bcf8d2ae Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 04:17:28 +0100 Subject: [PATCH 02/18] fix: clear stale usage limits and surface failed sources Review follow-ups for /usage-limits: drop the snapshot when the thread or model changes, keep it hidden rather than dropped while the provider list is unavailable, clear it only when a message actually leaves, clear it when a later request has nothing to show, let submissions carrying attachments or contexts send as prompts, and report a usage-limit source that failed to read even though it has no accounts left to match. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- .../src/features/threads/ThreadComposer.tsx | 10 ++-- .../features/threads/ThreadDetailScreen.tsx | 11 +++- apps/web/src/components/ChatView.tsx | 53 ++++++++++++++----- packages/shared/src/usageLimits.test.ts | 5 ++ packages/shared/src/usageLimits.ts | 6 ++- 5 files changed, 65 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 1af078cc1c46..fc0e37f28e1f 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -126,8 +126,8 @@ export interface ThreadComposerProps { readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; - /** `/usage-limits` resolves locally; the host decides where the report shows. */ - readonly onShowUsageLimits: (report: UsageLimitsReport) => void; + /** `/usage-limits` resolves locally; the host decides where the report shows. Null clears it. */ + readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; @@ -436,16 +436,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const handleSend = useCallback(async () => { // Answered locally from the last Limits snapshot; the agent never sees it. - if (isUsageLimitsCommand(props.draftMessage)) { + // Attachments mean the user is sending a prompt, so those go through as usual. + if (isUsageLimitsCommand(props.draftMessage) && props.draftAttachments.length === 0) { const report = collectProviderUsageLimits( currentModelSelection.instanceId, props.serverConfig?.providers ?? [], props.serverConfig?.usageLimitSources ?? [], Date.now(), ); + onShowUsageLimits(report); if (report) { onChangeDraftMessage(""); - onShowUsageLimits(report); } else { Alert.alert("Usage limits unavailable", "This provider does not currently report limits."); } @@ -474,6 +475,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } }, [ props.draftMessage, + props.draftAttachments.length, props.serverConfig, onChangeDraftMessage, onShowUsageLimits, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e0dab3fc53c6..f3f24c88e80e 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -367,10 +367,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread readonly report: UsageLimitsReport; } | null>(null); const usageLimitsKey = `${selectedThreadKey}:${props.selectedThread.modelSelection.instanceId}`; + // Drop the snapshot as soon as the thread or model changes so it cannot resurface stale. + if (usageLimitsPanel !== null && usageLimitsPanel.key !== usageLimitsKey) { + setUsageLimitsPanel(null); + } const usageLimitsReport = usageLimitsPanel?.key === usageLimitsKey ? usageLimitsPanel.report : null; const showUsageLimits = useCallback( - (report: UsageLimitsReport) => setUsageLimitsPanel({ key: usageLimitsKey, report }), + (report: UsageLimitsReport | null) => + setUsageLimitsPanel(report === null ? null : { key: usageLimitsKey, report }), [usageLimitsKey], ); const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []); @@ -629,7 +634,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ]); const handleSendMessage = useCallback(async () => { - setUsageLimitsPanel(null); const targetThreadKey = selectedThreadKey; const hasUserMessage = selectedThreadFeed.some( (entry) => entry.type === "message" && entry.message.role === "user", @@ -639,6 +643,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return messageId; } + // A sent message makes the snapshot stale; a refused send leaves it in place. + setUsageLimitsPanel(null); + setSubmittedMessageId(messageId); setAnchorMessageId( resolveThreadFeedSubmissionAnchor({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8fc06bcc6b48..1e20c89f8270 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1506,6 +1506,11 @@ export default function ChatView(props: ChatViewProps) { const draft = store.getComposerDraft(composerDraftTarget); return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; }); + // Anything beyond the prompt text: attachments, terminal or element contexts, annotations. + const composerHasNonPromptContent = useComposerDraftStore((store) => { + const draft = store.getComposerDraft(composerDraftTarget); + return draft ? composerDraftHasUserContent({ ...draft, prompt: "" }) : false; + }); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); @@ -2520,10 +2525,23 @@ export default function ChatView(props: ChatViewProps) { readonly key: string; readonly report: UsageLimitsReport; } | null>(null); - const usageLimitsKey = `${routeThreadKey}:${activeProviderInstanceId ?? ""}`; + // Null while the provider list is unavailable, such as during a reconnect; the + // snapshot then stays hidden rather than being dropped for a transient blip. + const usageLimitsKey = + activeProviderInstanceId === null ? null : `${routeThreadKey}:${activeProviderInstanceId}`; + // Drop the snapshot as soon as the thread or model changes so it cannot resurface stale. + if ( + usageLimitsPanel !== null && + usageLimitsKey !== null && + usageLimitsPanel.key !== usageLimitsKey + ) { + setUsageLimitsPanel(null); + } const usageLimitsBanner = useMemo( () => - usageLimitsPanel?.key === usageLimitsKey + usageLimitsPanel !== null && + usageLimitsKey !== null && + usageLimitsPanel.key === usageLimitsKey ? // A fresh id per snapshot: the stack keeps the last dismissed id as "exiting". usageLimitsBannerItem( `usage-limits:${usageLimitsKey}:${usageLimitsPanel.report.createdAt}`, @@ -6103,26 +6121,32 @@ export default function ChatView(props: ChatViewProps) { ) => { e?.preventDefault(); // Answered locally from the last Limits snapshot; the agent never sees it. - if (!directAnnotation && isUsageLimitsCommand(promptRef.current)) { - const report = activeProviderInstanceId - ? collectProviderUsageLimits( - activeProviderInstanceId, - providerStatuses, - serverConfig?.usageLimitSources ?? [], - Date.now(), - ) - : null; - if (report) { + // Attachments or contexts mean the user is sending a prompt, so those go through as usual. + if ( + !directAnnotation && + !composerHasNonPromptContent && + isUsageLimitsCommand(promptRef.current) + ) { + const report = + activeProviderInstanceId !== null && usageLimitsKey !== null + ? collectProviderUsageLimits( + activeProviderInstanceId, + providerStatuses, + serverConfig?.usageLimitSources ?? [], + Date.now(), + ) + : null; + if (report && usageLimitsKey !== null) { setUsageLimitsPanel({ key: usageLimitsKey, report }); promptRef.current = ""; setComposerDraftPrompt(composerDraftTarget, ""); composerRef.current?.resetCursorState(); } else { + setUsageLimitsPanel(null); toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); } return; } - setUsageLimitsPanel(null); const notifyDirectAnnotationAttached = () => { if (!directAnnotation) return; @@ -6600,6 +6624,8 @@ export default function ChatView(props: ChatViewProps) { } else { scrollToEnd(); } + // The message is leaving, so the limits snapshot is now stale. + setUsageLimitsPanel(null); setOptimisticUserMessages((existing) => [ ...existing, { @@ -7123,6 +7149,7 @@ export default function ChatView(props: ChatViewProps) { scrollToEnd(); + setUsageLimitsPanel(null); setOptimisticUserMessages((existing) => [ ...existing, { diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 81ac6ae2485f..0eaf03ee4cd0 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -406,6 +406,11 @@ describe("/usage-limits", () => { expect( collectProviderUsageLimits(selected.instanceId, [selected], [claudeOnly], now)?.notices, ).toEqual([]); + // A read failure clears the accounts, so the error must not depend on a match. + const unreadable = { ...failing, accounts: [] }; + expect( + collectProviderUsageLimits(selected.instanceId, [selected], [unreadable], now)?.notices, + ).toEqual(["Accounts: token expired"]); }); it("advertises global and workspace commands only for providers present in Limits", () => { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index ff1483c2c30f..648cf68cfac4 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -314,7 +314,11 @@ export function collectProviderUsageLimits( limits: account.usageLimits, }); } - if (matching.length > 0 && source.error) notices.push(`${source.label}: ${source.error}`); + // A source that failed to read has no accounts left to match on, so its + // error is reported to every provider rather than silently dropped. + if (source.error && (matching.length > 0 || source.accounts.length === 0)) { + notices.push(`${source.label}: ${source.error}`); + } } return { createdAt: DateTime.formatIso(DateTime.makeUnsafe(now)), accounts, notices }; } From 8183ada9e0607c4e805fa1dbedcb06a55e6f1c73 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 05:06:15 +0100 Subject: [PATCH 03/18] fix: republish limit commands on source refresh and drop stale panels Seed the provider status stream with the current providers so a usage limit source refresh reaches clients before any provider change, with a test. Tie the /usage-limits snapshot to the turn and clear it once an approval or question is answered, since the agent then spends quota. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- .../features/threads/ThreadDetailScreen.tsx | 26 ++++-- apps/server/src/server.test.ts | 87 +++++++++++++++++++ apps/server/src/ws.ts | 11 ++- apps/web/src/components/ChatView.tsx | 13 ++- 4 files changed, 129 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index f3f24c88e80e..89e8fdb63bda 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -361,13 +361,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = useState(null); const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; - // A /usage-limits snapshot for this thread and model; sending or switching clears it. + // A /usage-limits snapshot for this thread, model and turn. Anything that spends + // quota afterwards makes it stale: a new turn from any source, or the agent + // resuming after an approval or answered question. const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ readonly key: string; readonly report: UsageLimitsReport; } | null>(null); - const usageLimitsKey = `${selectedThreadKey}:${props.selectedThread.modelSelection.instanceId}`; - // Drop the snapshot as soon as the thread or model changes so it cannot resurface stale. + const usageLimitsKey = `${selectedThreadKey}:${props.selectedThread.modelSelection.instanceId}:${props.selectedThread.latestTurn?.turnId ?? ""}`; + // Drop the snapshot as soon as the key changes so it cannot resurface stale. if (usageLimitsPanel !== null && usageLimitsPanel.key !== usageLimitsKey) { setUsageLimitsPanel(null); } @@ -379,6 +381,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread [usageLimitsKey], ); const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []); + const { onRespondToApproval, onSubmitUserInput } = props; + const handleRespondToApproval = useCallback( + async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { + const result = await onRespondToApproval(requestId, decision); + setUsageLimitsPanel(null); + return result; + }, + [onRespondToApproval], + ); + const handleSubmitUserInput = useCallback(async () => { + const result = await onSubmitUserInput(); + setUsageLimitsPanel(null); + return result; + }, [onSubmitUserInput]); const userInputCollapsed = activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; // The card's height RESERVES keyboard space at all times instead of @@ -831,7 +847,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) : null} {props.activePendingUserInput ? ( @@ -849,7 +865,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread respondingUserInputId={props.respondingUserInputId} onSelectOption={props.onSelectUserInputOption} onChangeCustomAnswer={props.onChangeUserInputCustomAnswer} - onSubmit={props.onSubmitUserInput} + onSubmit={handleSubmitUserInput} /> ) : null} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index eac54bdf1f3a..dcc70063c270 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -38,6 +38,7 @@ import { ResolvedKeybindingRule, ThreadId, TurnId, + UsageLimitSourceId, WS_METHODS, WsRpcGroup, EditorId, @@ -495,6 +496,7 @@ const buildAppUnderTest = (options?: { keybindings?: Partial; environmentTheme?: Partial; providerRegistry?: Partial; + usageLimitSources?: Partial; providerService?: Partial; providerAuth?: Partial; providerInstanceRegistry?: Partial; @@ -752,6 +754,7 @@ const buildAppUnderTest = (options?: { current: Effect.succeed([]), streamChanges: Stream.make([]), refresh: Effect.void, + ...options?.layers?.usageLimitSources, }), ), ), @@ -6215,6 +6218,90 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "routes websocket rpc subscribeServerConfig republishes commands when only a limits source changes", + () => + Effect.gen(function* () { + const codex = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; + const hub = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Accounts", + checkedAt: "2026-04-11T00:00:00.000Z", + accounts: [ + { + id: "work", + driver: ProviderDriverKind.make("codex"), + usageLimits: { + checkedAt: "2026-04-11T00:00:00.000Z", + windows: [ + { id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }, + ], + }, + }, + ], + }; + + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.succeed({ keybindings: [], issues: [] }), + streamChanges: Stream.empty, + }, + // The registry emits no change: only the source refresh can carry it. + providerRegistry: { + getProviders: Effect.succeed([codex]), + streamChanges: Stream.empty, + }, + usageLimitSources: { + current: Effect.succeed([]), + // Replay the empty snapshot, then a later refresh, as the live stream does. + streamChanges: Stream.concat(Stream.make([]), Stream.make([hub])), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.providers, [codex]); + } + assert.deepEqual(second, { + version: 1, + type: "providerStatuses", + payload: { + providers: [ + { + ...codex, + slashCommands: [ + { name: "usage-limits", description: "Show this provider's usage limits" }, + ], + }, + ], + }, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect( "routes websocket rpc subscribeServerLifecycle replays snapshot and streams updates", () => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ddd98d9722a9..9248a2324dea 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2676,7 +2676,13 @@ const makeWsRpcLayer = ( })), ); const providerStatuses = Stream.zipLatestWith( - providerRegistry.streamChanges, + // The registry stream carries changes only. Seed it with the current + // providers so a source refresh that lands before any provider change + // still pairs up and reaches the client. + Stream.concat( + Stream.fromEffect(providerRegistry.getProviders), + providerRegistry.streamChanges, + ), usageLimitSources.streamChanges.pipe( // Quota updates already have their own stream. Republish the model // catalog only when the set of source-backed providers changes. @@ -2696,6 +2702,9 @@ const makeWsRpcLayer = ( ), withUsageLimitsCommands, ).pipe( + // Both sides replay their current value, so the first pairing repeats + // the snapshot the client already holds. + Stream.drop(1), Stream.map((providers) => ({ version: 1 as const, type: "providerStatuses" as const, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1e20c89f8270..42a5060e3146 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2520,7 +2520,9 @@ export default function ChatView(props: ChatViewProps) { ); const selectedProvider = selectedProviderEntry?.driverKind ?? requestedDriverKind; const activeProviderInstanceId = selectedProviderEntry?.instanceId ?? null; - // A /usage-limits snapshot for this thread and model; sending or switching clears it. + // A /usage-limits snapshot for this thread, model and turn. Anything that spends + // quota afterwards makes it stale: a new turn from any source, or the agent + // resuming after an approval or answered question. const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ readonly key: string; readonly report: UsageLimitsReport; @@ -2528,7 +2530,9 @@ export default function ChatView(props: ChatViewProps) { // Null while the provider list is unavailable, such as during a reconnect; the // snapshot then stays hidden rather than being dropped for a transient blip. const usageLimitsKey = - activeProviderInstanceId === null ? null : `${routeThreadKey}:${activeProviderInstanceId}`; + activeProviderInstanceId === null + ? null + : `${routeThreadKey}:${activeProviderInstanceId}:${activeThread?.latestTurn?.turnId ?? ""}`; // Drop the snapshot as soon as the thread or model changes so it cannot resurface stale. if ( usageLimitsPanel !== null && @@ -6952,6 +6956,9 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, error instanceof Error ? error.message : "Failed to submit approval decision.", ); + } else { + // The agent resumes and spends quota, so any limits snapshot is stale. + setUsageLimitsPanel(null); } setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); return result; @@ -6980,6 +6987,8 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, error instanceof Error ? error.message : "Failed to submit user input.", ); + } else { + setUsageLimitsPanel(null); } setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); return result; From 50666afc34b3ac46d998089c4f4dd007d28a04a8 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 05:11:52 +0100 Subject: [PATCH 04/18] fix: keep the limits panel live and out of New Task The /usage-limits panel now reads provider data at render, so a redeemed reset credit or refreshed probe shows through instead of a frozen snapshot. Mobile New Task no longer offers the command and refuses to send it, since only the thread composer can answer it. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- .../features/threads/NewTaskDraftScreen.tsx | 9 ++++ .../features/threads/ThreadDetailScreen.tsx | 34 +++++++++--- .../threads/use-composer-command-menu.ts | 3 ++ apps/web/src/components/ChatView.tsx | 52 ++++++++++++++----- 4 files changed, 77 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index e1cc7405bde2..54230025647d 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -49,6 +49,7 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; +import { isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; import { ShimmeringWorkContent } from "./thread-work-log"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; @@ -908,6 +909,14 @@ export function NewTaskDraftScreen(props: { ); return; } + // The thread composer answers this locally; a new task would send it to the agent. + if (isUsageLimitsCommand(initialMessageText)) { + Alert.alert( + "Usage limits", + "Send /usage-limits inside a thread, or open Settings → Usage → Limits.", + ); + return; + } // A failed-send restore can leave the draft over the cap on purpose (it // never drops the user's files); starting anyway would upload everything // and have the server reject the turn. diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 89e8fdb63bda..d8112ad273ef 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -58,6 +58,7 @@ import Animated, { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerAttachment } from "../../lib/composerImages"; @@ -361,23 +362,42 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = useState(null); const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; - // A /usage-limits snapshot for this thread, model and turn. Anything that spends - // quota afterwards makes it stale: a new turn from any source, or the agent - // resuming after an approval or answered question. + // The open /usage-limits panel for this thread, model and turn. Only the open + // moment is stored: the rows read live provider data, so a redeemed reset + // credit or refreshed probe shows through. Anything that spends quota closes + // it: a new turn from any source, or the agent resuming after an approval or + // answered question. const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ readonly key: string; - readonly report: UsageLimitsReport; + readonly now: number; } | null>(null); const usageLimitsKey = `${selectedThreadKey}:${props.selectedThread.modelSelection.instanceId}:${props.selectedThread.latestTurn?.turnId ?? ""}`; // Drop the snapshot as soon as the key changes so it cannot resurface stale. if (usageLimitsPanel !== null && usageLimitsPanel.key !== usageLimitsKey) { setUsageLimitsPanel(null); } - const usageLimitsReport = - usageLimitsPanel?.key === usageLimitsKey ? usageLimitsPanel.report : null; + const usageLimitsReport = useMemo( + () => + usageLimitsPanel !== null && usageLimitsPanel.key === usageLimitsKey + ? collectProviderUsageLimits( + props.selectedThread.modelSelection.instanceId, + props.serverConfig?.providers ?? [], + props.serverConfig?.usageLimitSources ?? [], + usageLimitsPanel.now, + ) + : null, + [ + props.selectedThread.modelSelection.instanceId, + props.serverConfig, + usageLimitsKey, + usageLimitsPanel, + ], + ); const showUsageLimits = useCallback( (report: UsageLimitsReport | null) => - setUsageLimitsPanel(report === null ? null : { key: usageLimitsKey, report }), + setUsageLimitsPanel( + report === null ? null : { key: usageLimitsKey, now: Date.parse(report.createdAt) }, + ), [usageLimitsKey], ); const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 5900acada910..c23b3e9e57e4 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -1,4 +1,5 @@ import type { EnvironmentId, ProviderInteractionMode, ServerProvider } from "@t3tools/contracts"; +import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { detectComposerTrigger, replaceTextRange, @@ -78,6 +79,8 @@ export function buildComposerSlashCommandItems(input: { for (const command of input.selectedProviderStatus?.slashCommands ?? []) { if (!command.name.toLowerCase().includes(query)) continue; if (command.name === "compact" && !input.hasCompactableConversation) continue; + // Answered by the thread composer; New Task has nowhere to show it. + if (command.name === USAGE_LIMITS_COMMAND.name && !input.hasThread) continue; if ( !input.hasThread && input.selectedProviderStatus?.driver === "codex" && diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 42a5060e3146..3a54987d3ef9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,4 +1,4 @@ -import type { UsageLimitsReport } from "@t3tools/contracts"; +import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; import { collectProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; import { @@ -452,6 +452,7 @@ import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./chat/composerPromptHistory"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; +const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { @@ -2520,12 +2521,14 @@ export default function ChatView(props: ChatViewProps) { ); const selectedProvider = selectedProviderEntry?.driverKind ?? requestedDriverKind; const activeProviderInstanceId = selectedProviderEntry?.instanceId ?? null; - // A /usage-limits snapshot for this thread, model and turn. Anything that spends - // quota afterwards makes it stale: a new turn from any source, or the agent - // resuming after an approval or answered question. + // The open /usage-limits panel for this thread, model and turn. Only the open + // moment is stored: the rows read live provider data, so a redeemed reset + // credit or refreshed probe shows through. Anything that spends quota closes + // it: a new turn from any source, or the agent resuming after an approval or + // answered question. const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ readonly key: string; - readonly report: UsageLimitsReport; + readonly now: number; } | null>(null); // Null while the provider list is unavailable, such as during a reconnect; the // snapshot then stays hidden rather than being dropped for a transient blip. @@ -2541,20 +2544,40 @@ export default function ChatView(props: ChatViewProps) { ) { setUsageLimitsPanel(null); } - const usageLimitsBanner = useMemo( + const usageLimitSources = serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES; + const usageLimitsReport = useMemo( () => usageLimitsPanel !== null && usageLimitsKey !== null && - usageLimitsPanel.key === usageLimitsKey - ? // A fresh id per snapshot: the stack keeps the last dismissed id as "exiting". + usageLimitsPanel.key === usageLimitsKey && + activeProviderInstanceId !== null + ? collectProviderUsageLimits( + activeProviderInstanceId, + providerStatuses, + usageLimitSources, + usageLimitsPanel.now, + ) + : null, + [ + activeProviderInstanceId, + providerStatuses, + usageLimitSources, + usageLimitsKey, + usageLimitsPanel, + ], + ); + const usageLimitsBanner = useMemo( + () => + usageLimitsReport !== null && usageLimitsPanel !== null + ? // A fresh id per opening: the stack keeps the last dismissed id as "exiting". usageLimitsBannerItem( - `usage-limits:${usageLimitsKey}:${usageLimitsPanel.report.createdAt}`, - usageLimitsPanel.report, + `usage-limits:${usageLimitsPanel.key}:${usageLimitsPanel.now}`, + usageLimitsReport, environmentId, () => setUsageLimitsPanel(null), ) : null, - [environmentId, usageLimitsKey, usageLimitsPanel], + [environmentId, usageLimitsPanel, usageLimitsReport], ); const activeProviderStatus = selectedProviderEntry?.snapshot ?? null; const { enabled: interactionModeEnabled, interactionMode } = resolveComposerInteractionMode({ @@ -6131,17 +6154,18 @@ export default function ChatView(props: ChatViewProps) { !composerHasNonPromptContent && isUsageLimitsCommand(promptRef.current) ) { + const now = Date.now(); const report = activeProviderInstanceId !== null && usageLimitsKey !== null ? collectProviderUsageLimits( activeProviderInstanceId, providerStatuses, - serverConfig?.usageLimitSources ?? [], - Date.now(), + usageLimitSources, + now, ) : null; if (report && usageLimitsKey !== null) { - setUsageLimitsPanel({ key: usageLimitsKey, report }); + setUsageLimitsPanel({ key: usageLimitsKey, now }); promptRef.current = ""; setComposerDraftPrompt(composerDraftTarget, ""); composerRef.current?.resetCursorState(); From 3836513f7d64a9478dde54e9181526102ed02a95 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 05:17:15 +0100 Subject: [PATCH 05/18] feat: open usage limits when the command is picked Choosing /usage-limits from the composer menu opens the panel at once instead of leaving the command in the draft to be sent. Typing it out and sending still works as a fallback. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- .../src/features/threads/ThreadComposer.tsx | 39 +++++++++--------- .../threads/use-composer-command-menu.ts | 16 ++++++++ apps/web/src/components/ChatView.tsx | 41 +++++++++++-------- apps/web/src/components/chat/ChatComposer.tsx | 16 ++++++++ docs/user/usage.md | 6 +-- 5 files changed, 79 insertions(+), 39 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index fc0e37f28e1f..4c16ba71b2e2 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -340,6 +340,21 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props; + // Answered locally from the last Limits snapshot; the agent never sees it. + const openUsageLimits = useCallback(() => { + const report = collectProviderUsageLimits( + currentModelSelection.instanceId, + props.serverConfig?.providers ?? [], + props.serverConfig?.usageLimitSources ?? [], + Date.now(), + ); + onShowUsageLimits(report); + if (!report) { + Alert.alert("Usage limits unavailable", "This provider does not currently report limits."); + } + return report !== null; + }, [currentModelSelection.instanceId, onShowUsageLimits, props.serverConfig]); const composerMenu = useComposerCommandMenu({ draftMessage: props.draftMessage, @@ -354,6 +369,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer selectedProviderStatus?.showInteractionModeToggle === false ? undefined : props.onUpdateInteractionMode, + onUsageLimits: openUsageLimits, }); const voiceInput = useVoiceInputController({ ownerKey: composerOwnerKey, @@ -432,24 +448,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } onEditorFocusChange?.(false); }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]); - const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props; - const handleSend = useCallback(async () => { - // Answered locally from the last Limits snapshot; the agent never sees it. - // Attachments mean the user is sending a prompt, so those go through as usual. + // Typed out in full rather than picked from the menu. Attachments mean the + // user is sending a prompt, so those go through as usual. if (isUsageLimitsCommand(props.draftMessage) && props.draftAttachments.length === 0) { - const report = collectProviderUsageLimits( - currentModelSelection.instanceId, - props.serverConfig?.providers ?? [], - props.serverConfig?.usageLimitSources ?? [], - Date.now(), - ); - onShowUsageLimits(report); - if (report) { - onChangeDraftMessage(""); - } else { - Alert.alert("Usage limits unavailable", "This provider does not currently report limits."); - } + if (openUsageLimits()) onChangeDraftMessage(""); return; } if (voiceInput.blocksSubmission) return; @@ -476,10 +479,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }, [ props.draftMessage, props.draftAttachments.length, - props.serverConfig, onChangeDraftMessage, - onShowUsageLimits, - currentModelSelection.instanceId, + openUsageLimits, onSendMessage, props.environmentId, props.environmentLabel, diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index c23b3e9e57e4..2909995cb690 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -149,6 +149,7 @@ export function useComposerCommandMenu({ enabled = true, onChangeDraftMessage, onUpdateInteractionMode, + onUsageLimits, }: { readonly draftMessage: string; readonly ownerKey: string | null; @@ -160,6 +161,8 @@ export function useComposerCommandMenu({ readonly enabled?: boolean; readonly onChangeDraftMessage: (value: string) => void; readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; + /** Picking /usage-limits is the action itself; the draft keeps nothing of it. */ + readonly onUsageLimits?: () => void; }) { const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage)); const previousOwnerKeyRef = useRef(ownerKey); @@ -399,6 +402,18 @@ export function useComposerCommandMenu({ (item: ComposerCommandItem) => { if (!trigger) return; + if ( + item.type === "provider-slash-command" && + item.command.name === USAGE_LIMITS_COMMAND.name && + onUsageLimits + ) { + const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""); + setSelection({ start: cleared.cursor, end: cleared.cursor }); + onChangeDraftMessage(cleared.text); + onUsageLimits(); + return; + } + const result = resolveComposerCommandSelection({ draftMessage, trigger, @@ -417,6 +432,7 @@ export function useComposerCommandMenu({ draftMessage, onChangeDraftMessage, onUpdateInteractionMode, + onUsageLimits, selectedProviderStatus?.showInteractionModeToggle, trigger, ], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3a54987d3ef9..407440d1f50d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2579,6 +2579,26 @@ export default function ChatView(props: ChatViewProps) { : null, [environmentId, usageLimitsPanel, usageLimitsReport], ); + // Answered locally from the last Limits snapshot; the agent never sees it. + const openUsageLimits = useCallback(() => { + const now = Date.now(); + const report = + activeProviderInstanceId !== null && usageLimitsKey !== null + ? collectProviderUsageLimits( + activeProviderInstanceId, + providerStatuses, + usageLimitSources, + now, + ) + : null; + if (report && usageLimitsKey !== null) { + setUsageLimitsPanel({ key: usageLimitsKey, now }); + return true; + } + setUsageLimitsPanel(null); + toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); + return false; + }, [activeProviderInstanceId, providerStatuses, usageLimitSources, usageLimitsKey]); const activeProviderStatus = selectedProviderEntry?.snapshot ?? null; const { enabled: interactionModeEnabled, interactionMode } = resolveComposerInteractionMode({ planModeEnabled: settings.planModeEnabled, @@ -6147,31 +6167,17 @@ export default function ChatView(props: ChatViewProps) { }, ) => { e?.preventDefault(); - // Answered locally from the last Limits snapshot; the agent never sees it. - // Attachments or contexts mean the user is sending a prompt, so those go through as usual. + // Typed out in full rather than picked from the menu. Attachments or contexts + // mean the user is sending a prompt, so those go through as usual. if ( !directAnnotation && !composerHasNonPromptContent && isUsageLimitsCommand(promptRef.current) ) { - const now = Date.now(); - const report = - activeProviderInstanceId !== null && usageLimitsKey !== null - ? collectProviderUsageLimits( - activeProviderInstanceId, - providerStatuses, - usageLimitSources, - now, - ) - : null; - if (report && usageLimitsKey !== null) { - setUsageLimitsPanel({ key: usageLimitsKey, now }); + if (openUsageLimits()) { promptRef.current = ""; setComposerDraftPrompt(composerDraftTarget, ""); composerRef.current?.resetCursorState(); - } else { - setUsageLimitsPanel(null); - toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); } return; } @@ -8056,6 +8062,7 @@ export default function ChatView(props: ChatViewProps) { } isPreparingWorktree={isPreparingWorktree} bannerItems={composerBannerItems} + onUsageLimitsCommand={openUsageLimits} environmentUnavailable={activeEnvironmentUnavailableState} activePendingApproval={activePendingApproval} pendingApprovals={pendingApprovals} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 10898e311cef..ec4cb77a94ed 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -22,6 +22,7 @@ import { import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; +import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { Fragment, memo, @@ -1191,6 +1192,8 @@ export interface ChatComposerProps { sendDisabledReason: string | null; isPreparingWorktree: boolean; bannerItems: readonly ComposerBannerStackItem[]; + /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */ + onUsageLimitsCommand?: () => void; environmentUnavailable: { readonly label: string; readonly connection: EnvironmentConnectionPresentation; @@ -2681,6 +2684,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; }, [readComposerSnapshot]); + const { onUsageLimitsCommand } = props; const onSelectComposerItem = useCallback( (item: ComposerCommandItem) => { if (composerSelectLockRef.current) return; @@ -2731,6 +2735,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } if (item.type === "provider-slash-command") { + if (item.command.name === USAGE_LIMITS_COMMAND.name && onUsageLimitsCommand) { + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + focusEditorAfterReplace: false, + }); + if (applied) { + setComposerHighlightedItemId(null); + onUsageLimitsCommand(); + } + return; + } const replacement = `/${item.command.name} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, @@ -2771,6 +2786,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) applyPromptReplacement, handleInteractionModeChange, planModeUiEnabled, + onUsageLimitsCommand, resolveActiveComposerTrigger, ], ); diff --git a/docs/user/usage.md b/docs/user/usage.md index 07b2043f7040..dc92b3f65917 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -45,9 +45,9 @@ next reset. If a window looks stale, refresh Limits to re-check every provider and hub. -Send `/usage-limits` in a thread to check the current model's limits without leaving the -conversation. The result opens above the composer and closes when you dismiss it or send your next -message. It uses the same snapshot as **Usage → Limits**, so it does not run the agent or refresh +Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the +current model's limits without leaving the conversation. The result opens above the composer and +closes when you dismiss it or send your next message. It uses the same snapshot as **Usage → Limits**, so it does not run the agent or refresh anything. The command is offered only for providers that appear under **Usage → Limits**. API-key accounts may not report subscription limits. This also applies to Claude connections From 819ff673a946719d5865e5937fb444316c16b42e Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 05:21:23 +0100 Subject: [PATCH 06/18] fix: never drop a provider update and advertise failed sources Compare provider status pairings against the snapshot the client holds instead of dropping the first one, so a refresh between snapshot and subscription still goes out. A usage-limit source that failed to read now counts for every driver, so its error reaches the panel. Only a successful approval or answer clears the panel, and New Task lets a prompt with attachments through. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- .../src/features/threads/NewTaskDraftScreen.tsx | 3 ++- apps/server/src/ws.ts | 17 ++++++++++------- apps/web/src/components/ChatView.tsx | 4 ++-- packages/shared/src/usageLimits.test.ts | 15 +++++++++++++++ packages/shared/src/usageLimits.ts | 11 ++++++++++- 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 54230025647d..ef3745841326 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -910,7 +910,8 @@ export function NewTaskDraftScreen(props: { return; } // The thread composer answers this locally; a new task would send it to the agent. - if (isUsageLimitsCommand(initialMessageText)) { + // Attachments mean the user is sending a prompt, so those go through as usual. + if (isUsageLimitsCommand(initialMessageText) && draft.attachments.length === 0) { Alert.alert( "Usage limits", "Send /usage-limits inside a thread, or open Settings → Usage → Limits.", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9248a2324dea..c798b023d7a4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2665,6 +2665,7 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( WS_METHODS.subscribeServerConfig, Effect.gen(function* () { + const config = yield* loadServerConfig; const keybindingsUpdates = keybindings.streamChanges.pipe( Stream.map((event) => ({ version: 1 as const, @@ -2702,8 +2703,14 @@ const makeWsRpcLayer = ( ), withUsageLimitsCommands, ).pipe( - // Both sides replay their current value, so the first pairing repeats - // the snapshot the client already holds. + // Both sides replay their current value, so the first pairing normally + // repeats the snapshot the client already holds. Compare against that + // snapshot rather than dropping blindly: a refresh that landed between + // the snapshot and the subscription still goes out. + (updates) => Stream.concat(Stream.make(config.providers), updates), + Stream.changesWith( + (previous, next) => JSON.stringify(previous) === JSON.stringify(next), + ), Stream.drop(1), Stream.map((providers) => ({ version: 1 as const, @@ -2765,11 +2772,7 @@ const makeWsRpcLayer = ( ); return Stream.concat( - Stream.make({ - version: 1 as const, - type: "snapshot" as const, - config: yield* loadServerConfig, - }), + Stream.make({ version: 1 as const, type: "snapshot" as const, config }), liveUpdates, ); }), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 407440d1f50d..b70fc6384198 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6986,7 +6986,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, error instanceof Error ? error.message : "Failed to submit approval decision.", ); - } else { + } else if (result._tag === "Success") { // The agent resumes and spends quota, so any limits snapshot is stale. setUsageLimitsPanel(null); } @@ -7017,7 +7017,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, error instanceof Error ? error.message : "Failed to submit user input.", ); - } else { + } else if (result._tag === "Success") { setUsageLimitsPanel(null); } setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 0eaf03ee4cd0..701341fd6484 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -411,6 +411,15 @@ describe("/usage-limits", () => { expect( collectProviderUsageLimits(selected.instanceId, [selected], [unreadable], now)?.notices, ).toEqual(["Accounts: token expired"]); + // A source-only provider still gets the report, carrying only the error. + const sourceOnly = collectProviderUsageLimits( + selected.instanceId, + [provider({})], + [unreadable], + now, + ); + expect(sourceOnly?.accounts).toEqual([]); + expect(sourceOnly?.notices).toEqual(["Accounts: token expired"]); }); it("advertises global and workspace commands only for providers present in Limits", () => { @@ -425,6 +434,12 @@ describe("/usage-limits", () => { supported?.workspaceSnapshots?.[0]?.slashCommands.map((command) => command.name), ).toEqual(["usage-limits"]); expect(withUsageLimitsCommands([withWorkspace], [])[0]?.slashCommands).toEqual([]); + const unreadable = { ...sources[0]!, accounts: [], error: "token expired" }; + expect( + withUsageLimitsCommands([withWorkspace], [unreadable])[0]?.slashCommands.map( + (command) => command.name, + ), + ).toEqual(["usage-limits"]); expect( withUsageLimitsCommands([selected], [])[0]?.slashCommands.map((command) => command.name), ).toEqual(["usage-limits"]); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 648cf68cfac4..4a957e143da2 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -226,6 +226,11 @@ export function isUsageLimitsCommand(prompt: string): boolean { return prompt.trim().toLowerCase() === "/usage-limits"; } +/** + * Whether Limits has anything to say about this driver. A source that failed to + * read keeps no accounts, so its error counts for every driver rather than + * disappearing until the next successful refresh. + */ export function hasProviderUsageLimits( driver: ServerProvider["driver"], providers: readonly ServerProvider[], @@ -233,7 +238,11 @@ export function hasProviderUsageLimits( ): boolean { return ( providersWithLimits(providers).some((provider) => provider.driver === driver) || - sources.some((source) => source.accounts.some((account) => account.driver === driver)) + sources.some( + (source) => + source.accounts.some((account) => account.driver === driver) || + (source.error !== undefined && source.accounts.length === 0), + ) ); } From e7a71acdb2700d40f803de806c5e13c7bc58deb2 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 05:26:14 +0100 Subject: [PATCH 07/18] fix: tell sibling instances apart and scope panel clears Native accounts in the panel show their instance id when they share a driver and have no display name. A response that resolves after navigating away clears only the originating thread's panel. The catalog republish comparator now lives in shared, counts a failed source as covering every driver, and is tested. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- .../features/threads/ComposerUsageLimits.tsx | 7 +++- .../features/threads/ThreadDetailScreen.tsx | 19 +++++++-- apps/server/src/ws.ts | 21 +++------- apps/web/src/components/ChatView.tsx | 35 +++++++++++++--- .../components/chat/ComposerUsageLimits.tsx | 12 ++++-- packages/shared/src/usageLimits.test.ts | 40 +++++++++++++++++++ packages/shared/src/usageLimits.ts | 22 ++++++++++ 7 files changed, 126 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx index e49b508ff110..fb885fd637f8 100644 --- a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx +++ b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx @@ -1,5 +1,4 @@ import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; -import { providerLimitsLabel } from "@t3tools/shared/usageLimits"; import { Pressable, ScrollView, useWindowDimensions, View } from "react-native"; import { SymbolView } from "../../components/AppSymbol"; @@ -51,9 +50,13 @@ export function ComposerUsageLimits({ first={index === 0} driver={account.driver} label={driverLabel} + // A custom instance without a name still needs telling apart from its siblings. instanceLabel={ account.instanceId - ? providerLimitsLabel(account, (driver) => DRIVER_LABEL[driver]) + ? account.displayName?.trim() || + (String(account.instanceId) !== String(account.driver) + ? account.instanceId + : driverLabel) : (account.sourceLabel ?? account.label) } detail={account.plan} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d8112ad273ef..5d0bd233bc52 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -402,19 +402,30 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ); const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []); const { onRespondToApproval, onSubmitUserInput } = props; + // The response may resolve after navigating away, so only the originating + // thread's panel is cleared; a panel opened elsewhere in the meantime stays. + const clearUsageLimitsFor = useCallback( + (threadKey: string) => + setUsageLimitsPanel((current) => + current !== null && current.key.startsWith(`${threadKey}:`) ? null : current, + ), + [], + ); const handleRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { + const threadKey = selectedThreadKey; const result = await onRespondToApproval(requestId, decision); - setUsageLimitsPanel(null); + clearUsageLimitsFor(threadKey); return result; }, - [onRespondToApproval], + [clearUsageLimitsFor, onRespondToApproval, selectedThreadKey], ); const handleSubmitUserInput = useCallback(async () => { + const threadKey = selectedThreadKey; const result = await onSubmitUserInput(); - setUsageLimitsPanel(null); + clearUsageLimitsFor(threadKey); return result; - }, [onSubmitUserInput]); + }, [clearUsageLimitsFor, onSubmitUserInput, selectedThreadKey]); const userInputCollapsed = activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; // The card's height RESERVES keyboard space at all times instead of diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c798b023d7a4..5219d1b5d418 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,7 @@ -import { withUsageLimitsCommands } from "@t3tools/shared/usageLimits"; +import { + sameUsageLimitCommandCoverage, + withUsageLimitsCommands, +} from "@t3tools/shared/usageLimits"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -2686,20 +2689,8 @@ const makeWsRpcLayer = ( ), usageLimitSources.streamChanges.pipe( // Quota updates already have their own stream. Republish the model - // catalog only when the set of source-backed providers changes. - Stream.changesWith((previous, next) => { - const drivers = (sources: typeof previous) => - new Set( - sources.flatMap((source) => - source.accounts.map((account) => account.driver), - ), - ); - const before = drivers(previous); - const after = drivers(next); - return ( - before.size === after.size && [...before].every((driver) => after.has(driver)) - ); - }), + // catalog only when the set of providers offered the command changes. + Stream.changesWith(sameUsageLimitCommandCoverage), ), withUsageLimitsCommands, ).pipe( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b70fc6384198..e8ebe7def48e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2599,6 +2599,14 @@ export default function ChatView(props: ChatViewProps) { toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); return false; }, [activeProviderInstanceId, providerStatuses, usageLimitSources, usageLimitsKey]); + // Responses can resolve after navigating away; only the originating thread's panel clears. + const clearUsageLimitsFor = useCallback( + (threadKey: string) => + setUsageLimitsPanel((current) => + current !== null && current.key.startsWith(`${threadKey}:`) ? null : current, + ), + [], + ); const activeProviderStatus = selectedProviderEntry?.snapshot ?? null; const { enabled: interactionModeEnabled, interactionMode } = resolveComposerInteractionMode({ planModeEnabled: settings.planModeEnabled, @@ -6968,6 +6976,7 @@ export default function ChatView(props: ChatViewProps) { const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { if (!activeThreadId) return; + const threadKeyForRequest = routeThreadKey; setRespondingRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], @@ -6987,18 +6996,27 @@ export default function ChatView(props: ChatViewProps) { error instanceof Error ? error.message : "Failed to submit approval decision.", ); } else if (result._tag === "Success") { - // The agent resumes and spends quota, so any limits snapshot is stale. - setUsageLimitsPanel(null); + // The agent resumes and spends quota, so that thread's snapshot is stale. + // The route may have moved meanwhile, so only the originating thread's clears. + clearUsageLimitsFor(threadKeyForRequest); } setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); return result; }, - [activeThreadId, environmentId, respondToThreadApproval, setThreadError], + [ + activeThreadId, + clearUsageLimitsFor, + environmentId, + respondToThreadApproval, + routeThreadKey, + setThreadError, + ], ); const onRespondToUserInput = useCallback( async (requestId: ApprovalRequestId, answers: Record) => { if (!activeThreadId) return; + const threadKeyForRequest = routeThreadKey; setRespondingUserInputRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], @@ -7018,12 +7036,19 @@ export default function ChatView(props: ChatViewProps) { error instanceof Error ? error.message : "Failed to submit user input.", ); } else if (result._tag === "Success") { - setUsageLimitsPanel(null); + clearUsageLimitsFor(threadKeyForRequest); } setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); return result; }, - [activeThreadId, environmentId, respondToThreadUserInput, setThreadError], + [ + activeThreadId, + clearUsageLimitsFor, + environmentId, + respondToThreadUserInput, + routeThreadKey, + setThreadError, + ], ); const setActivePendingUserInputQuestionIndex = useCallback( diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx index e5fd46d59e1c..0cce80bd6beb 100644 --- a/apps/web/src/components/chat/ComposerUsageLimits.tsx +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -1,5 +1,5 @@ import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; -import { limitsNotice, providerLimitsLabel } from "@t3tools/shared/usageLimits"; +import { limitsNotice } from "@t3tools/shared/usageLimits"; import { GaugeIcon } from "lucide-react"; import { getDriverOption } from "../settings/providerDriverMeta"; @@ -7,10 +7,14 @@ import { LimitWindows, ResetCredits } from "../usage/UsageLimits"; import { ComposerBanner } from "./ComposerBanner"; import type { ComposerBannerStackItem } from "./ComposerBannerStack"; +/** Driver name, then the instance when there could be more than one of that driver. */ function accountLabel(account: UsageLimitsReport["accounts"][number]): string { - return account.instanceId - ? providerLimitsLabel(account, (driver) => getDriverOption(driver)?.label) - : account.label; + if (!account.instanceId) return account.label; + const driver = getDriverOption(account.driver)?.label ?? String(account.driver); + const instance = + account.displayName?.trim() || + (String(account.instanceId) !== String(account.driver) ? account.instanceId : ""); + return instance ? `${driver} · ${instance}` : driver; } /** The /usage-limits result as a composer notice: it stacks under warnings and dismisses like one. */ diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 701341fd6484..2674bcaf7929 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -11,6 +11,7 @@ import { describe, expect, it } from "vite-plus/test"; import { isUsageLimitsCommand, collectProviderUsageLimits, + sameUsageLimitCommandCoverage, withUsageLimitsCommands, collectLimitSources, collectLimitsGroups, @@ -446,6 +447,45 @@ describe("/usage-limits", () => { }); }); +describe("sameUsageLimitCommandCoverage", () => { + const codexAccount = { + id: "a", + driver: ProviderDriverKind.make("codex"), + usageLimits: { checkedAt: "2026-09-03T11:00:00.000Z", windows: [] }, + }; + const base = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Accounts", + checkedAt: "2026-09-03T11:00:00.000Z", + }; + it("ignores quota movement but not the drivers offered the command", () => { + const withCodex = [{ ...base, accounts: [codexAccount] }]; + const withCodexLater = [ + { + ...base, + accounts: [ + { + ...codexAccount, + usageLimits: { ...codexAccount.usageLimits, checkedAt: "2026-09-03T12:00:00.000Z" }, + }, + ], + }, + ]; + expect(sameUsageLimitCommandCoverage(withCodex, withCodexLater)).toBe(true); + expect(sameUsageLimitCommandCoverage(withCodex, [{ ...base, accounts: [] }])).toBe(false); + }); + it("treats a failed read as a change in coverage, in both directions", () => { + const empty = [{ ...base, accounts: [] }]; + const failed = [{ ...base, accounts: [], error: "token expired" }]; + expect(sameUsageLimitCommandCoverage(empty, failed)).toBe(false); + expect(sameUsageLimitCommandCoverage(failed, empty)).toBe(false); + expect( + sameUsageLimitCommandCoverage(failed, [{ ...base, accounts: [], error: "still down" }]), + ).toBe(true); + }); +}); + describe("isUsageLimitsCommand", () => { it("recognizes only the standalone local action", () => { expect(isUsageLimitsCommand(" /USAGE-LIMITS\n")).toBe(true); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 4a957e143da2..c28156801cfb 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -246,6 +246,28 @@ export function hasProviderUsageLimits( ); } +/** + * The drivers a set of sources would offer the command to, where a source that + * failed to read counts for every driver. Two snapshots with the same coverage + * need no catalog republish, however much their quotas moved. + */ +export function sameUsageLimitCommandCoverage( + previous: UsageLimitSourceSnapshots, + next: UsageLimitSourceSnapshots, +): boolean { + const coverage = (sources: UsageLimitSourceSnapshots) => + new Set( + sources.flatMap((source) => + source.error !== undefined && source.accounts.length === 0 + ? ["*"] + : source.accounts.map((account) => String(account.driver)), + ), + ); + const before = coverage(previous); + const after = coverage(next); + return before.size === after.size && [...before].every((driver) => after.has(driver)); +} + /** Advertise on workspace catalogs too, which replace the global command list. */ export function withUsageLimitsCommands( providers: readonly ServerProvider[], From c8d81150655d53f6e4c5aa9f9fe380690727fcbd Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 05:28:05 +0100 Subject: [PATCH 08/18] fix(mobile): let a notice-only limits card be dismissed A report with no accounts, such as a failed hub and no native windows, now renders its own heading row with the close control. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- .../src/features/threads/ComposerUsageLimits.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx index fb885fd637f8..bf359fe97007 100644 --- a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx +++ b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx @@ -77,10 +77,21 @@ export function ComposerUsageLimits({ /> ); })} + {report.accounts.length === 0 ? ( + // Nothing but notices, so the close control needs a row of its own. + + Usage limits + {close} + + ) : null} {report.notices.map((notice) => ( {notice} From a1227e732e61cdc61b2755bd0efd117df0f210b9 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 05:30:14 +0100 Subject: [PATCH 09/18] fix: scope the on-send panel clear to the sending thread A send can outlast a navigation while attachments upload, so it clears only the originating thread's limits panel instead of whatever is open. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 3 ++- apps/web/src/components/ChatView.tsx | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 5d0bd233bc52..ec7eeba492db 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -691,7 +691,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread } // A sent message makes the snapshot stale; a refused send leaves it in place. - setUsageLimitsPanel(null); + clearUsageLimitsFor(targetThreadKey); setSubmittedMessageId(messageId); setAnchorMessageId( @@ -707,6 +707,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return messageId; }, [ anchorMessageId, + clearUsageLimitsFor, props.onSendMessage, props.selectedThread.latestTurn, props.selectedThreadQueueCount, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e8ebe7def48e..60b7b19e0613 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6666,8 +6666,9 @@ export default function ChatView(props: ChatViewProps) { } else { scrollToEnd(); } - // The message is leaving, so the limits snapshot is now stale. - setUsageLimitsPanel(null); + // The message is leaving, so that thread's limits snapshot is now stale. The + // uploads above may have outlasted a navigation, so only that thread's clears. + clearUsageLimitsFor(routeThreadKey); setOptimisticUserMessages((existing) => [ ...existing, { @@ -7213,7 +7214,7 @@ export default function ChatView(props: ChatViewProps) { scrollToEnd(); - setUsageLimitsPanel(null); + clearUsageLimitsFor(routeThreadKey); setOptimisticUserMessages((existing) => [ ...existing, { @@ -7313,6 +7314,8 @@ export default function ChatView(props: ChatViewProps) { startThreadTurn, environmentId, composerRef, + clearUsageLimitsFor, + routeThreadKey, ], ); From b0faf85f620e1499c4ebf5e4dc368460bceb9e70 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 05:36:03 +0100 Subject: [PATCH 10/18] fix(mobile): keep the limits panel when a reply fails Only a delivered approval or answer resumes the agent, so a failed reply leaves the snapshot in place. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- .../src/features/threads/ThreadDetailScreen.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index ec7eeba492db..b0124921786f 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -225,6 +225,13 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray setUsageLimitsPanel(null), []); const { onRespondToApproval, onSubmitUserInput } = props; - // The response may resolve after navigating away, so only the originating - // thread's panel is cleared; a panel opened elsewhere in the meantime stays. + // Only a delivered response resumes the agent and spends quota; a failed one + // leaves the snapshot valid. The response may also resolve after navigating + // away, so only the originating thread's panel is cleared. const clearUsageLimitsFor = useCallback( (threadKey: string) => setUsageLimitsPanel((current) => @@ -415,7 +423,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { const threadKey = selectedThreadKey; const result = await onRespondToApproval(requestId, decision); - clearUsageLimitsFor(threadKey); + if (succeeded(result)) clearUsageLimitsFor(threadKey); return result; }, [clearUsageLimitsFor, onRespondToApproval, selectedThreadKey], @@ -423,7 +431,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const handleSubmitUserInput = useCallback(async () => { const threadKey = selectedThreadKey; const result = await onSubmitUserInput(); - clearUsageLimitsFor(threadKey); + if (succeeded(result)) clearUsageLimitsFor(threadKey); return result; }, [clearUsageLimitsFor, onSubmitUserInput, selectedThreadKey]); const userInputCollapsed = From af50a48aa5a0cf11881c8dfe49ee7c87b2d9e7b6 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 05:37:53 +0100 Subject: [PATCH 11/18] fix(mobile): name pooled accounts in the limits card Source-backed rows showed only the hub kind, so two accounts from one hub were indistinguishable. They now carry the hub and account id. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- apps/mobile/src/features/threads/ComposerUsageLimits.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx index bf359fe97007..bcd76722c8e5 100644 --- a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx +++ b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx @@ -50,14 +50,15 @@ export function ComposerUsageLimits({ first={index === 0} driver={account.driver} label={driverLabel} - // A custom instance without a name still needs telling apart from its siblings. + // Siblings need telling apart: a custom instance without a name shows its + // id, and a pooled account shows its hub and account id. instanceLabel={ account.instanceId ? account.displayName?.trim() || (String(account.instanceId) !== String(account.driver) ? account.instanceId : driverLabel) - : (account.sourceLabel ?? account.label) + : account.label } detail={account.plan} limits={account.limits} From 78b805b7980879d4fca09144930154f7e4e8e1a0 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 05:40:08 +0100 Subject: [PATCH 12/18] fix: identify the panel's thread exactly Ownership was a prefix match on a composite key; thread ids may contain the delimiter, so the panel now stores its thread key and compares it. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- .../src/features/threads/ThreadDetailScreen.tsx | 13 ++++++++++--- apps/web/src/components/ChatView.tsx | 13 ++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index b0124921786f..0a18718e1757 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -376,6 +376,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // answered question. const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ readonly key: string; + readonly threadKey: string; readonly now: number; } | null>(null); const usageLimitsKey = `${selectedThreadKey}:${props.selectedThread.modelSelection.instanceId}:${props.selectedThread.latestTurn?.turnId ?? ""}`; @@ -403,9 +404,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const showUsageLimits = useCallback( (report: UsageLimitsReport | null) => setUsageLimitsPanel( - report === null ? null : { key: usageLimitsKey, now: Date.parse(report.createdAt) }, + report === null + ? null + : { + key: usageLimitsKey, + threadKey: selectedThreadKey, + now: Date.parse(report.createdAt), + }, ), - [usageLimitsKey], + [selectedThreadKey, usageLimitsKey], ); const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []); const { onRespondToApproval, onSubmitUserInput } = props; @@ -415,7 +422,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const clearUsageLimitsFor = useCallback( (threadKey: string) => setUsageLimitsPanel((current) => - current !== null && current.key.startsWith(`${threadKey}:`) ? null : current, + current !== null && current.threadKey === threadKey ? null : current, ), [], ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 60b7b19e0613..48b0b6b37685 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2528,6 +2528,7 @@ export default function ChatView(props: ChatViewProps) { // answered question. const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ readonly key: string; + readonly threadKey: string; readonly now: number; } | null>(null); // Null while the provider list is unavailable, such as during a reconnect; the @@ -2592,18 +2593,24 @@ export default function ChatView(props: ChatViewProps) { ) : null; if (report && usageLimitsKey !== null) { - setUsageLimitsPanel({ key: usageLimitsKey, now }); + setUsageLimitsPanel({ key: usageLimitsKey, threadKey: routeThreadKey, now }); return true; } setUsageLimitsPanel(null); toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); return false; - }, [activeProviderInstanceId, providerStatuses, usageLimitSources, usageLimitsKey]); + }, [ + activeProviderInstanceId, + providerStatuses, + routeThreadKey, + usageLimitSources, + usageLimitsKey, + ]); // Responses can resolve after navigating away; only the originating thread's panel clears. const clearUsageLimitsFor = useCallback( (threadKey: string) => setUsageLimitsPanel((current) => - current !== null && current.key.startsWith(`${threadKey}:`) ? null : current, + current !== null && current.threadKey === threadKey ? null : current, ), [], ); From d1b78283a3ab06f338265f8f726548706410f63c Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 06:01:52 +0100 Subject: [PATCH 13/18] fix(web): do not repeat a driver-named instance in the panel heading Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- apps/web/src/components/chat/ComposerUsageLimits.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx index 0cce80bd6beb..384492b5789e 100644 --- a/apps/web/src/components/chat/ComposerUsageLimits.tsx +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -14,7 +14,10 @@ function accountLabel(account: UsageLimitsReport["accounts"][number]): string { const instance = account.displayName?.trim() || (String(account.instanceId) !== String(account.driver) ? account.instanceId : ""); - return instance ? `${driver} · ${instance}` : driver; + // The default instance is often named after its driver; saying it twice adds nothing. + return instance && instance.toLowerCase() !== driver.toLowerCase() + ? `${driver} · ${instance}` + : driver; } /** The /usage-limits result as a composer notice: it stacks under warnings and dismisses like one. */ From a23a1f4138a00dde608b929c7ce8c9cad165c453 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 06:07:01 +0100 Subject: [PATCH 14/18] fix(web): clear the limits panel only once the turn starts A send that fails before the turn starts spends no quota, so it leaves the panel in place. Both the composer and plan follow-up paths clear after a successful start. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- apps/web/src/components/ChatView.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 48b0b6b37685..88ce5db78eb2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6673,9 +6673,6 @@ export default function ChatView(props: ChatViewProps) { } else { scrollToEnd(); } - // The message is leaving, so that thread's limits snapshot is now stale. The - // uploads above may have outlasted a navigation, so only that thread's clears. - clearUsageLimitsFor(routeThreadKey); setOptimisticUserMessages((existing) => [ ...existing, { @@ -6842,6 +6839,10 @@ export default function ChatView(props: ChatViewProps) { failure = startResult; } else { turnStartSucceeded = true; + // The turn is under way and will spend quota, so that thread's limits + // snapshot is stale. Uploads may have outlasted a navigation, so only + // the sending thread's panel clears. + clearUsageLimitsFor(routeThreadKey); if (turnUsesAttachmentUploads) { releaseDraftAttachments(composerAttachmentsSnapshot); } @@ -7221,7 +7222,6 @@ export default function ChatView(props: ChatViewProps) { scrollToEnd(); - clearUsageLimitsFor(routeThreadKey); setOptimisticUserMessages((existing) => [ ...existing, { @@ -7285,6 +7285,7 @@ export default function ChatView(props: ChatViewProps) { } if (failure === null) { + clearUsageLimitsFor(routeThreadKey); acknowledgeActiveThreadWoke(); sendInFlightRef.current = false; return; From ee64aaa35b429a2040c752d60a85d8e0473ccd0a Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 06:18:47 +0100 Subject: [PATCH 15/18] fix: claim /usage-limits only where T3 has limits data The command name is only T3's where Limits has data for the selected provider; elsewhere a provider's own command of that name is sent through untouched, on the menu, on send, and in New Task. The panel key now includes any pending approval or question, so an answer from any client closes it, which also retires the local response wrappers. The web key stays hidden rather than dropped while a server thread is not yet loaded. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- .../features/threads/NewTaskDraftScreen.tsx | 22 +- .../src/features/threads/ThreadComposer.tsx | 25 +- .../features/threads/ThreadDetailScreen.tsx | 41 +-- .../threads/use-composer-command-menu.ts | 14 +- apps/web/src/components/ChatView.tsx | 233 +++++++++--------- apps/web/src/components/chat/ChatComposer.tsx | 2 +- packages/shared/src/usageLimits.test.ts | 7 + 7 files changed, 186 insertions(+), 158 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index ef3745841326..a50895bd33da 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -49,7 +49,7 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; -import { isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; +import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; import { ShimmeringWorkContent } from "./thread-work-log"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; @@ -310,6 +310,14 @@ export function NewTaskDraftScreen(props: { const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting; // Also guard while a submit is in flight: an Android back press or iOS // Cancel would otherwise abandon the screen while the task still starts. + // T3 owns /usage-limits only where Limits has data for the selected provider. + const offersUsageLimits = + flow.selectedProviderStatus !== null && + hasProviderUsageLimits( + flow.selectedProviderStatus.driver, + selectedEnvironmentServerConfig?.providers ?? [], + selectedEnvironmentServerConfig?.usageLimitSources ?? [], + ); const composerMenu = useComposerCommandMenu({ draftMessage: flow.prompt, ownerKey: flow.draftKey, @@ -321,6 +329,7 @@ export function NewTaskDraftScreen(props: { selectedProviderStatus: flow.selectedProviderStatus, hasThread: false, hasCompactableConversation: false, + offersUsageLimits: offersUsageLimits, enabled: isComposerFocused && !isComposerInteractionLocked, onChangeDraftMessage: flow.setPrompt, onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined, @@ -909,9 +918,14 @@ export function NewTaskDraftScreen(props: { ); return; } - // The thread composer answers this locally; a new task would send it to the agent. - // Attachments mean the user is sending a prompt, so those go through as usual. - if (isUsageLimitsCommand(initialMessageText) && draft.attachments.length === 0) { + // T3's own limits command is answered by the thread composer; a new task would + // send it to the agent. A provider's same-named command, or a prompt carrying + // attachments, goes through as usual. + if ( + offersUsageLimits && + isUsageLimitsCommand(initialMessageText) && + draft.attachments.length === 0 + ) { Alert.alert( "Usage limits", "Send /usage-limits inside a thread, or open Settings → Usage → Limits.", diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 4c16ba71b2e2..1873d23c2bda 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -9,7 +9,11 @@ import type { ServerConfig as T3ServerConfig, UsageLimitsReport, } from "@t3tools/contracts"; -import { collectProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; +import { + collectProviderUsageLimits, + hasProviderUsageLimits, + isUsageLimitsCommand, +} from "@t3tools/shared/usageLimits"; import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; import { @@ -341,6 +345,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props; + // T3 owns /usage-limits only where Limits has data for the selected provider; + // elsewhere the name stays the provider's own and is sent through untouched. + const usageLimitsOffered = + selectedProviderStatus !== null && + hasProviderUsageLimits( + selectedProviderStatus.driver, + props.serverConfig?.providers ?? [], + props.serverConfig?.usageLimitSources ?? [], + ); // Answered locally from the last Limits snapshot; the agent never sees it. const openUsageLimits = useCallback(() => { const report = collectProviderUsageLimits( @@ -369,7 +382,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer selectedProviderStatus?.showInteractionModeToggle === false ? undefined : props.onUpdateInteractionMode, - onUsageLimits: openUsageLimits, + offersUsageLimits: usageLimitsOffered, + onUsageLimits: usageLimitsOffered ? openUsageLimits : undefined, }); const voiceInput = useVoiceInputController({ ownerKey: composerOwnerKey, @@ -451,7 +465,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const handleSend = useCallback(async () => { // Typed out in full rather than picked from the menu. Attachments mean the // user is sending a prompt, so those go through as usual. - if (isUsageLimitsCommand(props.draftMessage) && props.draftAttachments.length === 0) { + if ( + usageLimitsOffered && + isUsageLimitsCommand(props.draftMessage) && + props.draftAttachments.length === 0 + ) { if (openUsageLimits()) onChangeDraftMessage(""); return; } @@ -481,6 +499,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.draftAttachments.length, onChangeDraftMessage, openUsageLimits, + usageLimitsOffered, onSendMessage, props.environmentId, props.environmentLabel, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 0a18718e1757..52ab91a5f496 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -225,13 +225,6 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray(null); - const usageLimitsKey = `${selectedThreadKey}:${props.selectedThread.modelSelection.instanceId}:${props.selectedThread.latestTurn?.turnId ?? ""}`; + // A pending approval or question is part of the key: once it is answered, + // from this client or any other, the agent resumes and spends quota. + const usageLimitsKey = [ + selectedThreadKey, + props.selectedThread.modelSelection.instanceId, + props.selectedThread.latestTurn?.turnId ?? "", + props.activePendingApproval?.requestId ?? props.activePendingUserInput?.requestId ?? "", + ].join(":"); // Drop the snapshot as soon as the key changes so it cannot resurface stale. if (usageLimitsPanel !== null && usageLimitsPanel.key !== usageLimitsKey) { setUsageLimitsPanel(null); @@ -415,10 +415,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread [selectedThreadKey, usageLimitsKey], ); const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []); - const { onRespondToApproval, onSubmitUserInput } = props; - // Only a delivered response resumes the agent and spends quota; a failed one - // leaves the snapshot valid. The response may also resolve after navigating - // away, so only the originating thread's panel is cleared. + // A send may resolve after navigating away, so only the originating + // thread's panel is cleared; a panel opened elsewhere in the meantime stays. const clearUsageLimitsFor = useCallback( (threadKey: string) => setUsageLimitsPanel((current) => @@ -426,21 +424,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ), [], ); - const handleRespondToApproval = useCallback( - async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { - const threadKey = selectedThreadKey; - const result = await onRespondToApproval(requestId, decision); - if (succeeded(result)) clearUsageLimitsFor(threadKey); - return result; - }, - [clearUsageLimitsFor, onRespondToApproval, selectedThreadKey], - ); - const handleSubmitUserInput = useCallback(async () => { - const threadKey = selectedThreadKey; - const result = await onSubmitUserInput(); - if (succeeded(result)) clearUsageLimitsFor(threadKey); - return result; - }, [clearUsageLimitsFor, onSubmitUserInput, selectedThreadKey]); const userInputCollapsed = activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; // The card's height RESERVES keyboard space at all times instead of @@ -894,7 +877,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) : null} {props.activePendingUserInput ? ( @@ -912,7 +895,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread respondingUserInputId={props.respondingUserInputId} onSelectOption={props.onSelectUserInputOption} onChangeCustomAnswer={props.onChangeUserInputCustomAnswer} - onSubmit={handleSubmitUserInput} + onSubmit={props.onSubmitUserInput} /> ) : null} diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 2909995cb690..23c56d28f4c4 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -37,6 +37,8 @@ export function buildComposerSlashCommandItems(input: { readonly atMessageStart: boolean; readonly hasThread: boolean; readonly hasCompactableConversation?: boolean; + /** Whether T3 itself offers /usage-limits for the selected provider. */ + readonly offersUsageLimits?: boolean; readonly allowInteractionMode: boolean; readonly selectedProviderStatus: Pick< ServerProvider, @@ -79,8 +81,11 @@ export function buildComposerSlashCommandItems(input: { for (const command of input.selectedProviderStatus?.slashCommands ?? []) { if (!command.name.toLowerCase().includes(query)) continue; if (command.name === "compact" && !input.hasCompactableConversation) continue; - // Answered by the thread composer; New Task has nowhere to show it. - if (command.name === USAGE_LIMITS_COMMAND.name && !input.hasThread) continue; + // T3's own limits command is answered by the thread composer; New Task has + // nowhere to show it. A provider's same-named command is left alone. + if (command.name === USAGE_LIMITS_COMMAND.name && input.offersUsageLimits && !input.hasThread) { + continue; + } if ( !input.hasThread && input.selectedProviderStatus?.driver === "codex" && @@ -146,6 +151,7 @@ export function useComposerCommandMenu({ selectedProviderStatus, hasThread, hasCompactableConversation, + offersUsageLimits = false, enabled = true, onChangeDraftMessage, onUpdateInteractionMode, @@ -158,6 +164,8 @@ export function useComposerCommandMenu({ readonly selectedProviderStatus: ServerProvider | null; readonly hasThread: boolean; readonly hasCompactableConversation: boolean; + /** Whether T3 itself offers /usage-limits for the selected provider. */ + readonly offersUsageLimits?: boolean; readonly enabled?: boolean; readonly onChangeDraftMessage: (value: string) => void; readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; @@ -273,6 +281,7 @@ export function useComposerCommandMenu({ atMessageStart: trigger.rangeStart === 0, hasThread, hasCompactableConversation, + offersUsageLimits, allowInteractionMode: onUpdateInteractionMode !== undefined, selectedProviderStatus, }); @@ -396,6 +405,7 @@ export function useComposerCommandMenu({ selectedProviderStatus, skills, trigger, + offersUsageLimits, ]); const onSelect = useCallback( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 88ce5db78eb2..3c3726035ed7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,9 @@ import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; -import { collectProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; +import { + collectProviderUsageLimits, + hasProviderUsageLimits, + isUsageLimitsCommand, +} from "@t3tools/shared/usageLimits"; import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; import { type AssistantCitation, @@ -2521,99 +2525,6 @@ export default function ChatView(props: ChatViewProps) { ); const selectedProvider = selectedProviderEntry?.driverKind ?? requestedDriverKind; const activeProviderInstanceId = selectedProviderEntry?.instanceId ?? null; - // The open /usage-limits panel for this thread, model and turn. Only the open - // moment is stored: the rows read live provider data, so a redeemed reset - // credit or refreshed probe shows through. Anything that spends quota closes - // it: a new turn from any source, or the agent resuming after an approval or - // answered question. - const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ - readonly key: string; - readonly threadKey: string; - readonly now: number; - } | null>(null); - // Null while the provider list is unavailable, such as during a reconnect; the - // snapshot then stays hidden rather than being dropped for a transient blip. - const usageLimitsKey = - activeProviderInstanceId === null - ? null - : `${routeThreadKey}:${activeProviderInstanceId}:${activeThread?.latestTurn?.turnId ?? ""}`; - // Drop the snapshot as soon as the thread or model changes so it cannot resurface stale. - if ( - usageLimitsPanel !== null && - usageLimitsKey !== null && - usageLimitsPanel.key !== usageLimitsKey - ) { - setUsageLimitsPanel(null); - } - const usageLimitSources = serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES; - const usageLimitsReport = useMemo( - () => - usageLimitsPanel !== null && - usageLimitsKey !== null && - usageLimitsPanel.key === usageLimitsKey && - activeProviderInstanceId !== null - ? collectProviderUsageLimits( - activeProviderInstanceId, - providerStatuses, - usageLimitSources, - usageLimitsPanel.now, - ) - : null, - [ - activeProviderInstanceId, - providerStatuses, - usageLimitSources, - usageLimitsKey, - usageLimitsPanel, - ], - ); - const usageLimitsBanner = useMemo( - () => - usageLimitsReport !== null && usageLimitsPanel !== null - ? // A fresh id per opening: the stack keeps the last dismissed id as "exiting". - usageLimitsBannerItem( - `usage-limits:${usageLimitsPanel.key}:${usageLimitsPanel.now}`, - usageLimitsReport, - environmentId, - () => setUsageLimitsPanel(null), - ) - : null, - [environmentId, usageLimitsPanel, usageLimitsReport], - ); - // Answered locally from the last Limits snapshot; the agent never sees it. - const openUsageLimits = useCallback(() => { - const now = Date.now(); - const report = - activeProviderInstanceId !== null && usageLimitsKey !== null - ? collectProviderUsageLimits( - activeProviderInstanceId, - providerStatuses, - usageLimitSources, - now, - ) - : null; - if (report && usageLimitsKey !== null) { - setUsageLimitsPanel({ key: usageLimitsKey, threadKey: routeThreadKey, now }); - return true; - } - setUsageLimitsPanel(null); - toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); - return false; - }, [ - activeProviderInstanceId, - providerStatuses, - routeThreadKey, - usageLimitSources, - usageLimitsKey, - ]); - // Responses can resolve after navigating away; only the originating thread's panel clears. - const clearUsageLimitsFor = useCallback( - (threadKey: string) => - setUsageLimitsPanel((current) => - current !== null && current.threadKey === threadKey ? null : current, - ), - [], - ); const activeProviderStatus = selectedProviderEntry?.snapshot ?? null; const { enabled: interactionModeEnabled, interactionMode } = resolveComposerInteractionMode({ planModeEnabled: settings.planModeEnabled, @@ -2716,6 +2627,111 @@ export default function ChatView(props: ChatViewProps) { hasComposerAttachments: composerHasAttachments, }); const activePendingApproval = pendingApprovals[0] ?? null; + // The open /usage-limits panel for this thread, model and turn. Only the open + // moment is stored: the rows read live provider data, so a redeemed reset + // credit or refreshed probe shows through. Anything that spends quota closes + // it: a new turn from any source, or the agent resuming after an approval or + // answered question. + const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ + readonly key: string; + readonly threadKey: string; + readonly now: number; + } | null>(null); + // Null while the provider list or the thread itself is unavailable, such as + // during a reconnect; the panel then stays hidden rather than being dropped. + // A pending approval or question is part of the key: once it is answered, + // from this client or any other, the agent resumes and spends quota. + const usageLimitsKey = + activeProviderInstanceId === null || (isServerThread && activeThread === undefined) + ? null + : [ + routeThreadKey, + activeProviderInstanceId, + activeThread?.latestTurn?.turnId ?? "", + activePendingApproval?.requestId ?? activePendingUserInput?.requestId ?? "", + ].join(":"); + // Drop the snapshot as soon as the thread or model changes so it cannot resurface stale. + if ( + usageLimitsPanel !== null && + usageLimitsKey !== null && + usageLimitsPanel.key !== usageLimitsKey + ) { + setUsageLimitsPanel(null); + } + const usageLimitSources = serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES; + const usageLimitsReport = useMemo( + () => + usageLimitsPanel !== null && + usageLimitsKey !== null && + usageLimitsPanel.key === usageLimitsKey && + activeProviderInstanceId !== null + ? collectProviderUsageLimits( + activeProviderInstanceId, + providerStatuses, + usageLimitSources, + usageLimitsPanel.now, + ) + : null, + [ + activeProviderInstanceId, + providerStatuses, + usageLimitSources, + usageLimitsKey, + usageLimitsPanel, + ], + ); + const usageLimitsBanner = useMemo( + () => + usageLimitsReport !== null && usageLimitsPanel !== null + ? // A fresh id per opening: the stack keeps the last dismissed id as "exiting". + usageLimitsBannerItem( + `usage-limits:${usageLimitsPanel.key}:${usageLimitsPanel.now}`, + usageLimitsReport, + environmentId, + () => setUsageLimitsPanel(null), + ) + : null, + [environmentId, usageLimitsPanel, usageLimitsReport], + ); + // T3 owns /usage-limits only where Limits has data for the selected provider; + // elsewhere the name stays the provider's own and is sent through untouched. + const usageLimitsOffered = + activeProviderStatus !== null && + hasProviderUsageLimits(activeProviderStatus.driver, providerStatuses, usageLimitSources); + // Answered locally from the last Limits snapshot; the agent never sees it. + const openUsageLimits = useCallback(() => { + const now = Date.now(); + const report = + activeProviderInstanceId !== null && usageLimitsKey !== null + ? collectProviderUsageLimits( + activeProviderInstanceId, + providerStatuses, + usageLimitSources, + now, + ) + : null; + if (report && usageLimitsKey !== null) { + setUsageLimitsPanel({ key: usageLimitsKey, threadKey: routeThreadKey, now }); + return true; + } + setUsageLimitsPanel(null); + toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); + return false; + }, [ + activeProviderInstanceId, + providerStatuses, + routeThreadKey, + usageLimitSources, + usageLimitsKey, + ]); + // Responses can resolve after navigating away; only the originating thread's panel clears. + const clearUsageLimitsFor = useCallback( + (threadKey: string) => + setUsageLimitsPanel((current) => + current !== null && current.threadKey === threadKey ? null : current, + ), + [], + ); const { beginLocalDispatch, resetLocalDispatch, @@ -6185,6 +6201,7 @@ export default function ChatView(props: ChatViewProps) { // Typed out in full rather than picked from the menu. Attachments or contexts // mean the user is sending a prompt, so those go through as usual. if ( + usageLimitsOffered && !directAnnotation && !composerHasNonPromptContent && isUsageLimitsCommand(promptRef.current) @@ -6985,7 +7002,6 @@ export default function ChatView(props: ChatViewProps) { const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { if (!activeThreadId) return; - const threadKeyForRequest = routeThreadKey; setRespondingRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], @@ -7004,28 +7020,16 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, error instanceof Error ? error.message : "Failed to submit approval decision.", ); - } else if (result._tag === "Success") { - // The agent resumes and spends quota, so that thread's snapshot is stale. - // The route may have moved meanwhile, so only the originating thread's clears. - clearUsageLimitsFor(threadKeyForRequest); } setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); return result; }, - [ - activeThreadId, - clearUsageLimitsFor, - environmentId, - respondToThreadApproval, - routeThreadKey, - setThreadError, - ], + [activeThreadId, environmentId, respondToThreadApproval, setThreadError], ); const onRespondToUserInput = useCallback( async (requestId: ApprovalRequestId, answers: Record) => { if (!activeThreadId) return; - const threadKeyForRequest = routeThreadKey; setRespondingUserInputRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], @@ -7044,20 +7048,11 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, error instanceof Error ? error.message : "Failed to submit user input.", ); - } else if (result._tag === "Success") { - clearUsageLimitsFor(threadKeyForRequest); } setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); return result; }, - [ - activeThreadId, - clearUsageLimitsFor, - environmentId, - respondToThreadUserInput, - routeThreadKey, - setThreadError, - ], + [activeThreadId, environmentId, respondToThreadUserInput, setThreadError], ); const setActivePendingUserInputQuestionIndex = useCallback( @@ -8098,7 +8093,7 @@ export default function ChatView(props: ChatViewProps) { } isPreparingWorktree={isPreparingWorktree} bannerItems={composerBannerItems} - onUsageLimitsCommand={openUsageLimits} + onUsageLimitsCommand={usageLimitsOffered ? openUsageLimits : undefined} environmentUnavailable={activeEnvironmentUnavailableState} activePendingApproval={activePendingApproval} pendingApprovals={pendingApprovals} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ec4cb77a94ed..8327f95363d3 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1193,7 +1193,7 @@ export interface ChatComposerProps { isPreparingWorktree: boolean; bannerItems: readonly ComposerBannerStackItem[]; /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */ - onUsageLimitsCommand?: () => void; + onUsageLimitsCommand?: (() => void) | undefined; environmentUnavailable: { readonly label: string; readonly connection: EnvironmentConnectionPresentation; diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 2674bcaf7929..6b1952199dd2 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -435,6 +435,13 @@ describe("/usage-limits", () => { supported?.workspaceSnapshots?.[0]?.slashCommands.map((command) => command.name), ).toEqual(["usage-limits"]); expect(withUsageLimitsCommands([withWorkspace], [])[0]?.slashCommands).toEqual([]); + // A provider's own command of the same name is left alone without coverage. + const ownCommand = provider({ + slashCommands: [{ name: "usage-limits", description: "Provider's own" }], + }); + expect(withUsageLimitsCommands([ownCommand], [])[0]?.slashCommands).toEqual([ + { name: "usage-limits", description: "Provider's own" }, + ]); const unreadable = { ...sources[0]!, accounts: [], error: "token expired" }; expect( withUsageLimitsCommands([withWorkspace], [unreadable])[0]?.slashCommands.map( From 4ada290c231fc8fc64049507bd05aca48f993f97 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 06:25:10 +0100 Subject: [PATCH 16/18] fix: advertise /usage-limits only to clients that answer it Clients that handle the command locally say so on the config subscription; the server injects it into provider catalogs only for them. An older client, which would send the injected command to the provider as a prompt, never sees it. The one-shot config fetch stays without it. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- apps/mobile/src/connection/runtime.ts | 4 +- apps/mobile/src/state/server.ts | 1 + apps/server/src/server.test.ts | 64 ++++++++- apps/server/src/ws.ts | 137 +++++++++++--------- apps/web/src/connection/runtime.ts | 6 +- apps/web/src/state/server.ts | 1 + packages/client-runtime/src/rpc/session.ts | 3 + packages/client-runtime/src/state/server.ts | 4 + packages/contracts/src/rpc.ts | 6 + 9 files changed, 160 insertions(+), 66 deletions(-) diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts index deee27ef040d..ce478962b8b5 100644 --- a/apps/mobile/src/connection/runtime.ts +++ b/apps/mobile/src/connection/runtime.ts @@ -31,7 +31,9 @@ type ConnectionLayerSource = | typeof mobileBackgroundActivityReporterLayer; const providedClientConnectionLayer = snapshotLoaderLayer.pipe( - Layer.provideMerge(Connection.layerWithOptions({ usageLimitSources: true })), + Layer.provideMerge( + Connection.layerWithOptions({ usageLimitSources: true, usageLimitsCommand: true }), + ), Layer.provideMerge( Layer.mergeAll( runtimeContextLayer, diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts index 2157c72e13ef..28cd2af57062 100644 --- a/apps/mobile/src/state/server.ts +++ b/apps/mobile/src/state/server.ts @@ -8,6 +8,7 @@ import { environmentSession } from "./session"; export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { initialConfigValueAtom: environmentSession.initialConfigValueAtom, usageLimitSources: true, + usageLimitsCommand: true, }); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index dcc70063c270..43356ef63148 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6187,7 +6187,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const events = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), + client[WS_METHODS.subscribeServerConfig]({ usageLimitsCommand: true }).pipe( + Stream.take(2), + Stream.runCollect, + ), ), ); @@ -6218,6 +6221,60 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "routes websocket rpc subscribeServerConfig keeps the limits command from clients that do not ask for it", + () => + Effect.gen(function* () { + const codex = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + usageLimits: { + checkedAt: "2026-04-11T00:00:00.000Z", + windows: [{ id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }], + }, + }; + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.succeed({ keybindings: [], issues: [] }), + streamChanges: Stream.empty, + }, + providerRegistry: { + getProviders: Effect.succeed([codex]), + streamChanges: Stream.succeed([{ ...codex, version: "1.0.1" }]), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.providers, [codex]); + } + assert.deepEqual(second, { + version: 1, + type: "providerStatuses", + payload: { providers: [{ ...codex, version: "1.0.1" }] }, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect( "routes websocket rpc subscribeServerConfig republishes commands when only a limits source changes", () => @@ -6276,7 +6333,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const events = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), + client[WS_METHODS.subscribeServerConfig]({ usageLimitsCommand: true }).pipe( + Stream.take(2), + Stream.runCollect, + ), ), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5219d1b5d418..5188f0bfbd67 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1219,62 +1219,67 @@ const makeWsRpcLayer = ( ); }; - const loadServerConfig = Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.loadConfigState; - const providers = withUsageLimitsCommands( - yield* providerRegistry.getProviders, - yield* usageLimitSources.current, - ); - const settings = ServerSettings.redactServerSettingsForClient( - yield* serverSettings.getSettings, - ); - const environment = yield* serverEnvironment.getDescriptor; - const auth = yield* serverAuth.getDescriptor(); - const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( - externalLauncher.resolveAvailableEditors(), - ); - const fileManagerRevealKind = availableEditors.includes("file-manager") - ? yield* resolveFileManagerRevealKindForConfig( - externalLauncher.resolveFileManagerRevealKind(), - ) - : undefined; - - return { - environment, - auth, - cwd: config.cwd, - keybindingsConfigPath: config.keybindingsConfigPath, - keybindings: keybindingsConfig.keybindings, - issues: keybindingsConfig.issues, - providers, - availableEditors, - // Same discovery-with-timeout treatment as editors: a slow probe - // must not stall server.getConfig, so it degrades to no targets. - remoteOpenTargets: yield* resolveAvailableEditorsForConfig( - remoteOpenTargets.resolveTargets(), - ), - observability: { - logsDirectoryPath: config.logsDir, - localTracingEnabled: true, - ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), - otlpTracesEnabled: config.otlpTracesUrl !== undefined, - ...(config.otlpMetricsUrl !== undefined - ? { otlpMetricsUrl: config.otlpMetricsUrl } - : {}), - otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, - }, - settings, - shellResumeCompletionMarker: true, - ...(fileManagerRevealKind === undefined - ? {} - : { - shellRevealInFileManager: true, - shellRevealInFileManagerKind: fileManagerRevealKind, - }), - threadResumeCompletionMarker: true, - threadSnapshotPagination: true, - }; - }); + // Only clients that answer /usage-limits themselves see it in the catalogs; + // an older client would send the injected command to the provider. + const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.loadConfigState; + const currentProviders = yield* providerRegistry.getProviders; + const providers = options.usageLimitsCommand + ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) + : currentProviders; + const settings = ServerSettings.redactServerSettingsForClient( + yield* serverSettings.getSettings, + ); + const environment = yield* serverEnvironment.getDescriptor; + const auth = yield* serverAuth.getDescriptor(); + const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( + externalLauncher.resolveAvailableEditors(), + ); + const fileManagerRevealKind = availableEditors.includes("file-manager") + ? yield* resolveFileManagerRevealKindForConfig( + externalLauncher.resolveFileManagerRevealKind(), + ) + : undefined; + + return { + environment, + auth, + cwd: config.cwd, + keybindingsConfigPath: config.keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + providers, + availableEditors, + // Same discovery-with-timeout treatment as editors: a slow probe + // must not stall server.getConfig, so it degrades to no targets. + remoteOpenTargets: yield* resolveAvailableEditorsForConfig( + remoteOpenTargets.resolveTargets(), + ), + observability: { + logsDirectoryPath: config.logsDir, + localTracingEnabled: true, + ...(config.otlpTracesUrl !== undefined + ? { otlpTracesUrl: config.otlpTracesUrl } + : {}), + otlpTracesEnabled: config.otlpTracesUrl !== undefined, + ...(config.otlpMetricsUrl !== undefined + ? { otlpMetricsUrl: config.otlpMetricsUrl } + : {}), + otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, + }, + settings, + shellResumeCompletionMarker: true, + ...(fileManagerRevealKind === undefined + ? {} + : { + shellRevealInFileManager: true, + shellRevealInFileManagerKind: fileManagerRevealKind, + }), + threadResumeCompletionMarker: true, + threadSnapshotPagination: true, + }; + }); const refreshGitStatus = (cwd: string) => vcsStatusBroadcaster @@ -1751,9 +1756,13 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }), [WS_METHODS.serverGetConfig]: (_input) => - observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { - "rpc.aggregate": "server", - }), + observeRpcEffect( + WS_METHODS.serverGetConfig, + loadServerConfig({ usageLimitsCommand: false }), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, @@ -2668,7 +2677,8 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( WS_METHODS.subscribeServerConfig, Effect.gen(function* () { - const config = yield* loadServerConfig; + const usageLimitsCommand = input.usageLimitsCommand === true; + const config = yield* loadServerConfig({ usageLimitsCommand }); const keybindingsUpdates = keybindings.streamChanges.pipe( Stream.map((event) => ({ version: 1 as const, @@ -2690,9 +2700,12 @@ const makeWsRpcLayer = ( usageLimitSources.streamChanges.pipe( // Quota updates already have their own stream. Republish the model // catalog only when the set of providers offered the command changes. - Stream.changesWith(sameUsageLimitCommandCoverage), + Stream.changesWith( + usageLimitsCommand ? sameUsageLimitCommandCoverage : () => true, + ), ), - withUsageLimitsCommands, + (providers, sources) => + usageLimitsCommand ? withUsageLimitsCommands(providers, sources) : providers, ).pipe( // Both sides replay their current value, so the first pairing normally // repeats the snapshot the client already holds. Compare against that diff --git a/apps/web/src/connection/runtime.ts b/apps/web/src/connection/runtime.ts index ac2316560d98..b5e78287f263 100644 --- a/apps/web/src/connection/runtime.ts +++ b/apps/web/src/connection/runtime.ts @@ -32,7 +32,11 @@ type ConnectionLayerSource = const providedClientConnectionLayer = snapshotLoaderLayer.pipe( Layer.provideMerge( - Connection.layerWithOptions({ environmentThemes: true, usageLimitSources: true }), + Connection.layerWithOptions({ + environmentThemes: true, + usageLimitSources: true, + usageLimitsCommand: true, + }), ), Layer.provideMerge( Layer.mergeAll( diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 0eacc933da49..31b9436621c9 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -29,6 +29,7 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt initialConfigValueAtom: environmentSession.initialConfigValueAtom, environmentThemes: true, usageLimitSources: true, + usageLimitsCommand: true, }); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index aeef14968ff8..34e06c111c8d 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -57,6 +57,8 @@ export interface RpcSession { export interface RpcSessionOptions { readonly environmentThemes?: boolean; readonly usageLimitSources?: boolean; + /** This client answers /usage-limits itself, so the server may advertise it. */ + readonly usageLimitsCommand?: boolean; } export class RpcSessionFactory extends Context.Service< @@ -152,6 +154,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( const serverConfigInput: ServerConfigSubscriptionInput = { ...(options.environmentThemes === true ? { environmentThemes: true } : {}), ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), + ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }; const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 7ba62a681481..aa592f002ed4 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -356,6 +356,7 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven export interface ServerConfigSubscriptionOptions { readonly environmentThemes?: boolean; readonly usageLimitSources?: boolean; + readonly usageLimitsCommand?: boolean; } export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( @@ -423,6 +424,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf yield* subscribe(WS_METHODS.subscribeServerConfig, { ...(subscription.environmentThemes === true ? { environmentThemes: true } : {}), ...(subscription.usageLimitSources === true ? { usageLimitSources: true } : {}), + ...(subscription.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }).pipe( Stream.runForEach((event) => Effect.gen(function* () { @@ -521,6 +523,7 @@ export function createServerEnvironmentAtoms( readonly environmentThemes?: boolean; /** Whether this surface renders quota from configured usage-limit sources. */ readonly usageLimitSources?: boolean; + readonly usageLimitsCommand?: boolean; }, ) { const configScheduler = createAtomCommandScheduler(); @@ -536,6 +539,7 @@ export function createServerEnvironmentAtoms( serverConfigStateChanges(environmentId, { ...(options.environmentThemes === true ? { environmentThemes: true } : {}), ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), + ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }), ) .pipe( diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f7f2c2b6faa7..711710424a92 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1140,6 +1140,12 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon environmentThemes: Schema.optional(Schema.Boolean), /** Whether this client understands `usageLimitSourcesUpdated` events. */ usageLimitSources: Schema.optional(Schema.Boolean), + /** + * Whether this client answers `/usage-limits` itself. The server injects + * that command into provider catalogs only for such clients; an older + * client would send it to the provider as an ordinary prompt. + */ + usageLimitsCommand: Schema.optional(Schema.Boolean), }), success: ServerConfigStreamEvent, error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), From 7115102b0cf89caeaf0dd1f5f2ccc9e3ed43aad9 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 06:28:02 +0100 Subject: [PATCH 17/18] fix(web): keep the typed command while the panel cannot open The menu handler and the send intercept now require an available panel key, so a reconnect or loading thread leaves the text in the composer. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- apps/web/src/components/ChatView.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c3726035ed7..f8252525c028 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6202,6 +6202,7 @@ export default function ChatView(props: ChatViewProps) { // mean the user is sending a prompt, so those go through as usual. if ( usageLimitsOffered && + usageLimitsKey !== null && !directAnnotation && !composerHasNonPromptContent && isUsageLimitsCommand(promptRef.current) @@ -8093,7 +8094,11 @@ export default function ChatView(props: ChatViewProps) { } isPreparingWorktree={isPreparingWorktree} bannerItems={composerBannerItems} - onUsageLimitsCommand={usageLimitsOffered ? openUsageLimits : undefined} + onUsageLimitsCommand={ + usageLimitsOffered && usageLimitsKey !== null + ? openUsageLimits + : undefined + } environmentUnavailable={activeEnvironmentUnavailableState} activePendingApproval={activePendingApproval} pendingApprovals={pendingApprovals} From f370809200f8e73eb62a2f887d33d868bcce0b47 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 5 Sep 2026 06:32:48 +0100 Subject: [PATCH 18/18] fix: only offer the menu action for a text-only draft Picking /usage-limits with attachments or contexts aboard inserts the command text instead, so it sends as a prompt, matching the typed path. Model: Claude Fable 5.1. Harness: Claude Code in T3 Code. --- apps/mobile/src/features/threads/ThreadComposer.tsx | 4 +++- apps/web/src/components/ChatView.tsx | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 1873d23c2bda..d61d7b92d0fb 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -383,7 +383,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ? undefined : props.onUpdateInteractionMode, offersUsageLimits: usageLimitsOffered, - onUsageLimits: usageLimitsOffered ? openUsageLimits : undefined, + // With attachments aboard the pick just inserts the text, so it sends as a prompt. + onUsageLimits: + usageLimitsOffered && props.draftAttachments.length === 0 ? openUsageLimits : undefined, }); const voiceInput = useVoiceInputController({ ownerKey: composerOwnerKey, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f8252525c028..e698b1b48cb0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8094,8 +8094,12 @@ export default function ChatView(props: ChatViewProps) { } isPreparingWorktree={isPreparingWorktree} bannerItems={composerBannerItems} + // With attachments or contexts aboard the pick just inserts the + // text, so it sends as a prompt like the typed path would. onUsageLimitsCommand={ - usageLimitsOffered && usageLimitsKey !== null + usageLimitsOffered && + usageLimitsKey !== null && + !composerHasNonPromptContent ? openUsageLimits : undefined }