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
66 changes: 66 additions & 0 deletions apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentId } from "@t3tools/contracts";
import type { ReactNode } from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vite-plus/test";

type ButtonProps = { label: string; disabled?: boolean; onPress: () => void };

vi.mock("react-native", () => ({
View: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("../../components/ui/AppText", () => ({
AppText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
}));
vi.mock("../../components/ui/Button", () => ({
Button: ({ label, disabled, onPress }: ButtonProps) => (
<button type="button" disabled={disabled} onClick={onPress}>
{label}
</button>
),
}));
vi.mock("../../state/threads", () => ({ threadEnvironment: { updateMetadata: {} } }));
vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));

import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";

const DAY_MS = 24 * 60 * 60 * 1000;

function failedThread(resetAt: string): EnvironmentThreadShell {
return {
id: "thread-1",
updatedAt: new Date(0).toISOString(),
snoozedUntil: null,
latestRun: { runId: "run-1", completedAt: new Date(0).toISOString() },
limitRecovery: null,
runtime: { status: "failed", lastErrorClass: "usage_limit", usageLimitResetAt: resetAt },
} as unknown as EnvironmentThreadShell;
}

describe("UsageLimitRecoveryCard", () => {
it("disables Snooze once a reset more than 24.8 days away passes", () => {
using _timers = { [Symbol.dispose]: () => vi.useRealTimers() };
vi.useFakeTimers({ now: 0 });
const container = document.createElement("div");
const root = createRoot(container);
using _root = { [Symbol.dispose]: () => act(() => root.unmount()) };
act(() =>
root.render(
<UsageLimitRecoveryCard
thread={failedThread(new Date(30 * DAY_MS).toISOString())}
environmentId={"env-1" as EnvironmentId}
/>,
),
);
const snooze = () =>
[...container.querySelectorAll("button")].find(
(button) => button.textContent === "Snooze until reset",
);
expect(snooze()?.disabled).toBe(false);

act(() => vi.advanceTimersByTime(30 * DAY_MS));

expect(snooze()?.disabled).toBe(true);
});
});
21 changes: 3 additions & 18 deletions apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { AsyncResult } from "effect/reactivity";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentId } from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import { useEffect, useState } from "react";
import { useState } from "react";
import { View } from "react-native";
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";

Expand All @@ -24,23 +25,7 @@ export function UsageLimitRecoveryCard({
const canSchedule =
resetAt !== null &&
Date.parse(resetAt) > Date.parse(thread.latestRun?.completedAt ?? thread.updatedAt);
// Use timer-driven state; the compiler can cache render-time clock reads and leave Snooze enabled after reset.
const resetAtMs = resetAt === null ? null : Date.parse(resetAt);
const [resetPassedFor, setResetPassedFor] = useState<number | null>(null);
const resetPassed = resetAtMs !== null && resetPassedFor === resetAtMs;
useEffect(() => {
if (resetAtMs === null || Number.isNaN(resetAtMs)) return;
const flip = () => setResetPassedFor(resetAtMs);
const delay = resetAtMs - Date.now();
if (delay <= 0) {
flip();
return;
}
// Beyond setTimeout's 32-bit range the card re-renders long before then.
if (delay > 2_147_483_647) return;
const timer = setTimeout(flip, delay);
return () => clearTimeout(timer);
}, [resetAtMs]);
const resetPassed = useDeadlinePassed(resetAt === null ? null : Date.parse(resetAt));
const runId = thread.latestRun?.runId;
const recovery = thread.limitRecovery;
const scheduled =
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/lib/clock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { deadlineStore } from "@t3tools/client-runtime/clock";
import { useSyncExternalStore } from "react";

/** Re-renders once when `deadlineMs` (epoch ms) is reached; `null` never passes. */
export function useDeadlinePassed(deadlineMs: number | null): boolean {
const store = deadlineStore(deadlineMs);
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
}
24 changes: 15 additions & 9 deletions apps/mobile/src/lib/useDelayedStatus.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { createDelayedStatus, type ShownStatus } from "@t3tools/client-runtime/delayed-status";
import { useEffect, useState } from "react";
import {
delayedStatusDeadline,
shownDelayedStatus,
stepDelayedStatus,
} from "@t3tools/client-runtime/delayed-status";
import { useState } from "react";

import { useDeadlinePassed } from "./clock";

export function useDelayedStatus<A>(key: string, value: A | null): A | null {
const [shown, setShown] = useState<ShownStatus<A> | null>(null);
const [status] = useState(() => createDelayedStatus<A>(setShown));
useEffect(() => () => status.dispose(), [status]);
useEffect(() => {
status.update(key, value);
}, [status, key, value]);
return shown?.key === key ? shown.value : null;
const [state, setState] = useState(() =>
stepDelayedStatus<A>(null, { key, value, deadlinePassed: false }, Date.now),
);
const deadlinePassed = useDeadlinePassed(delayedStatusDeadline(state));
const next = stepDelayedStatus(state, { key, value, deadlinePassed }, Date.now);
if (next !== state) setState(next);
return shownDelayedStatus(next, key);
}
1 change: 1 addition & 0 deletions apps/mobile/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const reactTests = [
"src/components/ui/Dialog.android.test.tsx",
"src/features/settings/SettingsServerControlsRouteScreen.test.tsx",
"src/features/settings/components/BranchNamingSettings.test.tsx",
"src/features/threads/UsageLimitRecoveryCard.test.tsx",
"src/native/NativeHeaderToolbar.test.tsx",
"src/native/useRestingTopOffset.test.tsx",
"src/native/useRetryingImageSource.test.tsx",
Expand Down
16 changes: 6 additions & 10 deletions apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import {
type RunId,
} from "@t3tools/contracts";
import { type TimestampFormat } from "@t3tools/contracts/settings";
import { deadlineStore } from "@t3tools/client-runtime/clock";
import { GaugeIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useDeadlinePassed } from "../../hooks/useDeadline";
import { Button } from "../ui/button";
import { formatUpcomingTimestamp } from "../../timestampFormat";
import { toastManager } from "../ui/toast";
Expand Down Expand Up @@ -39,19 +41,12 @@ export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBann

function RecoveryActions({ runId, resetAt, recovery, snoozedUntil, onChange }: RecoveryProps) {
const [pending, setPending] = useState(false);
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const delay = Date.parse(resetAt ?? "") - Math.max(nowMs, Date.now());
if (!Number.isFinite(delay) || delay <= 0) return;
const timer = window.setTimeout(() => setNowMs(Date.now()), Math.min(delay + 1, 2_147_483_647));
return () => window.clearTimeout(timer);
}, [resetAt, nowMs]);

const scheduled =
recovery?.runId === runId && recovery.resetAt === resetAt && recovery.autoResume;
// The server leaves snoozedUntil in place after the timer wakes the thread,
// so a snooze whose reset has passed is no longer active.
const resetPassed = Date.parse(resetAt ?? "") <= nowMs;
const resetPassed = useDeadlinePassed(resetAt === null ? null : Date.parse(resetAt));
const snoozed =
recovery?.snooze === true &&
recovery.runId === runId &&
Expand All @@ -68,7 +63,8 @@ function RecoveryActions({ runId, resetAt, recovery, snoozedUntil, onChange }: R
title: "Could not snooze thread",
description: "The reset time has passed. Retry the thread manually.",
});
setNowMs(Date.now());
// The deadline timer can lag after a throttled tab or sleep; disable now.
deadlineStore(Date.parse(resetAt)).refresh();
return;
}
setPending(true);
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/hooks/useDeadline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { deadlineStore } from "@t3tools/client-runtime/clock";
import { useSyncExternalStore } from "react";

/** Re-renders once when `deadlineMs` (epoch ms) is reached; `null` never passes. */
export function useDeadlinePassed(deadlineMs: number | null): boolean {
const store = deadlineStore(deadlineMs);
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
}
24 changes: 15 additions & 9 deletions apps/web/src/hooks/useDelayedStatus.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { createDelayedStatus, type ShownStatus } from "@t3tools/client-runtime/delayed-status";
import { useEffect, useState } from "react";
import {
delayedStatusDeadline,
shownDelayedStatus,
stepDelayedStatus,
} from "@t3tools/client-runtime/delayed-status";
import { useState } from "react";

import { useDeadlinePassed } from "./useDeadline";

export function useDelayedStatus<A>(key: string, value: A | null): A | null {
const [shown, setShown] = useState<ShownStatus<A> | null>(null);
const [status] = useState(() => createDelayedStatus<A>(setShown));
useEffect(() => () => status.dispose(), [status]);
useEffect(() => {
status.update(key, value);
}, [status, key, value]);
return shown?.key === key ? shown.value : null;
const [state, setState] = useState(() =>
stepDelayedStatus<A>(null, { key, value, deadlinePassed: false }, Date.now),
);
const deadlinePassed = useDeadlinePassed(delayedStatusDeadline(state));
const next = stepDelayedStatus(state, { key, value, deadlinePassed }, Date.now);
if (next !== state) setState(next);
return shownDelayedStatus(next, key);
}
54 changes: 2 additions & 52 deletions apps/web/src/hooks/useNowMinute.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,6 @@
import { minuteTick } from "@t3tools/client-runtime/clock";
import { useSyncExternalStore } from "react";

function currentMinute(): string {
return new Date().toISOString().slice(0, 16);
}

let nowMinute = currentMinute();
let timerId: number | null = null;
let timerIsInterval = false;
const listeners = new Set<() => void>();

function tick(): void {
const next = currentMinute();
if (next !== nowMinute) {
nowMinute = next;
for (const listener of listeners) listener();
}
}

function startTimer(): void {
timerIsInterval = false;
timerId = window.setTimeout(
() => {
tick();
timerIsInterval = true;
timerId = window.setInterval(tick, 60_000);
},
60_000 - (Date.now() % 60_000),
);
}

function subscribe(listener: () => void): () => void {
if (listeners.size === 0) {
startTimer();
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0 && timerId !== null) {
if (timerIsInterval) window.clearInterval(timerId);
else window.clearTimeout(timerId);
timerId = null;
}
};
}

function getSnapshot(): string {
if (timerId === null) {
nowMinute = currentMinute();
}
return nowMinute;
}

export function useNowMinute(): string {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return useSyncExternalStore(minuteTick.subscribe, minuteTick.getSnapshot, minuteTick.getSnapshot);
}
41 changes: 2 additions & 39 deletions apps/web/src/hooks/useNowSecond.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,6 @@
import { secondTick } from "@t3tools/client-runtime/clock";
import { useSyncExternalStore } from "react";

// Share wall-clock ticks so duration labels repaint in one frame.

function currentSecond(): number {
return Math.floor(Date.now() / 1000) * 1000;
}

let nowSecond = currentSecond();
let timerId: number | null = null;
const listeners = new Set<() => void>();

function scheduleTick(): void {
timerId = window.setTimeout(
() => {
nowSecond = currentSecond();
for (const listener of listeners) listener();
scheduleTick();
},
1000 - (Date.now() % 1000),
);
}

function subscribe(listener: () => void): () => void {
if (listeners.size === 0) scheduleTick();
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0 && timerId !== null) {
window.clearTimeout(timerId);
timerId = null;
}
};
}

function getSnapshot(): number {
if (timerId === null) nowSecond = currentSecond();
return nowSecond;
}

export function useNowSecond(): number {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return useSyncExternalStore(secondTick.subscribe, secondTick.getSnapshot, secondTick.getSnapshot);
}
4 changes: 4 additions & 0 deletions packages/client-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@
"types": "./src/queuedRunEdit.ts",
"default": "./src/queuedRunEdit.ts"
},
"./clock": {
"types": "./src/clock.ts",
"default": "./src/clock.ts"
},
"./delayed-status": {
"types": "./src/delayedStatus.ts",
"default": "./src/delayedStatus.ts"
Expand Down
Loading
Loading