Skip to content
Open
13 changes: 11 additions & 2 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSym
import { ProjectFavicon } from "../../components/ProjectFavicon";
import { ProviderInstanceIcon } from "../../components/ProviderIcon";
import type { ThreadRowProviderInstance } from "./thread-provider-instance";
import { exhaustedUntil } from "@t3tools/shared/usageLimits";
import { cn } from "../../lib/cn";
import { relativeTime } from "../../lib/time";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
Expand Down Expand Up @@ -494,9 +495,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
snoozable: canSnooze(thread, { now: new Date().toISOString() }),
snoozed: snoozedRow,
});
// Re-read on the parent minute tick with the presets, so the row's own
// "Until limits reset" offer expires with the limit.
const limitsResetAt = exhaustedUntil(props.providerInstance?.usageLimits, Date.now());
const snoozePresets = useMemo(
() => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)),
[props.snoozePresetMinute, swipeActions.secondary],
() =>
swipeActions.secondary === "snooze"
? resolveSnoozePresets(new Date(), { limitsResetAt })
: ([] as const),
[props.snoozePresetMinute, limitsResetAt, swipeActions.secondary],
);
const snoozePresetActions = useMemo<MenuAction[]>(
() =>
Expand Down Expand Up @@ -616,6 +623,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
event: nativeEvent.event,
displayedPresets: snoozePresets,
now: new Date(),
limitsResetAt,
});
if (snoozeSelection._tag === "selected") {
handleSnooze(snoozeSelection.preset.snoozedUntil);
Expand All @@ -638,6 +646,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
handleUnsettle,
handleUnsnooze,
snoozePresets,
limitsResetAt,
],
);
const primaryAction = useMemo(() => {
Expand Down
10 changes: 9 additions & 1 deletion apps/mobile/src/features/threads/thread-provider-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,21 @@ import {
resolveProviderInstanceDisplayName,
shouldShowInstanceBadge,
} from "@t3tools/client-runtime/state/provider-instance-display";
import type { EnvironmentId, ProviderDriverKind, ServerConfig } from "@t3tools/contracts";
import type {
EnvironmentId,
ProviderDriverKind,
ServerConfig,
ServerProvider,
} from "@t3tools/contracts";

/** What a thread row needs to draw the provider glyph and its account badge. */
export interface ThreadRowProviderInstance {
readonly driverKind: ProviderDriverKind;
readonly displayName: string;
readonly accentColor?: string | undefined;
readonly showBadge: boolean;
/** Feeds the row's "Until limits reset" snooze preset. */
readonly usageLimits: ServerProvider["usageLimits"];
}

/**
Expand All @@ -34,6 +41,7 @@ export function resolveThreadProviderInstance(
};
return {
...entry,
usageLimits: snapshot.usageLimits,
showBadge: shouldShowInstanceBadge(
entry,
providers.map((provider) => ({ driverKind: provider.driver })),
Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,24 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => {
);
}
});

it("resolves a snooze:limits-reset event when the option is set", () => {
const selectedAt = new Date(2026, 4, 8, 10);
const resetsAt = new Date(2026, 4, 8, 14).toISOString();
const displayedPresets = resolveSnoozePresets(selectedAt, { limitsResetAt: resetsAt });

const selection = resolveThreadListV2SnoozeMenuSelection({
event: "snooze:limits-reset",
displayedPresets,
now: selectedAt,
limitsResetAt: resetsAt,
});

expect(selection).toEqual({
_tag: "selected",
preset: displayedPresets.find((preset) => preset.id === "limits-reset"),
});
});
});

describe("resolveThreadListV2Enabled", () => {
Expand Down
7 changes: 4 additions & 3 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,16 @@ export function resolveThreadListV2SnoozeMenuSelection(input: {
readonly event: string;
readonly displayedPresets: ReadonlyArray<SnoozePreset>;
readonly now: Date;
readonly limitsResetAt?: string | null;
}):
| { readonly _tag: "selected"; readonly preset: SnoozePreset }
| { readonly _tag: "expired" }
| { readonly _tag: "not-snooze" } {
if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" };

const currentPreset = resolveSnoozePresets(input.now).find(
(candidate) => input.event === `snooze:${candidate.id}`,
);
const currentPreset = resolveSnoozePresets(input.now, {
limitsResetAt: input.limitsResetAt,
}).find((candidate) => input.event === `snooze:${candidate.id}`);
if (currentPreset) return { _tag: "selected", preset: currentPreset };

const displayedPreset = input.displayedPresets.find(
Expand Down
109 changes: 107 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ import {
import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
import {
canSnooze,
effectiveSnoozed,
threadWokeAt,
usageLimitSnoozePreset,
} from "@t3tools/client-runtime/state/thread-settled";
import {
parseCodexFeedbackCommand,
submitCodexFeedback,
Expand All @@ -63,6 +68,7 @@ import {
} from "@t3tools/shared/projectScripts";
import { truncate } from "@t3tools/shared/String";
import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
import { exhaustedUntil } from "@t3tools/shared/usageLimits";
import {
getTerminalLabel,
nextTerminalId,
Expand Down Expand Up @@ -212,6 +218,7 @@ import { cn, randomHex } from "~/lib/utils";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
import { type NewProjectScriptInput } from "./ProjectScriptsControl";
import { snoozeWakeDescription } from "./Sidebar.snooze";
import {
buildProjectScript,
commandForProjectScript,
Expand Down Expand Up @@ -1383,7 +1390,7 @@ export default function ChatView(props: ChatViewProps) {
const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null;
const threadDetailLoading = threadSyncPhase === "loading";
const handleNewThread = useNewThreadHandler();
const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions();
const { settleThread, pinThread, confirmAndUnpinThread, snoozeThread } = useThreadActions();
const routeThreadRef = useMemo(
() => scopeThreadRef(environmentId, threadId),
[environmentId, threadId],
Expand Down Expand Up @@ -5248,6 +5255,10 @@ export default function ChatView(props: ChatViewProps) {
const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true;
const activeThreadPinned = supportsPinning && activeThreadShell?.pinnedAt != null;
const nowMinute = useNowMinute();
// One quantized clock for the usage-limit UI, so its visibility rule, its
// label and its snooze target can never disagree within a minute.
const nowMinuteIso = `${nowMinute}:00.000Z`;
const nowMinuteDate = useMemo(() => new Date(nowMinuteIso), [nowMinuteIso]);
const snoozeNow = new Date().toISOString();
const activeThreadSnoozed =
activeThreadShell !== null &&
Expand Down Expand Up @@ -5363,6 +5374,37 @@ export default function ChatView(props: ChatViewProps) {
setUnsnoozingThreadKey((current) => (current === threadKey ? null : current));
}
}, [activeThreadRef, unsnoozeThreadMutation]);
// Read off the provider snapshot the Limits tab already draws from: the
// latest reset among the instance's exhausted windows, or null while it serves.
const usageLimitResetsAt = useMemo(
() => exhaustedUntil(conversationProviderStatus?.usageLimits, nowMinuteDate.getTime()),
Comment thread
vitalyiegorov marked this conversation as resolved.
[conversationProviderStatus?.usageLimits, nowMinuteDate],
);
// The same preset the snooze menus lead with, on the shared minute tick so
// the notice expires with it instead of needing a timer of its own.
const usageLimitPreset = useMemo(
() =>
usageLimitResetsAt === null || !supportsSnooze || activeThreadSnoozed
? null
: usageLimitSnoozePreset(usageLimitResetsAt, nowMinuteDate),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
[activeThreadSnoozed, nowMinuteDate, supportsSnooze, usageLimitResetsAt],
);
const handleSnoozeUntilUsageLimitReset = useCallback(async () => {
if (activeThreadRef === null || usageLimitPreset === null) return;
// No success toast: the parked-thread banner that replaces this notice
// already offers Wake now.
const result = await snoozeThread(activeThreadRef, usageLimitPreset.snoozedUntil);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to snooze thread",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
}
}, [activeThreadRef, snoozeThread, usageLimitPreset]);
const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false);
const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false);
// Once revealed for a given mismatch, the banner stays mounted until the
Expand Down Expand Up @@ -5594,6 +5636,65 @@ export default function ChatView(props: ChatViewProps) {
isUnsnoozing,
isUnsettling,
]);
// Session-scoped dismissals keyed per (thread, reset), so dismissing one
// limit does not hide the next one the provider reports.
const [dismissedUsageLimitKeys, setDismissedUsageLimitKeys] = useState<ReadonlySet<string>>(
new Set(),
);
const usageLimitKey =
activeThread && usageLimitResetsAt !== null ? `${activeThread.id}:${usageLimitResetsAt}` : null;
// Nothing auto-resumes on the reset — the offer just parks the thread out of
// the inbox until the provider is serving again. The reset time is worth
// showing even while snoozing is unavailable, so only the button is gated.
const usageLimitBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
if (
usageLimitPreset === null ||
usageLimitResetsAt === null ||
usageLimitKey === null ||
activeThreadShell === null ||
dismissedUsageLimitKeys.has(usageLimitKey)
) {
return null;
}
const snoozable = canSnooze(activeThreadShell, { now: nowMinuteIso });
const snoozeAction = (
<Button
size="xs"
variant="ghost"
disabled={!snoozable}
onClick={() => void handleSnoozeUntilUsageLimitReset()}
>
{`Snooze until ${snoozeWakeDescription(usageLimitPreset.snoozedUntil, nowMinuteDate, timestampFormat)}`}
</Button>
);
return {
id: `usage-limit:${usageLimitKey}`,
variant: "warning",
icon: <AlarmClockIcon />,
title: "Usage limit reached",
description: `Limits reset ${snoozeWakeDescription(usageLimitResetsAt, nowMinuteDate, timestampFormat)}`,
actions: snoozable ? (
snoozeAction
) : (
<Tooltip>
<TooltipTrigger render={<span className="inline-flex">{snoozeAction}</span>} />
<TooltipPopup side="top">Snoozing is unavailable while work is pending</TooltipPopup>
</Tooltip>
),
dismissLabel: "Dismiss usage limit notice",
onDismiss: () => setDismissedUsageLimitKeys((keys) => new Set(keys).add(usageLimitKey)),
};
}, [
activeThreadShell,
dismissedUsageLimitKeys,
handleSnoozeUntilUsageLimitReset,
nowMinuteDate,
nowMinuteIso,
timestampFormat,
usageLimitKey,
usageLimitPreset,
usageLimitResetsAt,
]);
// Session-scoped dismissals, one key per (thread, snapshot). A set rather
// than a single slot so dismissing the banner on one thread does not
// resurface it on another thread dismissed earlier.
Expand Down Expand Up @@ -5730,12 +5831,14 @@ export default function ChatView(props: ChatViewProps) {
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];
const usageLimitItems = usageLimitBannerItem === null ? [] : [usageLimitBannerItem];
if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
return [
...feedbackBannerItems,
...usageLimitsItems,
...systemComposerBannerItems,
...backgroundLivenessItems,
...usageLimitItems,
...resumeCompactionItems,
...wokeThreadItems,
...parkedThreadItems,
Expand All @@ -5746,6 +5849,7 @@ export default function ChatView(props: ChatViewProps) {
...usageLimitsItems,
...systemComposerBannerItems,
...backgroundLivenessItems,
...usageLimitItems,
...resumeCompactionItems,
...wokeThreadItems,
{
Expand Down Expand Up @@ -5800,6 +5904,7 @@ export default function ChatView(props: ChatViewProps) {
showBranchMismatchBanner,
systemComposerBannerItems,
usageLimitsBanner,
usageLimitBannerItem,
wokeThreadBannerItem,
]);
useEffect(() => {
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/components/Sidebar.snooze.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,35 @@ describe("resolveSnoozePresets", () => {
expect(twelveHour.find((preset) => preset.id === "evening")!.whenLabel).toMatch(/PM/i);
expect(twentyFourHour.find((preset) => preset.id === "evening")!.whenLabel).toBe("18:00");
});

it("prepends the limits-reset preset, time-only today and weekday-qualified otherwise", () => {
const now = localDate(2026, 4, 8, 10);
const sameDay = resolveSnoozePresets(now, "24-hour", {
limitsResetAt: localDate(2026, 4, 8, 18).toISOString(),
});
expect(sameDay[0]?.id).toBe("limits-reset");
expect(sameDay[0]?.whenLabel).toBe("18:01");

const laterWeek = resolveSnoozePresets(now, "24-hour", {
limitsResetAt: localDate(2026, 4, 13, 9).toISOString(),
});
expect(laterWeek[0]?.whenLabel).toMatch(/Mon/);
});

it("omits the limits-reset preset with no option or a past/malformed reset", () => {
const now = localDate(2026, 4, 8, 10);
expect(resolveSnoozePresets(now, "24-hour").some((p) => p.id === "limits-reset")).toBe(false);
expect(
resolveSnoozePresets(now, "24-hour", { limitsResetAt: null }).some(
(p) => p.id === "limits-reset",
),
).toBe(false);
expect(
resolveSnoozePresets(now, "24-hour", {
limitsResetAt: localDate(2026, 4, 8, 9).toISOString(),
}).some((p) => p.id === "limits-reset"),
).toBe(false);
});
});

describe("snoozeWakeDescription", () => {
Expand Down
24 changes: 22 additions & 2 deletions apps/web/src/components/Sidebar.snooze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,18 @@ function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string {
export function resolveSnoozePresets(
now: Date,
timestampFormat: TimestampFormat,
options?: { readonly limitsResetAt?: string | null },
): ReadonlyArray<SnoozePreset> {
return resolveSharedSnoozePresets(now).map((preset) => {
return resolveSharedSnoozePresets(now, options).map((preset) => {
const wake = parseTimestampDate(preset.snoozedUntil);
if (wake === null) return preset;
const time = timeOfDayLabel(wake, timestampFormat);
if (preset.id === "limits-reset") {
return {
...preset,
whenLabel: snoozeWakeDescription(preset.snoozedUntil, now, timestampFormat),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
};
}
return {
...preset,
whenLabel:
Expand All @@ -33,6 +40,15 @@ export function resolveSnoozePresets(
});
}

/**
* Menus resolve their presets when they open, so a `limits-reset` row left on
* screen past the reset would snooze into the past. Only that row can expire;
* the others are relative to the open time.
*/
export function snoozePresetExpired(preset: SnoozePreset, now = Date.now()): boolean {
return Date.parse(preset.snoozedUntil) <= now;
}

/**
* Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00",
* "17:30" (today).
Expand All @@ -45,9 +61,13 @@ export function snoozeWakeDescription(
const wake = parseTimestampDate(snoozedUntil);
if (wake === null) return "";
const time = timeOfDayLabel(wake, timestampFormat);
// Midnight to midnight, rounded: a DST day is 23 or 25 hours long, so a
// fixed 24-hour bucket would file a wake just past midnight on the wrong day.
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const dayDelta = Math.floor((wake.getTime() - startOfToday.getTime()) / DAY_MS);
const startOfWakeDay = new Date(wake);
startOfWakeDay.setHours(0, 0, 0, 0);
const dayDelta = Math.round((startOfWakeDay.getTime() - startOfToday.getTime()) / DAY_MS);
if (dayDelta === 0) return time;
if (dayDelta === 1) return `tomorrow ${time}`;
const weekday = wake.toLocaleDateString(undefined, { weekday: "short" });
Expand Down
Loading
Loading