Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ import {
} from "../../native/native-layout-metrics";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";
import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage";
import type { ComposerEditorHandle } from "../../components/ComposerEditor";
import type { StatusTone } from "../../components/StatusPill";
import { getComposerDraftSnapshot, setFreshSessionPending } from "../../state/use-composer-drafts";
Expand Down Expand Up @@ -161,6 +162,7 @@ import {
} from "../../state/threads";
import { appAtomRegistry } from "../../state/atom-registry";
import { threadStreamHaptics } from "./threadStreamHaptics";
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { orchestrationEnvironment } from "../../state/orchestration";
import { useDelayedStatus } from "../../lib/useDelayedStatus";
Expand Down Expand Up @@ -993,6 +995,41 @@ function ThreadDetailBody(
now: Date.parse(report.createdAt),
},
);
const canCheckUsageLimits = useAtomValue(
serverEnvironment.refreshProviders.permissionAtom(props.environmentId),
);
const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
reportFailure: false,
});
const checkUsageLimits = async () => {
const result = await refreshUsageLimits(props.environmentId, () =>
refreshProviders({ environmentId: props.environmentId, input: {} }),
);
if (result?._tag !== "Success") return "Could not check usage limits.";
// Read the response and the registry: the config stream may not have rendered yet.
const report = collectProviderUsageLimits(
props.selectedThread.modelSelection.instanceId,
result.value.providers,
appAtomRegistry.get(serverEnvironment.configValueAtom(props.environmentId))
?.usageLimitSources ??
props.serverConfig?.usageLimitSources ??
[],
Date.now(),
);
// Never replace a panel opened for a newer context while this check was in flight.
setUsageLimitsPanel((current) =>
current !== null && current.key !== usageLimitsKey
? current
: report === null
? null
: {
key: usageLimitsKey,
threadKey: selectedThreadKey,
now: Date.parse(report.createdAt),
},
);
return report === null ? "This provider does not currently report limits." : null;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const dismissUsageLimits = () => setUsageLimitsPanel(null);
// Late sends clear only their originating thread's limits panel.
const clearUsageLimitsFor = (threadKey: string) =>
Expand Down Expand Up @@ -1679,6 +1716,7 @@ function ThreadDetailBody(
key={props.selectedThread.latestRun?.runId}
thread={props.selectedThread}
environmentId={props.environmentId}
onCheckLimits={canCheckUsageLimits ? checkUsageLimits : null}
/>
<ChildThreadApprovals
threadRef={props.selectedThreadDetailRef}
Expand Down
24 changes: 24 additions & 0 deletions apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ describe("UsageLimitRecoveryCard", () => {
<UsageLimitRecoveryCard
thread={failedThread(resetAt, code, errorClass)}
environmentId={"env-1" as EnvironmentId}
onCheckLimits={null}
/>,
),
);
Expand All @@ -86,6 +87,26 @@ describe("UsageLimitRecoveryCard", () => {
);
});

it("checks limits when the provider reported no reset, and shows why a check failed", async () => {
const onCheckLimits = vi.fn(async () => "Could not check usage limits.");
const container = document.createElement("div");
const root = createRoot(container);
using _root = { [Symbol.dispose]: () => act(() => root.unmount()) };
act(() =>
root.render(
<UsageLimitRecoveryCard
thread={failedThread(null)}
environmentId={"env-1" as EnvironmentId}
onCheckLimits={onCheckLimits}
/>,
),
);
await act(async () => container.querySelector("button")!.click());
expect(onCheckLimits).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("Could not check usage limits.");
expect(container.querySelector("button")!.textContent).toBe("Check limits");
});

it("drops a failed copy's error once a retry copies the request", async () => {
vi.mocked(Clipboard.setStringAsync)
.mockRejectedValueOnce(new Error("denied"))
Expand All @@ -98,6 +119,7 @@ describe("UsageLimitRecoveryCard", () => {
<UsageLimitRecoveryCard
thread={failedThread(null, CHATGPT_WORKSPACE_USAGE_LIMIT_CODE)}
environmentId={"env-1" as EnvironmentId}
onCheckLimits={null}
/>,
),
);
Expand All @@ -120,6 +142,7 @@ describe("UsageLimitRecoveryCard", () => {
<UsageLimitRecoveryCard
thread={failedThread(null, CHATGPT_WORKSPACE_USAGE_LIMIT_CODE)}
environmentId={"env-1" as EnvironmentId}
onCheckLimits={null}
/>,
),
);
Expand All @@ -142,6 +165,7 @@ describe("UsageLimitRecoveryCard", () => {
<UsageLimitRecoveryCard
thread={failedThread(new Date(30 * DAY_MS).toISOString())}
environmentId={"env-1" as EnvironmentId}
onCheckLimits={null}
/>,
),
);
Expand Down
21 changes: 20 additions & 1 deletion apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ import { useAtomCommand } from "../../state/use-atom-command";
export function UsageLimitRecoveryCard({
thread,
environmentId,
onCheckLimits,
}: {
thread: EnvironmentThreadShell;
environmentId: EnvironmentId;
/** Resolves to an error message, or null once the limits panel is open. */
onCheckLimits: (() => Promise<string | null>) | null;
}) {
const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata);
const [pending, setPending] = useState(false);
Expand All @@ -48,10 +51,12 @@ export function UsageLimitRecoveryCard({
: resetAt;
const [copied, setCopied] = useState(false);
const [actionPending, setActionPending] = useState(false);
const [checking, setChecking] = useState(false);
const code = thread.runtime?.lastErrorCode;
// OpenAI's partner guidelines specify the ChatGPT notice and its action.
const workspaceLimit = code === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE;
const chatGptLimit = workspaceLimit || code === CHATGPT_USAGE_LIMIT_CODE;
const canCheck = !chatGptLimit && resetAt === null && onCheckLimits !== null;
if (
thread.runtime?.status !== "failed" ||
// Older servers report ChatGPT sharing limits as provider errors.
Expand Down Expand Up @@ -97,6 +102,13 @@ export function UsageLimitRecoveryCard({
setError(cause instanceof Error ? cause.message : "Could not change limit recovery.");
}
}
async function checkLimits() {
if (!onCheckLimits) return;
setChecking(true);
setError(null);
setError(await onCheckLimits().catch(() => "Could not check usage limits."));
setChecking(false);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const action = workspaceLimit
? {
label: "Copy request for admin",
Expand Down Expand Up @@ -155,7 +167,7 @@ export function UsageLimitRecoveryCard({
? `Usage limit reached. ${resetText}`
: "Usage limit reached. The provider did not report a reset time. Retry manually when your limit is available."}
</Text>
{chatGptLimit || canSchedule ? (
{chatGptLimit || canSchedule || canCheck ? (
// The margins cancel the 44pt touch height so the links sit 8pt under the text.
<View className="-mb-3 -mt-2 flex-row flex-wrap gap-x-4">
{chatGptLimit ? (
Expand All @@ -166,6 +178,13 @@ export function UsageLimitRecoveryCard({
onPress={action.run}
/>
) : null}
{canCheck ? (
<RecoveryAction
label={checking ? "Checking..." : "Check limits"}
disabled={checking}
onPress={() => void checkLimits()}
/>
) : null}
{canSchedule ? (
<>
<RecoveryAction
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1402,6 +1402,7 @@ export default function ChatView(props: ChatViewProps) {
offered: usageLimitsOffered,
open: openUsageLimits,
clearFor: clearUsageLimitsFor,
checkLimits,
} = useUsageLimitsPanel({
environmentId,
threadKey: routeThreadKey,
Expand Down Expand Up @@ -2564,6 +2565,7 @@ export default function ChatView(props: ChatViewProps) {
recovery: activeThreadShell.limitRecovery ?? null,
snoozedUntil: activeThreadShell.snoozedUntil,
timestampFormat,
checkLimits,
onChange: async (limitRecovery) => {
const result = await updateThreadMetadata({
environmentId,
Expand Down
25 changes: 21 additions & 4 deletions apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
import { RunId } from "@t3tools/contracts";
import type { TimestampFormat } from "@t3tools/contracts/settings";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vite-plus/test";

import { usageLimitRecoveryBannerItem } from "./UsageLimitRecoveryBanner";

const stoppedAt = new Date(2026, 9, 20, 12, 0);

function bannerDescription(resetAt: Date, timestampFormat: TimestampFormat) {
function bannerItem(
resetAt: Date | null,
timestampFormat: TimestampFormat,
errorCode: string | null = null,
) {
return usageLimitRecoveryBannerItem({
runId: RunId.make("run-1"),
errorCode: null,
resetAt: resetAt.toISOString(),
errorCode,
resetAt: resetAt?.toISOString() ?? null,
stoppedAt: stoppedAt.toISOString(),
settled: false,
requestPending: false,
snoozedUntil: null,
recovery: null,
timestampFormat,
checkLimits: { pending: false, run: () => {} },
onChange: async () => {},
}).description;
});
}

function bannerDescription(resetAt: Date, timestampFormat: TimestampFormat) {
return bannerItem(resetAt, timestampFormat).description;
}

describe("usageLimitRecoveryBannerItem", () => {
Expand All @@ -36,4 +46,11 @@ describe("usageLimitRecoveryBannerItem", () => {
"Resets tomorrow at 17:30",
);
});

it("offers a limits check only when the reset time is unknown", () => {
const actions = (resetAt: Date | null) =>
renderToStaticMarkup(<>{bannerItem(resetAt, "24-hour").actions}</>);
expect(actions(null)).toContain("Check limits");
expect(actions(new Date(2026, 9, 20, 17, 30))).not.toContain("Check limits");
});
});
16 changes: 15 additions & 1 deletion apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type RecoveryProps = {
snoozedUntil: string | null;
recovery: OrchestrationV2LimitRecovery | null;
timestampFormat: TimestampFormat;
checkLimits: { readonly pending: boolean; readonly run: () => void } | null;
onChange: (recovery: OrchestrationV2LimitRecoveryUpdate) => Promise<void>;
};

Expand All @@ -45,6 +46,7 @@ export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBann
recovery,
snoozedUntil,
timestampFormat,
checkLimits,
} = props;
// The server refuses recovery changes while a request is pending.
const canSchedule =
Expand All @@ -71,7 +73,19 @@ export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBann
icon: <GaugeIcon />,
title: "Usage limit reached",
description: resetDescription,
actions: recoveryActions,
actions:
resetAt === null && checkLimits !== null ? (
<Button
size="xs"
variant="ghost"
disabled={checkLimits.pending}
onClick={checkLimits.run}
>
{checkLimits.pending ? "Checking..." : "Check limits"}
</Button>
) : (
recoveryActions
),
};
}
// OpenAI's partner guidelines specify this ChatGPT notice and its actions.
Expand Down
45 changes: 44 additions & 1 deletion apps/web/src/components/chat/useUsageLimitsPanel.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useAtomValue } from "@effect/atom-react";
import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage";
import type {
EnvironmentId,
ProviderInstanceId,
Expand All @@ -7,6 +9,9 @@ import type {
import { collectProviderUsageLimits, hasProviderUsageLimits } from "@t3tools/shared/usageLimits";
import { useState } from "react";
import { useStableCallback } from "../../hooks/useStableCallback";
import { appAtomRegistry } from "../../rpc/atomRegistry";
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { toastManager } from "../ui/toast";
import { usageLimitsBannerItem } from "./ComposerUsageLimits";

Expand Down Expand Up @@ -37,6 +42,11 @@ export function useUsageLimitsPanel(input: {
if (panel !== null && panelKey !== null && panel.key !== panelKey) {
setPanel(null);
}
const canCheck = useAtomValue(serverEnvironment.refreshProviders.permissionAtom(environmentId));
const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
reportFailure: false,
});
const [checkingKey, setCheckingKey] = useState<string | null>(null);
const report =
panel !== null && panelKey !== null && panel.key === panelKey && providerInstanceId !== null
? collectProviderUsageLimits(providerInstanceId, providers, sources, panel.now)
Expand Down Expand Up @@ -66,12 +76,45 @@ export function useUsageLimitsPanel(input: {
toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" });
return false;
});
// Runs after the refresh with this render's route and sources; the config stream lands first.
const finishCheck = useStableCallback(
(key: string, refreshed: readonly ServerProvider[] | null) => {
if (key !== panelKey || providerInstanceId === null) return;
if (refreshed === null) {
toastManager.add({ type: "error", title: "Could not check usage limits" });
return;
}
const latestSources =
appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId))?.usageLimitSources ??
sources;
const now = Date.now();
if (collectProviderUsageLimits(providerInstanceId, refreshed, latestSources, now)) {
setPanel({ key, threadKey, now });
return;
}
toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" });
},
);
const check = useStableCallback(async () => {
if (providerInstanceId === null || panelKey === null) return;
const key = panelKey;
setCheckingKey(key);
const result = await refreshUsageLimits(environmentId, () =>
refreshProviders({ environmentId, input: {} }),
);
setCheckingKey((current) => (current === key ? null : current));
finishCheck(key, result?._tag === "Success" ? result.value.providers : null);
});
// Async responses may clear only their originating thread's panel.
const clearFor = useStableCallback((originThreadKey: string) =>
setPanel((current) =>
current !== null && current.threadKey === originThreadKey ? null : current,
),
);

return { banner, offered, open, clearFor };
const checkLimits = canCheck
? { pending: checkingKey !== null && checkingKey === panelKey, run: check }
: null;

return { banner, offered, open, clearFor, checkLimits };
}
2 changes: 2 additions & 0 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ current model's limits without leaving the conversation. The result opens above
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**.

When a thread stops at a usage limit and the provider gave no reset time, choose **Check limits** on the notice above the composer. ChatGPT limit notices keep their own actions instead. It refreshes your providers and opens the same limits view, so you can see when a window resets.

To keep them in view on web and desktop, turn on **Usage limits** in **Settings → Appearance → Thread details**, or right-click the thread details card.
The bottom of the thread details card then shows how much is left in each limit window of the
current provider account. Some windows, such as a weekly Opus limit, count only one model. Hover a
Expand Down
Loading