Skip to content

Commit 7873f53

Browse files
fix(web): the Add ChatGPT account dialog animates closed once the account connects (#25868)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1152e91 commit 7873f53

3 files changed

Lines changed: 143 additions & 13 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import {
2+
EnvironmentId,
3+
ProviderDriverKind,
4+
ProviderInstanceId,
5+
type ServerProvider,
6+
} from "@t3tools/contracts";
7+
import { Atom } from "effect/reactivity";
8+
import { useState } from "react";
9+
import { page, userEvent } from "vite-plus/test/browser";
10+
import { describe, expect, it, vi } from "vite-plus/test";
11+
12+
import { act, render } from "../../../test/render";
13+
import { AppAtomRegistryProvider, appAtomRegistry as registry } from "../../rpc/atomRegistry";
14+
15+
const setup = vi.hoisted(() => ({
16+
update: vi.fn(async () => ({ _tag: "Success" as const })),
17+
}));
18+
19+
const providersAtom = vi.hoisted(() => ({ current: null as unknown }));
20+
21+
vi.mock(import("../../state/server"), async (original) => {
22+
const actual = await original();
23+
return {
24+
...actual,
25+
serverEnvironment: {
26+
...actual.serverEnvironment,
27+
providersValueAtom: (() => providersAtom.current) as never,
28+
updateSettings: setup.update as never,
29+
},
30+
};
31+
});
32+
vi.mock(import("../../state/use-atom-command"), async (original) => ({
33+
...(await original()),
34+
useAtomCommand: ((command: unknown) => command) as never,
35+
}));
36+
vi.mock(import("../../state/session"), async (original) => ({
37+
...(await original()),
38+
useEnvironmentScope: () => true,
39+
readEnvironmentScope: () => true,
40+
}));
41+
vi.mock(import("../../hooks/useSettings"), async (original) => ({
42+
...(await original()),
43+
useEnvironmentSettings: (() => ({ providerInstances: {} })) as never,
44+
}));
45+
46+
import { AddCodexAccountDialog } from "./AddCodexAccountDialog";
47+
48+
const environmentId = EnvironmentId.make("local");
49+
50+
function codexProvider(
51+
instanceId: ProviderInstanceId,
52+
auth: ServerProvider["auth"],
53+
): ServerProvider {
54+
return {
55+
instanceId,
56+
driver: ProviderDriverKind.make("codex"),
57+
installed: true,
58+
enabled: true,
59+
version: "test-version",
60+
status: "ready",
61+
auth,
62+
checkedAt: "2026-09-02T00:00:00.000Z",
63+
models: [],
64+
skills: [],
65+
slashCommands: [],
66+
setup: { canAuthenticate: true, canInstall: false },
67+
};
68+
}
69+
70+
function createdInstanceId(): ProviderInstanceId {
71+
const [call] = setup.update.mock.calls as unknown as [
72+
[{ input: { patch: { providerInstances: Record<string, unknown> } } }],
73+
];
74+
return ProviderInstanceId.make(Object.keys(call[0].input.patch.providerInstances)[0]!);
75+
}
76+
77+
function Owner({ onClose }: { readonly onClose: () => void }) {
78+
const [shown, setShown] = useState(true);
79+
return shown ? (
80+
<AddCodexAccountDialog
81+
environmentId={environmentId}
82+
onClose={() => {
83+
onClose();
84+
setShown(false);
85+
}}
86+
renderSetup={() => <p>Sign in to ChatGPT</p>}
87+
/>
88+
) : null;
89+
}
90+
91+
describe("AddCodexAccountDialog", () => {
92+
it("plays its close animation before the owner unmounts it once the account connects", async () => {
93+
const atom = Atom.make<ReadonlyArray<ServerProvider> | null>([]).pipe(Atom.keepAlive);
94+
providersAtom.current = atom;
95+
const onClose = vi.fn();
96+
// A long exit transition that the test finishes by hand, so the close is
97+
// observable mid-animation without waiting on real time.
98+
const style = document.createElement("style");
99+
style.textContent = `[data-slot="dialog-popup"] { transition: opacity 600s; }
100+
[data-slot="dialog-popup"][data-ending-style] { opacity: 0; }`;
101+
document.head.append(style);
102+
using _style = { [Symbol.dispose]: () => style.remove() };
103+
await using _screen = await render(
104+
<AppAtomRegistryProvider>
105+
<Owner onClose={onClose} />
106+
</AppAtomRegistryProvider>,
107+
);
108+
109+
await userEvent.click(page.getByRole("button", { name: "Continue" }));
110+
const instanceId = createdInstanceId();
111+
await act(() => registry.set(atom, [codexProvider(instanceId, { status: "unauthenticated" })]));
112+
await expect.element(page.getByText("Sign in to ChatGPT")).toBeVisible();
113+
114+
await act(() =>
115+
registry.set(atom, [
116+
codexProvider(instanceId, { status: "authenticated", subscriptionSharing: true }),
117+
]),
118+
);
119+
// Closing the dialog starts its exit animation; unmounting now would cut it off.
120+
await vi.waitFor(() => expect(document.getAnimations()).not.toHaveLength(0));
121+
expect(onClose).not.toHaveBeenCalled();
122+
123+
await act(() => {
124+
for (const animation of document.getAnimations()) animation.finish();
125+
});
126+
await vi.waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
127+
// onClose runs before the owner's unmount commits, so wait for the dialog to leave the DOM.
128+
await expect.element(page.getByRole("dialog")).not.toBeInTheDocument();
129+
});
130+
});

‎apps/web/src/components/settings/AddCodexAccountDialog.tsx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
type EnvironmentId,
88
type ServerProvider,
99
} from "@t3tools/contracts";
10-
import { useEffect, useEffectEvent, useState, type ReactNode } from "react";
10+
import { useState, type ReactNode } from "react";
1111
import { usesChatGptSharing } from "@t3tools/shared/usageLimits";
1212

1313
import { useEnvironmentSettings } from "../../hooks/useSettings";
@@ -45,11 +45,9 @@ export function AddCodexAccountDialog({
4545
const [instanceId, setInstanceId] = useState<ProviderInstanceId | null>(null);
4646
const [pending, setPending] = useState(false);
4747
const provider = providers?.find((candidate) => candidate.instanceId === instanceId);
48+
// Closes itself once the new account connects; the owner unmounts it after
49+
// the close animation.
4850
const connected = usesChatGptSharing(provider);
49-
const closeAfterConnection = useEffectEvent(onClose);
50-
useEffect(() => {
51-
if (connected) closeAfterConnection();
52-
}, [connected]);
5351

5452
const createAccount = async () => {
5553
if (pending || !name.trim() || !readEnvironmentScope(environmentId, AuthProvidersManageScope))
@@ -89,6 +87,9 @@ export function AddCodexAccountDialog({
8987
onOpenChange={(open) => {
9088
if (!open) onClose();
9189
}}
90+
onOpenChangeComplete={(open) => {
91+
if (!open && connected) onClose();
92+
}}
9293
>
9394
<WizardPopup size="wide">
9495
<WizardHeader

‎apps/web/src/components/settings/ConnectionDiagnosisDetails.tsx‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ import {
1010
import * as Option from "effect/Option";
1111
import { AsyncResult } from "effect/reactivity";
1212
import { ChevronRightIcon } from "lucide-react";
13-
import { useEffect, useState } from "react";
1413

1514
import { APP_VERSION } from "~/branding";
1615
import { environmentCatalog } from "~/connection/catalog";
1716
import { type EnvironmentPresentation, useRelayEnvironmentDiscovery } from "~/state/environments";
1817
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
18+
import { useDeadlinePassed } from "../../hooks/useDeadline";
19+
import { useNowSecond } from "../../hooks/useNowSecond";
1920
import { cn } from "../../lib/utils";
2021
import { Button } from "../ui/button";
2122
import { stackedThreadToast, toastManager } from "../ui/toast";
@@ -64,13 +65,11 @@ const HOP_NOTE = { ok: "", failed: "Failed", unchecked: "Not checked" } as const
6465

6566
/** Ticks once per second, and only while a retry is scheduled, so the list around it does not re-render. */
6667
function RetryCountdown({ retryAt }: { readonly retryAt: number }) {
67-
const [now, setNow] = useState(Date.now);
68-
useEffect(() => {
69-
if (retryAt <= Date.now()) return;
70-
const timer = setInterval(() => setNow(Date.now()), 1_000);
71-
return () => clearInterval(timer);
72-
}, [retryAt]);
73-
const seconds = Math.ceil((retryAt - now) / 1_000);
68+
return useDeadlinePassed(retryAt) ? "retrying" : <RetryCountdownTicker retryAt={retryAt} />;
69+
}
70+
71+
function RetryCountdownTicker({ retryAt }: { readonly retryAt: number }) {
72+
const seconds = Math.ceil((retryAt - useNowSecond()) / 1_000);
7473
return <>{seconds > 0 ? `retrying in ${seconds}s` : "retrying"}</>;
7574
}
7675

0 commit comments

Comments
 (0)