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
9 changes: 7 additions & 2 deletions apps/mobile/src/components/ComposerEditor.ios.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const native = vi.hoisted(() => ({
text: "",
eventCount: 0,
resetGeneration: 0,
mounts: 0,
props: null as NativeProps | null,
deliver: null as ((event: NativeEvent) => void) | null,
}));
Expand All @@ -28,6 +29,7 @@ function NativeView(props: NativeProps) {
const propsRef = useRef(props);
useLayoutEffect(() => {
Object.assign(native, { text: "", eventCount: 0, resetGeneration: 0 });
native.mounts += 1;
}, []);
useLayoutEffect(() => {
propsRef.current = props;
Expand Down Expand Up @@ -173,13 +175,16 @@ it("keeps a pending event from the previous native editor out of the selected dr
);
await act(async () => renderDraft(first));
const deliverPendingEvent = native.deliver!;
const pending = { eventCount: native.eventCount + 1, resetGeneration: native.resetGeneration };
const mounts = native.mounts;
await act(async () => renderDraft(second));
// Remounting the native view would drop the keyboard mid-edit.
expect(native.mounts).toBe(mounts);
await act(async () =>
deliverPendingEvent({
value: "Draft A corrected",
selection: { start: 17, end: 17 },
eventCount: 1,
resetGeneration: 0,
...pending,
}),
);
expect(getComposerDraftSnapshot(second).text).toBe("Draft B");
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/components/ComposerEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export function ComposerEditor({
<>
{/* Reset revision history with the recreated iOS view without aborting context imports. */}
<NativeComposerEditor
key={`${hidden ? "hidden" : "visible"}:${draftKey ?? "no-draft"}`}
key={hidden ? "hidden" : "visible"}
{...props}
enterBehavior={props.enterBehavior ?? preferredEnterBehavior}
onChangeText={changeText}
Expand Down
69 changes: 0 additions & 69 deletions apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx

This file was deleted.

5 changes: 0 additions & 5 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { parseThreadKey, threadKey } from "@t3tools/client-runtime/state/entitie
import { createThreadVisitTracker } from "@t3tools/client-runtime/state/thread-visits";
import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities";
import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
import { ChatGptUsageLimitNotice } from "./ChatGptUsageLimitNotice";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import {
effectiveSnoozed,
Expand Down Expand Up @@ -1618,10 +1617,6 @@ function ThreadDetailBody(
thread={props.selectedThread}
environmentId={props.environmentId}
/>
<ChatGptUsageLimitNotice
key={`chatgpt-limit:${props.selectedThread.latestRun?.runId}`}
thread={props.selectedThread}
/>
<ChildThreadApprovals
threadRef={props.selectedThreadDetailRef}
maxHeight={pendingUserInputMaxHeight}
Expand Down
96 changes: 94 additions & 2 deletions apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentId } from "@t3tools/contracts";
import {
CHATGPT_USAGE_LIMIT_CODE,
CHATGPT_WORKSPACE_USAGE_LIMIT_CODE,
} from "@t3tools/shared/usageLimits";
import * as Clipboard from "expo-clipboard";
import type { ReactNode } from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
Expand All @@ -8,8 +13,16 @@ import { describe, expect, it, vi } from "vite-plus/test";
type ButtonProps = { label: string; disabled?: boolean; onPress: () => void };

vi.mock("react-native", () => ({
Linking: { openURL: vi.fn() },
Pressable: ({ children, disabled, onPress }: ButtonProps & { children: ReactNode }) => (
<button type="button" disabled={disabled} onClick={onPress}>
{children}
</button>
),
View: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("expo-clipboard", () => ({ setStringAsync: vi.fn() }));
vi.mock("../../components/ProviderIcon", () => ({ ProviderIcon: () => null }));
vi.mock("../../components/ui/AppText", () => ({
AppText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
}));
Expand All @@ -27,18 +40,97 @@ import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";

const DAY_MS = 24 * 60 * 60 * 1000;

function failedThread(resetAt: string): EnvironmentThreadShell {
function failedThread(
resetAt: string | null,
lastErrorCode?: string,
lastErrorClass = "usage_limit",
): 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 },
runtime: { status: "failed", lastErrorClass, lastErrorCode, usageLimitResetAt: resetAt },
} as unknown as EnvironmentThreadShell;
}

describe("UsageLimitRecoveryCard", () => {
// Older servers report ChatGPT sharing limits as provider errors and may store a reset.
it.each([
[CHATGPT_USAGE_LIMIT_CODE, "usage_limit", null, ["Manage usage"]],
[CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, "usage_limit", null, ["Copy request for admin"]],
[CHATGPT_USAGE_LIMIT_CODE, "provider_error", null, ["Manage usage"]],
[
CHATGPT_USAGE_LIMIT_CODE,
"usage_limit",
new Date(DAY_MS).toISOString(),
["Manage usage", "Resume at reset", "Snooze until reset"],
],
])("shows one ChatGPT notice for %s (%s, reset %s)", (code, errorClass, resetAt, actions) => {
const container = document.createElement("div");
const root = createRoot(container);
using _root = { [Symbol.dispose]: () => act(() => root.unmount()) };
act(() =>
root.render(
<UsageLimitRecoveryCard
thread={failedThread(resetAt, code, errorClass)}
environmentId={"env-1" as EnvironmentId}
/>,
),
);
expect(container.textContent).toContain("ChatGPT usage limit reached");
expect(container.textContent).not.toContain("did not report a reset time");
expect([...container.querySelectorAll("button")].map((button) => button.textContent)).toEqual(
actions,
);
});

it("drops a failed copy's error once a retry copies the request", async () => {
vi.mocked(Clipboard.setStringAsync)
.mockRejectedValueOnce(new Error("denied"))
.mockResolvedValueOnce(true);
const container = document.createElement("div");
const root = createRoot(container);
using _root = { [Symbol.dispose]: () => act(() => root.unmount()) };
act(() =>
root.render(
<UsageLimitRecoveryCard
thread={failedThread(null, CHATGPT_WORKSPACE_USAGE_LIMIT_CODE)}
environmentId={"env-1" as EnvironmentId}
/>,
),
);
const copy = () => container.querySelector("button")!.click();
await act(async () => copy());
expect(container.textContent).toContain("Could not copy the request");
await act(async () => copy());
expect(container.textContent).toContain("Request copied");
expect(container.textContent).not.toContain("Could not copy the request");
});

it("ignores a second copy while the first is still in flight", async () => {
const first = Promise.withResolvers<boolean>();
vi.mocked(Clipboard.setStringAsync).mockReset().mockReturnValueOnce(first.promise);
const container = document.createElement("div");
const root = createRoot(container);
using _root = { [Symbol.dispose]: () => act(() => root.unmount()) };
act(() =>
root.render(
<UsageLimitRecoveryCard
thread={failedThread(null, CHATGPT_WORKSPACE_USAGE_LIMIT_CODE)}
environmentId={"env-1" as EnvironmentId}
/>,
),
);
const copy = () => container.querySelector("button")!.click();
act(() => copy());
act(() => copy());
expect(Clipboard.setStringAsync).toHaveBeenCalledTimes(1);
await act(async () => first.resolve(true));
expect(container.textContent).toContain("Request copied");
});

it("disables Snooze once a reset more than 24.8 days away passes", () => {
using _timers = { [Symbol.dispose]: () => vi.useRealTimers() };
vi.useFakeTimers({ now: 0 });
Expand Down
Loading
Loading