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
200 changes: 198 additions & 2 deletions apps/web/src/components/onboarding/WelcomePage.import.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { RegistryContext } from "@effect/atom-react";
import * as Cause from "effect/Cause";
import {
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
AuthRelayReadScope,
AuthRelayWriteScope,
DEFAULT_SERVER_SETTINGS,
EnvironmentId,
ProjectId,
ProviderDriverKind,
Expand All @@ -12,6 +14,10 @@ import {
type AuthEnvironmentScope,
type AuthSessionState,
type EnvironmentCloudLinkStateResult,
type ProviderAuthState,
type ProviderInstallState,
type ProviderInstanceMutation,
type ServerConfig,
type ServerProvider,
} from "@t3tools/contracts";
import { AsyncResult, Atom } from "effect/reactivity";
Expand All @@ -31,10 +37,15 @@ const state = vi.hoisted(() => ({
projects: null as unknown,
providers: null as unknown,
config: null as unknown,
authState: null as unknown,
installState: null as unknown,
candidates: null as unknown,
createProject: vi.fn(),
importThreads: vi.fn(),
refreshProviders: vi.fn(),
mutateProviderInstance: vi.fn(),
startProviderAuth: vi.fn(),
cancelProviderAuth: vi.fn(),
completeOnboarding: vi.fn(),
onDone: vi.fn(),
scan: vi.fn(),
Expand Down Expand Up @@ -103,6 +114,11 @@ vi.mock(import("../../state/server"), async (original) => {
providersValueAtom: (() => state.providers) as never,
configValueAtom: (() => state.config) as never,
refreshProviders: "refreshProviders" as never,
providerAuthState: (() => state.authState) as never,
providerInstallState: (() => state.installState) as never,
mutateProviderInstance: "mutateProviderInstance" as never,
startProviderAuth: "startProviderAuth" as never,
cancelProviderAuth: "cancelProviderAuth" as never,
},
};
});
Expand Down Expand Up @@ -146,8 +162,15 @@ vi.mock(import("../../onboarding/useProjectScans"), async (original) => {
});
vi.mock(import("../../state/use-atom-command"), async (original) => ({
...(await original()),
useAtomCommand: ((command: "createProject" | "importThreads" | "refreshProviders") =>
state[command]) as never,
useAtomCommand: ((
command:
| "createProject"
| "importThreads"
| "refreshProviders"
| "mutateProviderInstance"
| "startProviderAuth"
| "cancelProviderAuth",
) => state[command]) as never,
}));

import { welcomeImportSelectionKey } from "../../onboarding/welcomeState";
Expand Down Expand Up @@ -284,6 +307,93 @@ async function mountWelcome() {
);
}

const codexId = ProviderInstanceId.make("codex");
const signedOutCodex: ServerProvider = {
instanceId: codexId,
driver: ProviderDriverKind.make("codex"),
installed: true,
enabled: true,
version: "0.163.0",
status: "error",
auth: { status: "unauthenticated" },
checkedAt: "2026-01-01T00:00:00.000Z",
models: [],
skills: [],
slashCommands: [],
setup: { canAuthenticate: true, canInstall: true },
};

function authState(phase: ProviderAuthState["phase"], message: string | null = null) {
const active = phase !== "idle";
return {
instanceId: codexId,
phase,
flowId: active ? "flow-1" : null,
authorizationUrl: active ? "https://auth.openai.com/oauth/authorize" : null,
expiresAt: null,
message,
} as ProviderAuthState;
}

function authStateAtom() {
return state.authState as Atom.Writable<AsyncResult.AsyncResult<ProviderAuthState>>;
}

function configAtom() {
return state.config as Atom.Writable<ServerConfig | null>;
}

function upsertInstance({
input,
}: {
input: { providerInstanceMutation: Extract<ProviderInstanceMutation, { operation: "upsert" }> };
}) {
const { instanceId, instance } = input.providerInstanceMutation;
const config = appAtomRegistry.get(configAtom())!;
appAtomRegistry.set(configAtom(), {
...config,
settings: {
...config.settings,
providerInstances: { ...config.settings.providerInstances, [instanceId]: instance },
},
});
return AsyncResult.success(undefined);
}

function codexSetupMode() {
const config = appAtomRegistry.get(configAtom())?.settings.providerInstances[codexId]?.config;
return config !== null && typeof config === "object" && "setupMode" in config
? config.setupMode
: undefined;
}

// A signed-out Codex CLI with no Codex entry in settings, as on a fresh install.
async function mountCodexSignIn() {
appAtomRegistry.set(configAtom(), {
settings: DEFAULT_SERVER_SETTINGS,
cwd: "/projects",
environment: { platform: { os: "linux" } },
keybindings: [],
} as unknown as ServerConfig);
appAtomRegistry.set(state.providers as Atom.Writable<ReadonlyArray<ServerProvider>>, [
readyClaude,
signedOutCodex,
]);
await using stack = new AsyncDisposableStack();
const screen = stack.use(await mountWelcome());
await vi.waitFor(() => button("Set up"));
await click("Set up");
await vi.waitFor(() => button("Continue with ChatGPT"));
expect(codexSetupMode()).toBeUndefined();
await click("Continue with ChatGPT");
await vi.waitFor(() => expect(state.startProviderAuth).toHaveBeenCalledOnce());
// The server only signs in to its own runtime, so the attempt switches to managed first.
expect(codexSetupMode()).toBe("managed");
await vi.waitFor(() => button("Cancel"));
stack.move();
return screen;
}

// The mocked modules read `state`, so every test starts by replacing all of it.
// Returns the clock spy so each test can `using` it; the spy would otherwise outlive the test.
function setup() {
Expand All @@ -300,6 +410,21 @@ function setup() {
state.projects = Atom.make<ReadonlyArray<Project>>([]).pipe(Atom.keepAlive);
state.providers = Atom.make<ReadonlyArray<ServerProvider>>([readyClaude]).pipe(Atom.keepAlive);
state.config = Atom.make(null).pipe(Atom.keepAlive);
state.authState = Atom.make(AsyncResult.success(authState("idle"))).pipe(Atom.keepAlive);
state.installState = Atom.make(
AsyncResult.success<ProviderInstallState>({
driver: ProviderDriverKind.make("codex"),
operationId: null,
phase: "idle",
downloadedBytes: 0,
totalBytes: null,
version: "0.163.0",
installedVersion: "0.163.0",
source: "managed",
canRemove: true,
message: null,
}),
).pipe(Atom.keepAlive);
state.candidates = Atom.make<ReadonlyArray<AgentSessionProjectCandidate>>([
candidate("/projects/first"),
candidate("/projects/second"),
Expand All @@ -311,6 +436,21 @@ function setup() {
.mockReset()
.mockResolvedValue(AsyncResult.success({ importedCount: 1, skippedCount: 0 }));
state.refreshProviders.mockReset().mockResolvedValue(AsyncResult.success(undefined));
state.mutateProviderInstance
.mockReset()
.mockImplementation(async (input) => upsertInstance(input));
state.startProviderAuth.mockReset().mockImplementation(async () => {
const waiting = authState("waiting", "Complete sign-in in your browser.");
appAtomRegistry.set(authStateAtom(), AsyncResult.success(waiting));
return AsyncResult.success(waiting);
});
state.cancelProviderAuth.mockReset().mockImplementation(async () => {
appAtomRegistry.set(
authStateAtom(),
AsyncResult.success(authState("cancelled", "Sign-in cancelled.")),
);
return AsyncResult.success(undefined);
});
state.completeOnboarding.mockReset().mockResolvedValue(undefined);
state.onDone.mockReset();
state.scan.mockReset();
Expand Down Expand Up @@ -739,3 +879,59 @@ it("says the phone is set up only once this computer is shared with the signed-i
await vi.waitFor(() => expect(pageText()).toContain("Your phone can reach this computer."));
expect(pageText()).not.toContain("Check T3 Connect");
});

it("restores the Codex setup mode when ChatGPT sign-in is cancelled", async () => {
using _now = setup();
await using _screen = await mountCodexSignIn();

await click("Cancel");
expect(state.cancelProviderAuth).toHaveBeenCalledOnce();
await vi.waitFor(() =>
expect(codexSetupMode(), "a cancelled sign-in must not leave Codex managed").toBeUndefined(),
);
});

it("restores the Codex setup mode onto current settings when ChatGPT sign-in fails", async () => {
using _now = setup();
await using _screen = await mountCodexSignIn();
// Another client edits Codex while sign-in is pending.
const config = appAtomRegistry.get(configAtom())!;
appAtomRegistry.set(configAtom(), {
...config,
settings: {
...config.settings,
providerInstances: {
...config.settings.providerInstances,
[codexId]: { ...config.settings.providerInstances[codexId]!, displayName: "Work" },
},
},
});

await act(async () =>
appAtomRegistry.set(
authStateAtom(),
AsyncResult.success(authState("failed", "ChatGPT sign-in was declined.")),
),
);
await vi.waitFor(() =>
expect(codexSetupMode(), "a failed sign-in must not leave Codex managed").toBeUndefined(),
);
expect(appAtomRegistry.get(configAtom())?.settings.providerInstances[codexId]?.displayName).toBe(
"Work",
);
await vi.waitFor(() => expect(pageText()).toContain("ChatGPT sign-in was declined."));
});

it("keeps the Codex setup mode managed when sign-in succeeds before Cancel reaches the server", async () => {
using _now = setup();
await using _screen = await mountCodexSignIn();
state.cancelProviderAuth.mockImplementationOnce(async () => {
appAtomRegistry.set(authStateAtom(), AsyncResult.success(authState("succeeded")));
return AsyncResult.failure(Cause.fail(new Error("This sign-in is no longer active.")));
});

await click("Cancel");
await vi.waitFor(() => expect(pageText()).toContain("This sign-in is no longer active."));
expect(state.mutateProviderInstance).toHaveBeenCalledOnce();
expect(codexSetupMode()).toBe("managed");
});
50 changes: 42 additions & 8 deletions apps/web/src/components/onboarding/WelcomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environmen
import { managedTelemetryOffMessage, useManagedPolicy } from "../../state/managedPolicy";
import { useProjectScans } from "../../onboarding/useProjectScans";
import { projectEnvironment } from "../../state/projects";
import { appAtomRegistry } from "../../rpc/atomRegistry";
import { serverEnvironment } from "../../state/server";
import {
readEnvironmentScope,
Expand Down Expand Up @@ -1060,30 +1061,59 @@ function OnboardingCodexSetup({
const config =
instance.config !== null && typeof instance.config === "object" ? instance.config : {};
const mode = readCodexSetupMode(config);
// The server only signs in to its own runtime, so ChatGPT sign-in saves "managed" first.
// An attempt that ends without signing in puts back what was saved before it.
const modeBeforeSignIn = useRef<{ readonly setupMode: unknown } | null>(null);
const reportSaveFailure = (result: Awaited<ReturnType<typeof persistProviderInstance>>) => {
if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
const cause = squashAtomCommandFailure(result);
toastManager.add({
type: "error",
title: "Could not save Codex setup",
...(cause instanceof Error ? { description: cause.message } : {}),
});
};
const changeMode = (setupMode: "managed" | "existing") => {
// "Use existing CLI" also opens a terminal to install or sign in to the CLI.
onUseCliChange(setupMode === "existing");
const unchanged = mode === setupMode && provider.enabled;
if (unchanged || !readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
modeBeforeSignIn.current =
setupMode === "managed" && mode !== "managed"
? { setupMode: "setupMode" in config ? config.setupMode : undefined }
: null;
// One-instance upsert keeps a provider another client added in the meantime.
void persistProviderInstance({
operation: "upsert",
instanceId,
instance: { ...instance, enabled: true, config: { ...config, enabled: true, setupMode } },
}).then((result) => {
if (result._tag === "Failure") {
modeBeforeSignIn.current = null;
onUseCliChange(false);
if (!isAtomCommandInterrupted(result)) {
const cause = squashAtomCommandFailure(result);
toastManager.add({
type: "error",
title: "Could not save Codex setup",
...(cause instanceof Error ? { description: cause.message } : {}),
});
}
reportSaveFailure(result);
}
});
};
const restoreMode = () => {
const before = modeBeforeSignIn.current;
if (before === null) return;
modeBeforeSignIn.current = null;
// Sign-in ends after later renders, so restore onto the settings as they are now.
const current =
appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId))?.settings
.providerInstances[instanceId] ?? instance;
const { setupMode: _managed, ...rest }: Record<string, unknown> =
current.config !== null && typeof current.config === "object" ? { ...current.config } : {};
void persistProviderInstance({
operation: "upsert",
instanceId,
instance: {
...current,
config: before.setupMode === undefined ? rest : { ...rest, setupMode: before.setupMode },
},
}).then(reportSaveFailure);
};
return (
<CodexSetupSection
presentation="onboarding"
Expand All @@ -1096,6 +1126,10 @@ function OnboardingCodexSetup({
existingCliDisabled={!canOperateTerminal}
displayName={agentName(provider)}
onModeChange={changeMode}
onSignInUnfinished={restoreMode}
onSignInFinished={() => {
modeBeforeSignIn.current = null;
}}
/>
);
}
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/components/settings/CodexSetupSection.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
settleAuthorizationPage,
settleHandoffAuthorizationPage,
settleProviderInstall,
settleSignIn,
} from "./CodexSetupSection.logic";

const install = (overrides: Partial<ProviderInstallState>) =>
Expand Down Expand Up @@ -119,3 +120,12 @@ describe("settleAuthorizationPage", () => {
expect(settleHandoffAuthorizationPage({ phase: "finished", profile: {} as never })).toBeNull();
});
});

describe("settleSignIn", () => {
it("ends only with its own flow", () => {
expect(settleSignIn(auth({ phase: "waiting" }), "flow-1")).toBeUndefined();
expect(settleSignIn(auth({ flowId: "flow-0", phase: "failed" }), "flow-1")).toBeUndefined();
const failed = auth({ phase: "failed" });
expect(settleSignIn(failed, "flow-1")).toBe(failed);
});
});
12 changes: 12 additions & 0 deletions apps/web/src/components/settings/CodexSetupSection.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ export function settleAuthorizationPage(
return url ?? undefined;
}

/** A sign-in flow's final state, `undefined` while it runs. `flowId` as above. */
export function settleSignIn(
state: ProviderAuthState | null,
flowId: string | null,
): ProviderAuthState | undefined {
if (state === null || state.flowId === null) return undefined;
if (flowId !== null && state.flowId !== flowId) return undefined;
return state.phase === "succeeded" || state.phase === "failed" || state.phase === "cancelled"
? state
: undefined;
}

export function settleHandoffAuthorizationPage(
state: ChatGptHandoffState | null,
): string | null | undefined {
Expand Down
Loading
Loading