Skip to content
Merged
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
27 changes: 21 additions & 6 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
BACKGROUND_WORK_STOP_RETRY_AFTER_MS,
type BackgroundWorkStopRequest,
isBackgroundWorkStopPending,
limitRecoveryOwnsSnooze,
presentPendingBackgroundWork,
presentProviderGoal,
} from "@t3tools/client-runtime/state/thread-execution";
Expand Down Expand Up @@ -642,6 +643,19 @@ function ThreadDetailBody(
return () => clearTimeout(timer);
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- Re-arm if timer clamping fires before the wake time.
}, [snoozed, snoozedUntil, lifecycleNow]);
// The usage-limit recovery card shows this snooze and its Wake now.
const lifecycleSnoozed =
snoozed &&
!limitRecoveryOwnsSnooze({
runId:
props.selectedThread.runtime?.status === "failed" &&
props.selectedThread.runtime.lastErrorClass === "usage_limit"
? props.selectedThread.latestRun?.runId
: null,
resetAt: props.selectedThread.runtime?.usageLimitResetAt,
recovery: props.selectedThread.limitRecovery,
snoozedUntil,
});
const settled = configLoaded && props.selectedThread.settledOverride === "settled";
const wokeAt = configLoaded ? threadWokeAt(props.selectedThread, { now: lifecycleNow }) : null;
const woke = isThreadWokeUnseen({
Expand Down Expand Up @@ -809,30 +823,31 @@ function ThreadDetailBody(
},
};
}
if (contentPresentationKind === "ready" && (snoozed || settled || woke)) {
const state = snoozed ? "snoozed" : settled ? "settled" : "woke";
if (contentPresentationKind === "ready" && (lifecycleSnoozed || settled || woke)) {
const state = lifecycleSnoozed ? "snoozed" : settled ? "settled" : "woke";
const label =
snoozed && snoozedUntil !== null
lifecycleSnoozed && snoozedUntil !== null
? `Snoozed, wakes in ${snoozeWakeLabel(snoozedUntil, { now: lifecycleNow })}`
: settled
? "Settled"
: "Woke from snooze";
const actionLabel = snoozed ? "Wake now" : settled ? "Un-settle" : "Dismiss";
const actionLabel = lifecycleSnoozed ? "Wake now" : settled ? "Un-settle" : "Dismiss";
return {
kind: "lifecycle",
state,
label,
action: {
label: statusPending
? snoozed
? lifecycleSnoozed
? "Waking..."
: settled
? "Un-settling..."
: "Dismissing..."
: actionLabel,
accessibilityLabel: actionLabel,
disabled: !props.canOperateThread || statusPending,
onPress: () => void runStatusAction(snoozed ? "wake" : settled ? "unsettle" : "dismiss"),
onPress: () =>
void runStatusAction(lifecycleSnoozed ? "wake" : settled ? "unsettle" : "dismiss"),
},
};
}
Expand Down
145 changes: 93 additions & 52 deletions apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { Button } from "../../components/ui/Button";
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 {
CHATGPT_USAGE_LIMIT_CODE,
CHATGPT_USAGE_URL,
CHATGPT_WORKSPACE_ADMIN_REQUEST,
CHATGPT_WORKSPACE_USAGE_LIMIT_CODE,
} from "@t3tools/shared/usageLimits";
import * as DateTime from "effect/DateTime";
import * as Clipboard from "expo-clipboard";
import { useState } from "react";
import { Linking, Pressable, View } from "react-native";
Expand All @@ -31,13 +30,22 @@ export function UsageLimitRecoveryCard({
const [error, setError] = useState<string | null>(null);
const resetAt = thread.runtime?.usageLimitResetAt ?? null;
const canSchedule =
thread.settledOverride !== "settled" &&
resetAt !== null &&
Date.parse(resetAt) > Date.parse(thread.latestRun?.completedAt ?? thread.updatedAt);
const resetPassed = useDeadlinePassed(resetAt === null ? null : Date.parse(resetAt));
const runId = thread.latestRun?.runId;
const recovery = thread.limitRecovery;
const scheduled =
recovery?.runId === runId && recovery?.resetAt === resetAt && recovery?.autoResume;
const displayAt =
canSchedule &&
scheduled &&
resetAt &&
thread.snoozedUntil &&
Date.parse(thread.snoozedUntil) > Date.parse(resetAt)
? thread.snoozedUntil
: resetAt;
const [copied, setCopied] = useState(false);
const [actionPending, setActionPending] = useState(false);
const code = thread.runtime?.lastErrorCode;
Expand All @@ -51,16 +59,19 @@ export function UsageLimitRecoveryCard({
!runId
)
return null;
const resetText = resetAt
? `Usage limit resets ${DateTime.toDateUtc(DateTime.makeUnsafe(resetAt)).toLocaleString()}.`
const resetText = displayAt
? `${canSchedule && scheduled ? "Resumes" : "Resets"} ${new Date(displayAt).toLocaleString(
undefined,
{ month: "short", day: "numeric", hour: "numeric", minute: "2-digit" },
)}.`
: null;
const snoozed =
recovery?.snooze === true &&
recovery.runId === runId &&
recovery.resetAt === resetAt &&
resetAt !== null &&
thread.snoozedUntil !== null &&
Date.parse(thread.snoozedUntil) === Date.parse(resetAt);
limitRecoveryOwnsSnooze({
runId,
resetAt,
recovery,
snoozedUntil: thread.snoozedUntil,
}) && !resetPassed;
async function toggle(action: "resume" | "snooze") {
if (!resetAt || !runId || !canSchedule) return;
if (action === "snooze" && !snoozed && Date.parse(resetAt) <= Date.now()) {
Expand Down Expand Up @@ -123,50 +134,52 @@ export function UsageLimitRecoveryCard({
<ProviderIcon provider="codex" size={16} />
</View>
) : null}
<View className="min-w-0 flex-1 gap-2">
<View className="min-w-0 flex-1 gap-1">
{chatGptLimit ? (
<View className="gap-1">
<Text className="text-sm font-t3-medium text-foreground">
ChatGPT usage limit reached
</Text>
<Text className="text-sm text-foreground-muted">
{workspaceLimit
? copied
? "Request copied. Send it to your workspace admin."
: "You've reached your workspace's limit for using your ChatGPT plan in other apps. Copy a request to ask your workspace admin for increased usage limits."
: (resetText ?? "Review your usage settings in ChatGPT.")}
</Text>
<Pressable
accessibilityRole={action.role}
accessibilityState={{ disabled: actionPending }}
disabled={actionPending}
hitSlop={14}
className="mt-1 self-start active:opacity-70"
onPress={action.run}
>
<Text className="text-sm font-t3-medium text-primary">{action.label}</Text>
</Pressable>
</View>
) : (
<Text className="text-sm text-warning-foreground">
{resetText ??
"The provider did not report a reset time. Retry manually when your limit is available."}
<Text className="text-sm font-t3-medium text-foreground">
ChatGPT usage limit reached
</Text>
)}
{canSchedule ? (
<View className="flex-row flex-wrap gap-2">
<Button
label={scheduled ? "Cancel auto-resume" : "Resume at reset"}
tone="secondary"
disabled={pending}
onPress={() => void toggle("resume")}
/>
<Button
label={snoozed ? "Wake now" : "Snooze until reset"}
tone="secondary"
disabled={pending || (!snoozed && resetPassed)}
onPress={() => void toggle("snooze")}
/>
) : null}
<Text
className={
chatGptLimit ? "text-sm text-foreground-muted" : "text-sm text-warning-foreground"
}
>
{workspaceLimit
? copied
? "Request copied. Send it to your workspace admin."
: "You've reached your workspace's limit for using your ChatGPT plan in other apps. Copy a request to ask your workspace admin for increased usage limits."
: chatGptLimit
? (resetText ?? "Review your usage settings in ChatGPT.")
: resetText
? `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 ? (
// 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 ? (
<RecoveryAction
label={action.label}
role={action.role}
disabled={actionPending}
onPress={action.run}
/>
) : null}
{canSchedule ? (
<>
<RecoveryAction
label={scheduled ? "Cancel auto-resume" : "Resume at reset"}
disabled={pending}
onPress={() => void toggle("resume")}
/>
<RecoveryAction
label={snoozed ? "Wake now" : "Snooze until reset"}
disabled={pending || (!snoozed && resetPassed)}
onPress={() => void toggle("snooze")}
/>
</>
) : null}
</View>
) : null}
{error ? (
Expand All @@ -178,3 +191,31 @@ export function UsageLimitRecoveryCard({
</View>
);
}

function RecoveryAction(props: {
readonly label: string;
readonly role?: "button" | "link";
readonly disabled: boolean;
readonly onPress: () => void;
}) {
return (
<Pressable
accessibilityRole={props.role ?? "button"}
accessibilityState={{ disabled: props.disabled }}
disabled={props.disabled}
hitSlop={{ left: 8, right: 8 }}
onPress={props.onPress}
className="min-h-11 justify-center active:opacity-70"
>
<Text
className={
props.disabled
? "text-sm font-t3-medium text-foreground-muted"
: "text-sm font-t3-medium text-primary"
}
>
{props.label}
</Text>
</Pressable>
);
}
32 changes: 21 additions & 11 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import {
deriveThreadActivityRun,
deriveLatestThreadRun,
deriveThreadStructureRuntime,
limitRecoveryOwnsSnooze,
} from "@t3tools/client-runtime/state/thread-execution";
import {
resolveWorkflowOpening,
Expand Down Expand Up @@ -983,11 +984,19 @@ export default function ChatView(props: ChatViewProps) {
);
const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser;
const activeThreadShell = serverThread;
const limitRecoveryRun =
serverRuntime?.status === "failed" && serverRuntime.lastErrorClass === "usage_limit"
? (activeThreadShell?.latestRun ?? null)
: null;
// The recovery banner offers Wake now for a snooze it set.
const limitRecoveryHoldsSnooze = limitRecoveryOwnsSnooze({
runId: limitRecoveryRun?.runId,
resetAt: serverRuntime?.usageLimitResetAt,
recovery: activeThreadShell?.limitRecovery,
snoozedUntil: activeThreadShell?.snoozedUntil,
});
const timelineThreadError =
serverRuntime?.status === "failed" &&
serverRuntime.lastErrorClass === "usage_limit" &&
activeThreadShell?.latestRun &&
visibleThreadError === serverRuntime.lastError
limitRecoveryRun !== null && visibleThreadError === serverRuntime?.lastError
? null
: visibleThreadError;

Expand Down Expand Up @@ -2414,6 +2423,7 @@ export default function ChatView(props: ChatViewProps) {
activeThreadKey,
activeThreadShell,
activeLatestRun,
limitRecoveryHoldsSnooze,
activeProjectCwd,
serverConfig,
runtimeMode,
Expand Down Expand Up @@ -2542,14 +2552,14 @@ export default function ChatView(props: ChatViewProps) {
return item ? [item] : [];
});
const limitRecoveryBanner =
serverRuntime?.status === "failed" &&
serverRuntime.lastErrorClass === "usage_limit" &&
activeThreadShell?.latestRun
limitRecoveryRun !== null && activeThreadShell !== null
? usageLimitRecoveryBannerItem({
runId: activeThreadShell.latestRun.runId,
errorCode: serverRuntime.lastErrorCode ?? null,
resetAt: serverRuntime.usageLimitResetAt ?? null,
stoppedAt: activeThreadShell.latestRun.completedAt ?? activeThreadShell.updatedAt,
runId: limitRecoveryRun.runId,
errorCode: serverRuntime?.lastErrorCode ?? null,
resetAt: serverRuntime?.usageLimitResetAt ?? null,
stoppedAt: limitRecoveryRun.completedAt ?? activeThreadShell.updatedAt,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
settled: activeThreadShell.settledOverride === "settled",
requestPending: pendingApprovals.length > 0 || pendingUserInputs.length > 0,
recovery: activeThreadShell.limitRecovery ?? null,
snoozedUntil: activeThreadShell.snoozedUntil,
timestampFormat,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ function bannerDescription(resetAt: Date, timestampFormat: TimestampFormat) {
errorCode: null,
resetAt: resetAt.toISOString(),
stoppedAt: stoppedAt.toISOString(),
settled: false,
requestPending: false,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
snoozedUntil: null,
recovery: null,
timestampFormat,
Expand Down
Loading
Loading