Skip to content
7 changes: 7 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1677,6 +1677,13 @@ function ThreadDetailBody(
key={props.selectedThread.latestRun?.runId}
thread={props.selectedThread}
environmentId={props.environmentId}
resetCredits={
props.serverConfig?.providers.find(
(provider) =>
provider.instanceId ===
props.selectedThread.runtime?.providerInstanceId,
)?.usageLimits?.resetCredits
}
/>
<ChildThreadApprovals
threadRef={props.selectedThreadDetailRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ vi.mock("../../components/ui/Button", () => ({
}));
vi.mock("../../state/threads", () => ({ threadEnvironment: { updateMetadata: {} } }));
vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
vi.mock("../usage/useResetCredit", () => ({ useResetCredit: vi.fn() }));

import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";

Expand Down
36 changes: 34 additions & 2 deletions apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"
import { AsyncResult } from "effect/reactivity";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { limitRecoveryOwnsSnooze } from "@t3tools/client-runtime/state/thread-execution";
import type { EnvironmentId } from "@t3tools/contracts";
import type {
EnvironmentId,
ProviderInstanceId,
ServerProviderResetCredits,
} from "@t3tools/contracts";
import {
CHATGPT_USAGE_LIMIT_CODE,
CHATGPT_USAGE_URL,
Expand All @@ -17,13 +21,17 @@ import { AppText as Text } from "../../components/ui/AppText";
import { useDeadlinePassed } from "../../lib/clock";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import { useResetCredit } from "../usage/useResetCredit";

export function UsageLimitRecoveryCard({
thread,
environmentId,
resetCredits,
}: {
thread: EnvironmentThreadShell;
environmentId: EnvironmentId;
/** The limited account's banked reset credits, when it reports any. */
resetCredits?: ServerProviderResetCredits | undefined;
}) {
const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata);
const [pending, setPending] = useState(false);
Expand All @@ -48,6 +56,7 @@ export function UsageLimitRecoveryCard({
: resetAt;
const [copied, setCopied] = useState(false);
const [actionPending, setActionPending] = useState(false);
const instanceId = thread.runtime?.providerInstanceId ?? null;
const code = thread.runtime?.lastErrorCode;
// OpenAI's partner guidelines specify the ChatGPT notice and its action.
const workspaceLimit = code === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE;
Expand All @@ -59,6 +68,7 @@ export function UsageLimitRecoveryCard({
!runId
)
return null;
const canReset = instanceId !== null && (resetCredits?.availableCount ?? 0) > 0;
const resetText = displayAt
? `${canSchedule && scheduled ? "Resumes" : "Resets"} ${new Date(displayAt).toLocaleString(
undefined,
Expand Down Expand Up @@ -155,9 +165,12 @@ 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 || canReset ? (
// 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">
{canReset && instanceId !== null ? (
<ResetUsageAction environmentId={environmentId} instanceId={instanceId} />
) : null}
{chatGptLimit ? (
<RecoveryAction
label={action.label}
Expand Down Expand Up @@ -192,6 +205,25 @@ export function UsageLimitRecoveryCard({
);
}

function ResetUsageAction(props: {
readonly environmentId: EnvironmentId;
readonly instanceId: ProviderInstanceId;
}) {
const { canManageProviders, busy, status, confirm } = useResetCredit(props.environmentId, {
instanceId: props.instanceId,
});
return (
<>
<RecoveryAction
label={busy ? "Resetting…" : "Reset usage"}
disabled={busy || !canManageProviders}
onPress={confirm}
/>
{status ? <Text className="basis-full text-sm text-foreground">{status}</Text> : null}
</>
);
}

function RecoveryAction(props: {
readonly label: string;
readonly role?: "button" | "link";
Expand Down
50 changes: 2 additions & 48 deletions apps/mobile/src/features/usage/UsageLimitsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import * as Dialog from "../../components/ui/Dialog";
import { RegistryContext, useAtomMount, useAtomValue } from "@effect/atom-react";
import { normalizeProviderAccentColor } from "@t3tools/client-runtime/state/provider-instance-display";
import {
AuthProvidersManageScope,
type EnvironmentId,
type ProviderConsumeResetCreditOutcome,
type ProviderConsumeResetCreditInput,
type ServerProvider,
type ServerProviderResetCredits,
Expand All @@ -25,9 +22,6 @@ import { Linking, Pressable, View } from "react-native";
import { AppText as Text } from "../../components/ui/AppText";
import { ProviderIcon } from "../../components/ProviderIcon";
import { environmentPresentations } from "../../state/presentation";
import { serverEnvironment } from "../../state/server";
import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
import { useAtomCommand } from "../../state/use-atom-command";
import { useNowMinuteMs } from "../../lib/clock";
import {
environmentIdsKey,
Expand All @@ -36,6 +30,7 @@ import {
refreshEnvironmentLimits,
} from "./usageLimitsRefresh";
import { usageProviderKind, useProviderColors } from "./usageProviders";
import { useResetCredit } from "./useResetCredit";

const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const;

Expand Down Expand Up @@ -220,13 +215,6 @@ export function AccountLimits(props: {
);
}

const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
reset: "Reset applied. Your windows have cleared.",
nothingToReset: "Nothing to reset right now.",
noCredit: "No reset credit left.",
alreadyRedeemed: "That credit was already redeemed.",
};

export function ResetCredits(props: {
readonly environmentId: EnvironmentId;
readonly input: ProviderConsumeResetCreditInput;
Expand All @@ -235,12 +223,7 @@ export function ResetCredits(props: {
readonly dense?: boolean;
}) {
const { environmentId, input, credits, now, dense = false } = props;
const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
const consume = useAtomCommand(serverEnvironment.consumeResetCredit, {
reportFailure: false,
});
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const { canManageProviders, busy, status, confirm } = useResetCredit(environmentId, input);
if (dense && credits.availableCount === 0 && status === null) return null;

const expiresIn = credits.nextExpiresAt
Expand All @@ -253,35 +236,6 @@ export function ResetCredits(props: {
expiresIn ? ` · next expires in ${expiresIn}` : ""
}`;

const redeem = async () => {
if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
setBusy(true);
setStatus(null);
const result = await consume({ environmentId, input });
setBusy(false);
if (result._tag === "Success") {
setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]);
return;
}
setStatus(
"error" in result.cause && result.cause.error instanceof Error
? result.cause.error.message
: "Could not use the reset credit.",
);
};

const confirm = () => {
if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
Dialog.alert(
"Use a reset credit?",
"This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{ text: "Use credit", onPress: () => void redeem() },
],
);
};

return (
<View className="flex-row flex-wrap items-center gap-x-3 gap-y-1">
<Text className="text-xs tabular-nums text-foreground-tertiary">{summary}</Text>
Expand Down
112 changes: 112 additions & 0 deletions apps/mobile/src/features/usage/useResetCredit.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { createRef, useImperativeHandle } from "react";
import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vite-plus/test";

const mocks = vi.hoisted(() => ({ consume: vi.fn(), canManage: vi.fn() }));
vi.mock("../../components/ui/Dialog", () => ({ alert: vi.fn() }));
vi.mock("../../state/server", () => ({ serverEnvironment: { consumeResetCredit: {} } }));
vi.mock("../../state/session", () => ({
readEnvironmentScope: mocks.canManage,
useEnvironmentScope: () => true,
}));
vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.consume }));

import * as Dialog from "../../components/ui/Dialog";
import { useResetCredit } from "./useResetCredit";

const environmentId = EnvironmentId.make("reset-environment");
const input = { instanceId: ProviderInstanceId.make("codex") };
const success = { _tag: "Success", value: { outcome: "reset" } } as const;
type Result = typeof success | { readonly _tag: "Failure"; readonly cause: { error: Error } };

function setup() {
mocks.consume.mockReset().mockResolvedValue(success);
mocks.canManage.mockReset().mockReturnValue(true);
vi.mocked(Dialog.alert).mockClear();
const root = createRoot(document.createElement("div"));
const ref = createRef<ReturnType<typeof useResetCredit>>();
function Probe() {
const value = useResetCredit(environmentId, input);
useImperativeHandle(ref, () => value);
return null;
}
act(() => root.render(<Probe />));
return {
get value() {
if (ref.current === null) throw new Error("Reset-credit hook was not mounted");
return ref.current;
},
[Symbol.dispose]: () => act(() => root.unmount()),
};
}

function approval() {
const action = vi
.mocked(Dialog.alert)
.mock.lastCall?.[2]?.find((button) => button.text === "Use credit")?.onPress;
if (action === undefined) throw new Error("Missing reset-credit confirmation");
return action;
}

it.each(["success", "failure", "rejection"] as const)(
"coalesces overlapping approvals and permits a new attempt after %s",
async (outcome) => {
using fixture = setup();
let resolve!: (value: Result) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<Result>((complete, fail) => {
resolve = complete;
reject = fail;
});
const pending = { promise, resolve, reject };
mocks.consume.mockReturnValueOnce(pending.promise);
fixture.value.confirm();
const first = approval();
fixture.value.confirm();
const second = approval();
act(() => {
first();
second();
});
expect(mocks.consume).toHaveBeenCalledTimes(1);
expect(mocks.consume).toHaveBeenCalledWith({ environmentId, input });
expect(fixture.value.busy).toBe(true);
const prompts = vi.mocked(Dialog.alert).mock.calls.length;
fixture.value.confirm();
expect(Dialog.alert).toHaveBeenCalledTimes(prompts);

await act(async () => {
if (outcome === "rejection") pending.reject(new Error("Disconnected"));
else if (outcome === "failure") {
pending.resolve({ _tag: "Failure", cause: { error: new Error("Denied") } });
} else pending.resolve(success);
await pending.promise.catch(() => undefined);
});
expect(fixture.value.busy).toBe(false);
expect(fixture.value.status).toBe(
outcome === "success"
? "Reset applied. Your windows have cleared."
: outcome === "failure"
? "Denied"
: "Could not use the reset credit.",
);
await act(async () => {
fixture.value.confirm();
approval()();
});
expect(mocks.consume).toHaveBeenCalledTimes(2);
expect(fixture.value.busy).toBe(false);
},
);

it("rechecks management permission after the confirmation opens", () => {
using fixture = setup();
fixture.value.confirm();
const confirm = approval();
mocks.canManage.mockReturnValue(false);
act(() => confirm());
expect(mocks.consume).not.toHaveBeenCalled();
expect(fixture.value.busy).toBe(false);
});
66 changes: 66 additions & 0 deletions apps/mobile/src/features/usage/useResetCredit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
AuthProvidersManageScope,
type EnvironmentId,
type ProviderConsumeResetCreditInput,
type ProviderConsumeResetCreditOutcome,
} from "@t3tools/contracts";
import { useRef, useState } from "react";

import * as Dialog from "../../components/ui/Dialog";
import { serverEnvironment } from "../../state/server";
import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
import { useAtomCommand } from "../../state/use-atom-command";

const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
reset: "Reset applied. Your windows have cleared.",
nothingToReset: "Nothing to reset right now.",
noCredit: "No reset credit left.",
alreadyRedeemed: "That credit was already redeemed.",
};

/** Spending a credit asks for confirmation first because it cannot be undone. */
export function useResetCredit(
environmentId: EnvironmentId,
input: ProviderConsumeResetCreditInput,
) {
const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
const consume = useAtomCommand(serverEnvironment.consumeResetCredit, {
reportFailure: false,
});
const inFlight = useRef(false);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<string | null>(null);

const redeem = async () => {
if (inFlight.current || !readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
inFlight.current = true;
setBusy(true);
setStatus(null);
const result = await consume({ environmentId, input }).catch(() => null);
inFlight.current = false;
setBusy(false);
if (result?._tag === "Success") {
setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]);
return;
}
setStatus(
result !== null && "error" in result.cause && result.cause.error instanceof Error
? result.cause.error.message
: "Could not use the reset credit.",
);
};

const confirm = () => {
if (inFlight.current || !readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
Dialog.alert(
"Use a reset credit?",
"This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{ text: "Use credit", onPress: () => void redeem() },
],
);
};

return { canManageProviders, busy, status, confirm };
}
1 change: 1 addition & 0 deletions apps/mobile/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const reactTests = [
"src/features/connection/LinkedEnvironmentsSection.test.tsx",
"src/features/devices/device-stream.browser.test.ts",
"src/features/keyboard/keyboardControllerState.test.tsx",
"src/features/usage/useResetCredit.test.tsx",
];

const sourceRoot = `${normalizePath(NodeURL.fileURLToPath(new URL("./src", import.meta.url)))}/`;
Expand Down
Loading
Loading