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
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@ import { RegistryContext } from "@effect/atom-react";
import {
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
AuthSettingsWriteScope,
DEFAULT_SERVER_SETTINGS,
EnvironmentId,
ProjectId,
ProviderDriverKind,
ProviderInstanceId,
type AgentSessionProjectCandidate,
type AuthEnvironmentScope,
type AuthSessionState,
type ProviderAuthState,
type ProviderInstallState,
type ServerConfig,
type ServerProvider,
} from "@t3tools/contracts";
import { AsyncResult, Atom } from "effect/reactivity";
import { expect, it, vi } from "vite-plus/test";
Expand All @@ -27,10 +35,14 @@ 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: [] as AgentSessionProjectCandidate[],
createProject: vi.fn(),
importThreads: vi.fn(),
refreshProviders: vi.fn(),
updateSettings: vi.fn(),
startProviderAuth: vi.fn(),
connectPairing: vi.fn(),
completeOnboarding: vi.fn(),
onDone: vi.fn(),
Expand Down Expand Up @@ -82,6 +94,10 @@ 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,
updateSettings: "updateSettings" as never,
startProviderAuth: "startProviderAuth" as never,
},
};
});
Expand Down Expand Up @@ -123,7 +139,13 @@ vi.mock(import("../../connection/onboarding"), async (original) => ({
vi.mock(import("../../state/use-atom-command"), async (original) => ({
...(await original()),
useAtomCommand: ((
command: "createProject" | "importThreads" | "refreshProviders" | "connectPairing",
command:
| "createProject"
| "importThreads"
| "refreshProviders"
| "updateSettings"
| "startProviderAuth"
| "connectPairing",
) => state[command]) as never,
}));
vi.mock(import("../settings/ChatGptWelcomeCoordinator"), async (original) => ({
Expand Down Expand Up @@ -258,6 +280,83 @@ async function mountImport(remote = false) {
return screen;
}

const codexId = ProviderInstanceId.make("codex");

function authState(phase: ProviderAuthState["phase"]) {
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: null,
} as ProviderAuthState;
}

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

// The server replaces its settings with the patch, so later reads see the new setup mode.
async function applySettingsPatch({
input,
}: {
input: { patch: Partial<ServerConfig["settings"]> };
}) {
const config = appAtomRegistry.get(configAtom())!;
appAtomRegistry.set(configAtom(), {
...config,
settings: { ...config.settings, ...input.patch },
});
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 mountCodexAgents() {
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>>, [
{
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 },
},
]);
await using stack = new AsyncDisposableStack();
const screen = stack.use(
await render(
<RegistryContext.Provider value={appAtomRegistry}>
<WelcomeWizard localAvailable onDone={state.onDone} />
</RegistryContext.Provider>,
),
);
await click("Continue");
expect(pageText()).toContain("Connect your agents");
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 @@ -275,6 +374,21 @@ function setup() {
state.projects = Atom.make<ReadonlyArray<Project>>([]).pipe(Atom.keepAlive);
state.providers = Atom.make<ReadonlyArray<never>>([]).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 = [candidate("/projects/first"), candidate("/projects/second")];
state.createProject
.mockReset()
Expand All @@ -283,6 +397,12 @@ function setup() {
.mockReset()
.mockResolvedValue(AsyncResult.success({ importedCount: 1, skippedCount: 0 }));
state.refreshProviders.mockReset().mockResolvedValue(AsyncResult.success(undefined));
state.updateSettings.mockReset().mockImplementation(applySettingsPatch);
state.startProviderAuth.mockReset().mockImplementation(async () => {
const waiting = authState("waiting");
appAtomRegistry.set(state.authState as Atom.Writable<unknown>, AsyncResult.success(waiting));
return AsyncResult.success(waiting);
});
state.connectPairing.mockReset().mockResolvedValue(AsyncResult.success(remoteId));
state.completeOnboarding.mockReset().mockResolvedValue(undefined);
state.onDone.mockReset();
Expand Down Expand Up @@ -386,3 +506,49 @@ it("finishes setup without landing when the imported project never reaches the c
await vi.waitFor(() => expect(state.onDone).toHaveBeenCalledOnce());
expect(state.onDone).toHaveBeenCalledWith(undefined);
});

it("can switch Codex back to ChatGPT sign-in after choosing the existing CLI", async () => {
using _now = setup();
await using _screen = await mountCodexAgents();

await click("Use existing CLI");
await vi.waitFor(() => expect(codexSetupMode()).toBe("existing"));
await vi.waitFor(() =>
expect(
[...document.querySelectorAll("button")]
.map((element) => element.textContent?.trim())
.join(" | "),
"the signed-out Codex card must still offer ChatGPT sign-in",
).toContain("Continue with ChatGPT"),
);

await click("Continue with ChatGPT");
await vi.waitFor(() => expect(state.startProviderAuth).toHaveBeenCalledOnce());
expect(codexSetupMode()).toBe("managed");
});

it("keeps the Codex ChatGPT fallback disabled without permission to manage providers", async () => {
using _now = setup();
await using _screen = await mountCodexAgents();

await click("Use existing CLI");
await vi.waitFor(() => expect(codexSetupMode()).toBe("existing"));
await act(async () =>
appAtomRegistry.set(
sessionAtom(primaryId),
AsyncResult.success({
...session([AuthOrchestrationOperateScope]),
permissions: [
AuthOrchestrationReadScope,
AuthOrchestrationOperateScope,
AuthSettingsWriteScope,
],
}),
),
);
await vi.waitFor(() => expect(button("Continue with ChatGPT").disabled).toBe(true));

await act(async () => button("Continue with ChatGPT").click());
expect(state.startProviderAuth).not.toHaveBeenCalled();
expect(codexSetupMode()).toBe("existing");
});
49 changes: 43 additions & 6 deletions apps/web/src/components/onboarding/WelcomeWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "@t3tools/client-runtime/state/runtime";
import {
AgentSessionSource,
AuthProvidersManageScope,
AuthTerminalOperateScope,
CommandId,
defaultInstanceIdForDriver,
Expand All @@ -34,7 +35,7 @@ import {
MonitorIcon,
TerminalIcon,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { type ReactNode, useEffect, useRef, useState } from "react";

import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts";
import { useLocalStorage } from "../../hooks/useLocalStorage";
Expand Down Expand Up @@ -85,6 +86,7 @@ import { getProviderSummary } from "../settings/providerStatus";
import { providerClients } from "../settings/providerDriverMeta";
import { ChatGptWelcomeCoordinator } from "../settings/ChatGptWelcomeCoordinator";
import { AddManagedCodexAccountDialog, CodexSetupSection } from "../settings/CodexSetupSection";
import { ChatGptConnectionButton } from "../settings/ChatGptConnectionButton";
import { readCodexSetupMode } from "../settings/CodexSetupSection.logic";
import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic";
import { TerminalViewport } from "../ThreadTerminalDrawer";
Expand Down Expand Up @@ -906,7 +908,10 @@ function OnboardingCodexSetup({
readonly onAutoStartConsumed: () => void;
}) {
const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
const update = useAtomCommand(serverEnvironment.updateSettings, "Codex setup settings");
// Switching remounts the ChatGPT card, so the request to start sign-in lives here.
const [chatGptRequested, setChatGptRequested] = useState(false);
const instanceId =
createdAccount?.instanceId ??
provider?.instanceId ??
Expand Down Expand Up @@ -945,20 +950,49 @@ function OnboardingCodexSetup({
},
});
};
const existingState = getOnboardingProviderState(provider);
const existingSettled =
provider?.auth.status === "authenticated" ||
existingState === "checking" ||
((provider?.enabled ?? true) && existingState === "ready");
// A policy that owns Codex's launch settings also rules out ChatGPT's own runtime.
const chatGptAllowed =
managedInstanceLocks(
serverConfig.managedPolicy,
ProviderDriverKind.make("codex"),
)?.configKeys.has("setupMode") !== true;
return existingChosen ? (
<AgentCard
driver="codex"
provider={provider}
terminalOpen={terminalOpen}
terminalAvailable={canOperateTerminal}
onOpenTerminal={onOpenTerminal}
secondaryControl={
chatGptAllowed && !existingSettled ? (
<ChatGptConnectionButton
size="xs"
variant="ghost"
disabled={!canManageProviders}
onClick={() => {
setChatGptRequested(true);
changeMode("managed");
}}
>
Continue with ChatGPT
</ChatGptConnectionButton>
) : null
}
/>
) : (
<CodexSetupSection
presentation="onboarding"
autoStart={createdAccount?.autoStart === true}
autoStart={chatGptRequested || createdAccount?.autoStart === true}
displayName={createdAccount?.displayName}
onAutoStartConsumed={onAutoStartConsumed}
onAutoStartConsumed={() => {
setChatGptRequested(false);
onAutoStartConsumed();
}}
environmentId={environmentId}
instanceId={instanceId}
provider={provider}
Expand All @@ -975,12 +1009,14 @@ function AgentCard({
terminalOpen,
terminalAvailable,
onOpenTerminal,
secondaryControl,
}: {
readonly driver: OnboardingAgentDriver;
readonly provider: ServerProvider | undefined;
readonly terminalOpen: boolean;
readonly terminalAvailable: boolean;
readonly onOpenTerminal: () => void;
readonly secondaryControl?: ReactNode;
}) {
const meta = providerClients.get(ProviderDriverKind.make(driver));
const displayName =
Expand All @@ -989,20 +1025,21 @@ function AgentCard({
const providerState = getOnboardingProviderState(provider);

return (
<div className="flex items-center gap-3 rounded-lg border border-border bg-background px-4 py-4">
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-border bg-background px-4 py-4">
<ProviderInstanceIcon
driverKind={ProviderDriverKind.make(driver)}
displayName={displayName}
iconClassName="size-5"
/>
<div className="min-w-0 flex-1">
<div className="min-w-0 flex-1 basis-40">
<span className="block text-sm font-medium text-foreground">{displayName}</span>
<p className="mt-0.5 text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground">
{providerState === "ready" ? "Ready to code." : summary.headline}
{providerState !== "ready" && summary.detail ? ` · ${summary.detail}` : ""}
</p>
</div>
<div className="shrink-0">
<div className="ml-auto flex shrink-0 items-center gap-2">
{secondaryControl}
{providerState === "ready" ? (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-success-foreground">
<CheckIcon className="size-3.5" />
Expand Down
Loading