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
19 changes: 10 additions & 9 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type SidebarProjectGroupingMode,
type SidebarThreadSortOrder,
} from "@t3tools/contracts";
import { exhaustedUntil } from "@t3tools/shared/usageLimits";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import { useFocusEffect } from "@react-navigation/native";
Expand Down Expand Up @@ -792,6 +793,13 @@ export function HomeScreen(props: HomeScreenProps) {
);
}
const thread = item.item.thread;
const provider = serverConfigs
.get(thread.environmentId)
?.providers.find(
(candidate) =>
candidate.instanceId ===
(thread.session?.providerInstanceId ?? thread.modelSelection.instanceId),
);
return (
<ThreadListV2Row
thread={thread}
Expand All @@ -807,15 +815,8 @@ export function HomeScreen(props: HomeScreenProps) {
projectTitle={v2ProjectTitleByProjectKey.get(
scopedProjectKey(thread.environmentId, thread.projectId),
)}
providerDriver={
serverConfigs
.get(thread.environmentId)
?.providers.find(
(provider) =>
provider.instanceId ===
(thread.session?.providerInstanceId ?? thread.modelSelection.instanceId),
)?.driver ?? null
}
providerDriver={provider?.driver ?? null}
limitsResetAt={exhaustedUntil(provider?.usageLimits, Date.now())}
environmentLabel={
Object.keys(props.savedConnectionsById).length > 1
? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
Expand Down
19 changes: 10 additions & 9 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { MenuAction } from "@react-native-menu/menu";
import { useAtomValue } from "@effect/atom-react";
import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import { exhaustedUntil } from "@t3tools/shared/usageLimits";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { LayoutChangeEvent } from "react-native";
import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
Expand Down Expand Up @@ -854,6 +855,13 @@ function ThreadNavigationSidebarPane(
case "v2-thread": {
const thread = item.item.thread;
const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId);
const provider = serverConfigs
.get(thread.environmentId)
?.providers.find(
(candidate) =>
candidate.instanceId ===
(thread.session?.providerInstanceId ?? thread.modelSelection.instanceId),
);
return (
<ThreadListV2Row
thread={thread}
Expand All @@ -864,15 +872,8 @@ function ThreadNavigationSidebarPane(
snoozeWakeLabelText={item.snoozeWakeLabelText}
project={projectByKey.get(scopeKey) ?? null}
projectTitle={projectTitleByProjectKey.get(scopeKey)}
providerDriver={
serverConfigs
.get(thread.environmentId)
?.providers.find(
(provider) =>
provider.instanceId ===
(thread.session?.providerInstanceId ?? thread.modelSelection.instanceId),
)?.driver ?? null
}
providerDriver={provider?.driver ?? null}
limitsResetAt={exhaustedUntil(provider?.usageLimits, Date.now())}
environmentLabel={
Object.keys(savedConnectionsById).length > 1
? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
Expand Down
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 @@ -332,6 +332,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly project: EnvironmentProject | null;
readonly projectTitle?: string;
readonly providerDriver: string | null;
/** Account-wide usage-limit reset for the thread's provider instance, or
null when it isn't exhausted. Feeds the "Until limits reset" snooze
preset so it leads the menu while the limit is in force. */
readonly limitsResetAt: string | null;
/** Which machine hosts the thread. Null when only one environment is
connected — repeating the same label on every row is noise. Mirrors
the web sidebar's remote-environment cloud icon, but as text since
Expand Down Expand Up @@ -473,8 +477,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
snoozed: snoozedRow,
});
const snoozePresets = useMemo(
() => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)),
[props.snoozePresetMinute, swipeActions.secondary],
() =>
swipeActions.secondary === "snooze"
? resolveSnoozePresets(new Date(), { limitsResetAt: props.limitsResetAt })
: ([] as const),
[props.snoozePresetMinute, props.limitsResetAt, swipeActions.secondary],
);
const snoozePresetActions = useMemo<MenuAction[]>(
() =>
Expand Down Expand Up @@ -587,6 +594,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
event: nativeEvent.event,
displayedPresets: snoozePresets,
now: new Date(),
limitsResetAt: props.limitsResetAt,
});
if (snoozeSelection._tag === "selected") {
handleSnooze(snoozeSelection.preset.snoozedUntil);
Expand All @@ -607,6 +615,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
handleUnsettle,
handleUnsnooze,
snoozePresets,
props.limitsResetAt,
],
);
const primaryAction = useMemo(() => {
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 @@ -106,6 +106,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 @@ -34,15 +34,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 @@ -28,7 +28,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 {
codexFeedbackMessage,
parseCodexFeedbackCommand,
Expand All @@ -49,6 +54,7 @@ import {
import { projectScriptCwd, projectScriptRuntimeEnv } 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 @@ -199,6 +205,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 @@ -1371,7 +1378,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 @@ -5150,6 +5157,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 @@ -5265,6 +5276,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()),
[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),
[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 @@ -5496,6 +5538,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 @@ -5615,10 +5716,12 @@ export default function ChatView(props: ChatViewProps) {
resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem];
const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];
const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
const usageLimitItems = usageLimitBannerItem === null ? [] : [usageLimitBannerItem];
if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
return [
...systemComposerBannerItems,
...backgroundLivenessItems,
...usageLimitItems,
...resumeCompactionItems,
...wokeThreadItems,
...parkedThreadItems,
Expand All @@ -5627,6 +5730,7 @@ export default function ChatView(props: ChatViewProps) {
return [
...systemComposerBannerItems,
...backgroundLivenessItems,
...usageLimitItems,
...resumeCompactionItems,
...wokeThreadItems,
{
Expand Down Expand Up @@ -5679,6 +5783,7 @@ export default function ChatView(props: ChatViewProps) {
resumeCompactionBannerItem,
showBranchMismatchBanner,
systemComposerBannerItems,
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
Loading
Loading