1import { AuthProvidersManageScope } from "@t3tools/contracts";
2import { useEnvironmentScope } from "../../state/session";
3import {
4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,
6 type AtomCommandResult,
7} from "@t3tools/client-runtime/state/runtime";
8import type {
9 ChatGptHandoffInput,
10 ChatGptTransferredProfile,
11 EnvironmentId,
12 ProviderInstanceId,
13 ServerProvider,
14} from "@t3tools/contracts";
15import { codexAuthHandoffUrl } from "@t3tools/shared/codexAuthHandoff";
16import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl";
17import { isLoopbackHost } from "@t3tools/shared/preview";
18import { CheckIcon, ChevronRightIcon, ExternalLinkIcon } from "lucide-react";
19import { Children, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
20
21import { ensureLocalApi } from "../../localApi";
22import {
23 useEnvironmentHttpBaseUrl,
24 usePrimaryEnvironmentId,
25 useEnvironment,
26} from "../../state/environments";
27import { useEnvironmentQuery } from "../../state/query";
28import { serverEnvironment } from "../../state/server";
29import { useAtomCommand } from "../../state/use-atom-command";
30import { Button } from "../ui/button";
31import { ChatGptConnectionButton } from "./ChatGptConnectionButton";
32import { ChatGptUsageButton } from "./ChatGptUsageButton";
33import { ChatGptAccountPicker } from "./ChatGptAccountPicker";
34import { Input } from "../ui/input";
35import { OpenAI } from "../Icons";
36import { RedactedSensitiveText } from "./RedactedSensitiveText";
37import { SettingsRow } from "./settingsLayout";
38import { AddCodexAccountDialog } from "./AddCodexAccountDialog";
39import { getOnboardingProviderState } from "../../onboarding/providerReadiness.logic";
40import { getProviderSummary } from "./providerStatus";
41
42const noop = () => undefined;
43
44interface CodexSetupSectionProps {
45 readonly environmentId: EnvironmentId;
46 readonly instanceId: ProviderInstanceId;
47 readonly provider: ServerProvider | undefined;
48 readonly mode: "managed" | "existing";
49 readonly enabled: boolean;
50 readonly readOnly?: boolean;
51 readonly presentation?: "settings" | "onboarding";
52 readonly onModeChange: (mode: "managed" | "existing") => void;
53 readonly autoStart?: boolean;
54 readonly displayName?: string | undefined;
55 readonly onAutoStartConsumed?: () => void;
56 readonly onSignInCancelled?: (() => void) | undefined;
57}
58
59/** Welcome and provider settings run the same environment-owned setup flow. */
60export function CodexSetupSection(props: CodexSetupSectionProps) {
61 const [requested, setRequested] = useState(false);
62 const existingState = getOnboardingProviderState(props.provider);
63 const existingReady = props.enabled && existingState === "ready";
64 const existingAuthenticated = props.provider?.auth.status === "authenticated";
65 const existingChecking = existingState === "checking";
66 const existingSummary = getProviderSummary(props.provider);
67 const content =
68 props.mode === "existing" && props.presentation === "onboarding" ? (
69 <CodexWelcomeCard
70 title={props.displayName || props.provider?.displayName || "Codex"}
71 description={
72 existingReady ? (
73 props.provider?.auth.email?.trim() ? (
74 <>
75 Signed in as{" "}
76 <RedactedSensitiveText
77 value={props.provider.auth.email.trim()}
78 ariaLabel="Toggle account email visibility"
79 revealTooltip="Click to reveal email"
80 hideTooltip="Click to hide email"
81 className="break-all"
82 />
83 .
84 </>
85 ) : (
86 "Connected with your Codex CLI."
87 )
88 ) : existingChecking ? (
89 "Checking your Codex CLI..."
90 ) : props.provider?.installed ? (
91 existingSummary.headline
92 ) : (
93 "Code with your ChatGPT subscription."
94 )
95 }
96 control={
97 existingReady ? (
98 <span className="inline-flex items-center gap-1.5 text-xs font-medium text-success-foreground">
99 <CheckIcon className="size-3.5" />
100 Ready
101 </span>
102 ) : existingChecking ? (
103 <span className="text-xs text-muted-foreground">Checking...</span>
104 ) : existingAuthenticated ? (
105 <span className="text-xs text-muted-foreground">{existingSummary.headline}</span>
106 ) : (
107 <ChatGptConnectionButton
108 size="sm"
109 disabled={props.readOnly}
110 onClick={() => {
111 setRequested(true);
112 props.onModeChange("managed");
113 }}
114 >
115 Continue with ChatGPT
116 </ChatGptConnectionButton>
117 )
118 }
119 secondaryControl={
120 !existingAuthenticated && !existingReady && !existingChecking ? (
121 <Button size="sm" variant="ghost-muted" onClick={() => props.onModeChange("existing")}>
122 Use existing CLI
123 </Button>
124 ) : null
125 }
126 />
127 ) : props.mode === "existing" ? null : props.provider?.setup === undefined ? (
128 props.presentation === "onboarding" ? (
129 <CodexWelcomeCard
130 title={props.displayName || props.provider?.displayName || "Codex"}
131 description={<CodexSignInDescription />}
132 control={
133 <Button size="sm" variant="outline" className="min-w-44" disabled>
134 Open sign-in page
135 </Button>
136 }
137 secondaryControl={
138 <Button size="sm" variant="ghost-muted" disabled>
139 Cancel
140 </Button>
141 }
142 />
143 ) : (
144 <SettingsRow title="ChatGPT account" description="Preparing sign-in." />
145 )
146 ) : (
147 <ManagedCodexSetup
148 key={`${props.environmentId}:${props.instanceId}`}
149 {...props}
150 autoStart={requested || props.autoStart === true}
151 onAutoStartConsumed={() => {
152 setRequested(false);
153 props.onAutoStartConsumed?.();
154 }}
155 />
156 );
157 return content;
158}
159
160/** All add-Codex entry points use the same managed account setup. */
161export function AddManagedCodexAccountDialog({
162 environmentId,
163 onClose,
164 onAccountCreated,
165}: {
166 readonly environmentId: EnvironmentId;
167 readonly onClose: () => void;
168 readonly onAccountCreated?:
169 | ((instanceId: ProviderInstanceId, displayName: string) => void)
170 | undefined;
171}) {
172 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
173 return (
174 <AddCodexAccountDialog
175 environmentId={environmentId}
176 onClose={onClose}
177 onAccountCreated={onAccountCreated}
178 renderSetup={(instanceId, provider) => (
179 <ManagedCodexSetup
180 key={instanceId}
181 environmentId={environmentId}
182 instanceId={instanceId}
183 provider={provider}
184 mode="managed"
185 enabled
186 readOnly={!canManageProviders}
187 autoStart
188 onAutoStartConsumed={noop}
189 onModeChange={noop}
190 onSignInCancelled={onClose}
191 allowExistingCli={false}
192 />
193 )}
194 />
195 );
196}
197
198function ManagedCodexSetup({
199 environmentId,
200 instanceId,
201 provider,
202 enabled,
203 readOnly,
204 onModeChange,
205 autoStart,
206 onAutoStartConsumed,
207 allowExistingCli = true,
208 presentation,
209 displayName,
210 onSignInCancelled,
211}: CodexSetupSectionProps & {
212 readonly autoStart: boolean;
213 readonly onAutoStartConsumed: () => void;
214 readonly allowExistingCli?: boolean;
215}) {
216 const target = { environmentId, input: { instanceId } };
217 const authQuery = useEnvironmentQuery(serverEnvironment.providerAuthState(target));
218 const installQuery = useEnvironmentQuery(serverEnvironment.providerInstallState(target));
219 const [handoff, setHandoff] = useState<{
220 environmentId: EnvironmentId;
221 input: ChatGptHandoffInput;
222 } | null>(null);
223 const handoffQuery = useEnvironmentQuery(
224 handoff ? serverEnvironment.chatGptHandoffState(handoff) : null,
225 );
226 const auth = handoffQuery.data?.phase === "auth" ? handoffQuery.data.state : authQuery.data;
227 const [accountPickerOpen, setAccountPickerOpen] = useState(false);
228 const [requestedMethodId, setRequestedMethodId] = useState("chatgpt");
229 const reconnectEmail = auth?.methods?.find((method) => method.id === "chatgpt")?.accountEmail;
230 const requestedAccountEmail = auth?.methods?.find(
231 (method) => method.id === requestedMethodId,
232 )?.accountEmail;
233 const hasSavedAccount = auth?.methods?.some((method) => method.id.startsWith("chatgpt-profile:"));
234 const url = auth?.interaction?.type === "browser" ? auth.interaction.url : auth?.authorizationUrl;
235 const installation = installQuery.data;
236 const httpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
237 const local = httpBaseUrl !== null && isLoopbackHost(new URL(httpBaseUrl).hostname);
238 const options = { reportFailure: false, reportDefect: false };
239 const startAuth = useAtomCommand(serverEnvironment.startProviderAuth, options);
240 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, options);
241 const completeAuth = useAtomCommand(serverEnvironment.completeProviderAuth, options);
242 const cancelAuth = useAtomCommand(serverEnvironment.cancelProviderAuth, options);
243 const logoutAuth = useAtomCommand(serverEnvironment.logoutProviderAuth, options);
244 const startInstall = useAtomCommand(serverEnvironment.startProviderInstall, options);
245 const cancelInstall = useAtomCommand(serverEnvironment.cancelProviderInstall, options);
246 const reconnectProfile = useAtomCommand(serverEnvironment.chatGptReconnectProfile, options);
247 const importProfile = useAtomCommand(serverEnvironment.chatGptImportProfile, options);
248 const clientCallback =
249 !handoff && (!local || window.desktopBridge?.receiveProviderAuthCallback !== undefined);
250 const remoteWeb = clientCallback && !window.desktopBridge?.receiveProviderAuthCallback;
251 const primaryEnvironmentId = usePrimaryEnvironmentId();
252 const primaryEnvironment = useEnvironment(primaryEnvironmentId);
253 const primaryHttpBaseUrl = useEnvironmentHttpBaseUrl(primaryEnvironmentId);
254 const primaryAuthEnvironmentId =
255 !local &&
256 primaryEnvironmentId &&
257 primaryEnvironment?.connection.phase === "connected" &&
258 primaryHttpBaseUrl &&
259 isLoopbackHost(new URL(primaryHttpBaseUrl).hostname) &&
260 (window.desktopBridge !== undefined || isLoopbackHost(window.location.hostname))
261 ? primaryEnvironmentId
262 : null;
263 const returnUrl = new URL(window.location.href);
264 if (returnUrl.pathname === "/welcome") returnUrl.hash = `agents:${environmentId}`;
265 if (returnUrl.pathname === "/settings/providers")
266 returnUrl.searchParams.set("instanceId", instanceId);
267 const needsManualCallback = remoteWeb;
268 const callbackHelpId = useId();
269 const [callbackHelpOpen, setCallbackHelpOpen] = useState(false);
270 const [callbackDraft, setCallbackDraft] = useState({ flowId: "", value: "" });
271 const callbackUrl = callbackDraft.flowId === auth?.flowId ? callbackDraft.value : "";
272 const [pending, setPending] = useState(false);
273 const [awaitingProvider, setAwaitingProvider] = useState<"sign-in" | "handoff" | null>(null);
274 const pendingRef = useRef(false);
275 const [error, setError] = useState<string | null>(null);
276 const continueWithSignIn = useRef<string | null>(null);
277 const openRequested = useRef(false);
278 const openedFlow = useRef<string | null>(null);
279 const [autoStartHandled, setAutoStartHandled] = useState(false);
280 const installed = installation?.installedVersion != null;
281 const authenticated = provider?.auth.status === "authenticated";
282 const updateAvailable =
283 installed &&
284 installation?.source !== "local" &&
285 installation?.version != null &&
286 installation.version !== installation.installedVersion;
287 // Auth receipts and provider snapshots arrive independently. Keep the current
288 // attempt pending until its authenticated snapshot arrives, even after success.
289 const finishingSignIn =
290 awaitingProvider !== null &&
291 !authenticated &&
292 (auth?.phase === "succeeded" || awaitingProvider === "handoff");
293 useEffect(() => {
294 if (authenticated || auth?.phase === "failed" || auth?.phase === "cancelled") {
295 setAwaitingProvider(null);
296 }
297 }, [authenticated, auth?.phase]);
298 useEffect(() => {
299 if (awaitingProvider && auth?.phase === "succeeded") {
300 void refreshProviders({ environmentId, input: { instanceId } });
301 }
302 }, [awaitingProvider, auth?.phase, refreshProviders, environmentId, instanceId]);
303 const startingAutomatically = autoStart && !autoStartHandled;
304 const waitingForAuthState =
305 awaitingProvider === "sign-in" && !authenticated && (auth === null || auth.phase === "idle");
306 const authInProgress =
307 finishingSignIn ||
308 waitingForAuthState ||
309 handoff !== null ||
310 auth?.phase === "starting" ||
311 auth?.phase === "waiting" ||
312 auth?.phase === "verifying";
313 const installActive =
314 installation?.phase === "downloading" ||
315 installation?.phase === "extracting" ||
316 installation?.phase === "verifying";
317 const authActive = startingAutomatically || authInProgress || (pending && !installActive);
318 const unavailable =
319 readOnly || !enabled || pending || authQuery.error !== null || installQuery.error !== null;
320 const busy = pending || authInProgress || installActive;
321
322 const run = useCallback(
323 async <A, E>(
324 request: () => Promise<AtomCommandResult<A, E>>,
325 onSuccess?: (value: A) => void,
326 ) => {
327 if (pendingRef.current) return false;
328 pendingRef.current = true;
329 setPending(true);
330 setError(null);
331 let succeeded = false;
332 try {
333 const result = await request();
334 if (result._tag === "Failure") {
335 if (!isAtomCommandInterrupted(result)) {
336 const failure = squashAtomCommandFailure(result);
337 setError(failure instanceof Error ? failure.message : "Codex setup failed. Try again.");
338 }
339 } else {
340 succeeded = true;
341 onSuccess?.(result.value);
342 }
343 } catch {
344 setError("Codex setup failed. Try again.");
345 }
346 pendingRef.current = false;
347 setPending(false);
348 return succeeded;
349 },
350 [],
351 );
352
353 const handoffId = useId();
354 const handoffSequence = useRef(0);
355 const importedAttempt = useRef<string | null>(null);
356 const [transferFailed, setTransferFailed] = useState(false);
357 const transferProfile = useCallback(
358 async (profile: ChatGptTransferredProfile) => {
359 const succeeded = await run(() =>
360 importProfile({ environmentId, input: { instanceId, profile } }),
361 );
362 if (succeeded) {
363 setAwaitingProvider("handoff");
364 setHandoff(null);
365 }
366 setTransferFailed(!succeeded);
367 },
368 [run, importProfile, environmentId, instanceId],
369 );
370 useEffect(() => {
371 if (!handoff || handoffQuery.data?.phase !== "finished") return;
372 const attemptId = handoff.input.attemptId;
373 if (importedAttempt.current === attemptId) return;
374 importedAttempt.current = attemptId;
375 void transferProfile(handoffQuery.data.profile);
376 }, [handoff, handoffQuery.data, transferProfile]);
377 useEffect(() => {
378 if (!handoff) return;
379 if (
380 handoffQuery.error ||
381 (handoffQuery.data?.phase === "auth" &&
382 ["failed", "cancelled"].includes(handoffQuery.data.state.phase))
383 ) {
384 setError(
385 handoffQuery.data?.phase === "auth"
386 ? (handoffQuery.data.state.message ?? "ChatGPT sign-in could not finish. Try again.")
387 : "ChatGPT sign-in on the primary environment was interrupted. Try again.",
388 );
389 setHandoff(null);
390 }
391 }, [handoff, handoffQuery.data, handoffQuery.error]);
392 const cancelSignIn = useCallback(() => {
393 setAwaitingProvider(null);
394 if (handoff) {
395 setHandoff(null);
396 return Promise.resolve();
397 }
398 return run(() => cancelAuth({ environmentId, input: { instanceId, flowId: auth!.flowId! } }));
399 }, [handoff, run, cancelAuth, environmentId, instanceId, auth]);
400
401 const signIn = useCallback(
402 async (methodId = "chatgpt") => {
403 if (pendingRef.current) return;
404 openRequested.current = true;
405 setTransferFailed(false);
406 setRequestedMethodId(methodId);
407 const returnUrl = new URL(window.location.href);
408 if (returnUrl.pathname === "/welcome") returnUrl.hash = `agents:${environmentId}`;
409 if (returnUrl.pathname === "/settings/providers")
410 returnUrl.searchParams.set("instanceId", instanceId);
411 if (primaryAuthEnvironmentId) {
412 const attemptId = `${handoffId}:${++handoffSequence.current}`;
413 const succeeded = await run(
414 () => reconnectProfile({ environmentId, input: { instanceId, methodId } }),
415 (profile) =>
416 setHandoff({
417 environmentId: primaryAuthEnvironmentId,
418 input: {
419 instanceId,
420 environmentId,
421 attemptId,
422 returnUrl: returnUrl.toString(),
423 profile,
424 },
425 }),
426 );
427 if (!succeeded) openRequested.current = false;
428 return;
429 }
430 if (
431 !(await run(
432 () =>
433 startAuth({
434 environmentId,
435 input: {
436 instanceId,
437 methodId,
438 returnUrl: returnUrl.toString(),
439 callbackMode: clientCallback ? "client" : "server",
440 },
441 }),
442 () => setAwaitingProvider("sign-in"),
443 ))
444 ) {
445 openRequested.current = false;
446 }
447 },
448 [
449 environmentId,
450 instanceId,
451 run,
452 startAuth,
453 clientCallback,
454 primaryAuthEnvironmentId,
455 reconnectProfile,
456 handoffId,
457 ],
458 );
459
460 const setup = useCallback(
461 async (methodId = "chatgpt") => {
462 if (unavailable || busy) return;
463 if (installed && !updateAvailable) {
464 await signIn(methodId);
465 } else {
466 continueWithSignIn.current = methodId;
467 if (!(await run(() => startInstall({ environmentId, input: { instanceId } })))) {
468 continueWithSignIn.current = null;
469 }
470 }
471 },
472 [
473 unavailable,
474 busy,
475 installed,
476 updateAvailable,
477 signIn,
478 run,
479 startInstall,
480 environmentId,
481 instanceId,
482 ],
483 );
484
485 useEffect(() => {
486 if (
487 !autoStart ||
488 autoStartHandled ||
489 unavailable ||
490 busy ||
491 installation === null ||
492 !provider?.setup?.canInstall
493 )
494 return;
495 setAutoStartHandled(true);
496 onAutoStartConsumed();
497 void setup();
498 }, [
499 autoStart,
500 autoStartHandled,
501 unavailable,
502 busy,
503 installation,
504 provider?.setup?.canInstall,
505 onAutoStartConsumed,
506 setup,
507 ]);
508
509 useEffect(() => {
510 if (!continueWithSignIn.current || pending || installActive) return;
511 if (installation?.phase === "failed" || installation?.phase === "cancelled") {
512 continueWithSignIn.current = null;
513 } else if (installed) {
514 const methodId = continueWithSignIn.current;
515 continueWithSignIn.current = null;
516 void signIn(methodId);
517 }
518 }, [pending, installActive, installation?.phase, installed, signIn]);
519
520 const receivingCallback = useRef<string | null>(null);
521 const flowId = auth?.flowId;
522 const openPage = useCallback(
523 async (authorizationUrl: string) => {
524 try {
525 const receive = window.desktopBridge?.receiveProviderAuthCallback;
526 if (receive && clientCallback && flowId) {
527 if (receivingCallback.current === authorizationUrl) {
528 await ensureLocalApi().shell.openExternal(authorizationUrl);
529 return;
530 }
531 receivingCallback.current = authorizationUrl;
532 const callbackUrl = await receive(authorizationUrl);
533 if (receivingCallback.current !== authorizationUrl) return;
534 receivingCallback.current = null;
535 await run(() =>
536 completeAuth({ environmentId, input: { instanceId, flowId, callbackUrl } }),
537 );
538 } else {
539 await ensureLocalApi().shell.openExternal(authorizationUrl);
540 }
541 } catch {
542 setError(
543 "Could not finish sign-in on this computer. Try again or paste the redirect URL below.",
544 );
545 }
546 },
547 [clientCallback, flowId, run, completeAuth, environmentId, instanceId],
548 );
549
550 useEffect(() => {
551 if (!clientCallback || !url || !window.desktopBridge?.cancelProviderAuthCallback) return;
552 return () => {
553 if (receivingCallback.current === url) receivingCallback.current = null;
554 void window.desktopBridge?.cancelProviderAuthCallback?.(url).catch(() => undefined);
555 };
556 }, [clientCallback, url]);
557
558 useEffect(() => {
559 if (
560 !openRequested.current ||
561 auth?.phase !== "waiting" ||
562 !auth.flowId ||
563 !url ||
564 openedFlow.current === auth.flowId
565 )
566 return;
567 openedFlow.current = auth.flowId;
568 openRequested.current = false;
569 if (!remoteWeb) void openPage(url);
570 }, [auth?.phase, auth?.flowId, url, openPage, remoteWeb]);
571
572 const runtimeDescription =
573 installation?.phase === "downloading"
574 ? `Downloading ${(installation.downloadedBytes / 1_000_000).toFixed(1)}${installation.totalBytes === null ? "" : ` of ${(installation.totalBytes / 1_000_000).toFixed(1)}`} MB.`
575 : installation?.phase === "extracting"
576 ? "Installing Codex."
577 : installation?.phase === "verifying"
578 ? "Checking Codex."
579 : installed
580 ? `${installation?.source === "local" ? "Using your installed Codex" : "Managed by The Knife"}${installation?.installedVersion ? ` · v${installation.installedVersion}` : ""}.`
581 : (installation?.message ?? "The Knife downloads and manages Codex for you.");
582 const accountDescription = finishingSignIn ? (
583 "Finishing sign-in..."
584 ) : installActive ? (
585 runtimeDescription
586 ) : authActive || auth?.phase === "failed" || auth?.phase === "cancelled" ? (
587 auth?.phase === "waiting" && requestedAccountEmail ? (
588 `Continue as ${requestedAccountEmail} on OpenAI.`
589 ) : (
590 (auth?.message ?? "Finish signing in in your browser.")
591 )
592 ) : authenticated ? (
593 provider?.auth.email?.trim() ? (
594 <>
595 Signed in as{" "}
596 <RedactedSensitiveText
597 value={provider.auth.email.trim()}
598 ariaLabel="Toggle account email visibility"
599 revealTooltip="Click to reveal email"
600 hideTooltip="Click to hide email"
601 />
602 .
603 </>
604 ) : (
605 "Signed in with ChatGPT."
606 )
607 ) : (
608 (reconnectEmail ?? "Use your ChatGPT subscription.")
609 );
610
611 const handoffUrl =
612 needsManualCallback && url && auth?.flowId && providerAuthReturnUrl(returnUrl.toString())
613 ? codexAuthHandoffUrl(
614 {
615 authorizationUrl: url,
616 returnUrl: returnUrl.toString(),
617 environmentId,
618 instanceId,
619 flowId: auth.flowId,
620 },
621 import.meta.env.DEV,
622 )
623 : null;
624 const waitingControl = (
625 <Button
626 size="sm"
627 variant={remoteWeb ? "default" : "outline"}
628 className={presentation === "onboarding" ? "min-w-44" : undefined}
629 disabled={
630 handoffQuery.data?.phase === "finished"
631 ? pending || !transferFailed
632 : !url || auth?.phase !== "waiting"
633 }
634 onClick={() => {
635 if (handoffQuery.data?.phase === "finished")
636 void transferProfile(handoffQuery.data.profile);
637 else if (url) void openPage(url);
638 }}
639 >
640 {finishingSignIn
641 ? "Finishing sign-in..."
642 : handoffQuery.data?.phase === "finished"
643 ? transferFailed
644 ? "Retry connection"
645 : "Finishing sign-in..."
646 : auth?.phase === "waiting"
647 ? remoteWeb
648 ? "Open ChatGPT sign-in"
649 : "Open sign-in page"
650 : presentation === "onboarding"
651 ? "Open sign-in page"
652 : "Signing in..."}
653 </Button>
654 );
655 const callbackCompletion =
656 !handoff && auth?.phase === "waiting" && url ? (
657 <div className="flex w-full flex-col gap-3 text-xs leading-relaxed text-muted-foreground">
658 <p>If sign-in doesn't return to The Knife, paste the URL from the final localhost page.</p>
659 <form
660 className="flex flex-col gap-2 sm:flex-row sm:items-center"
661 onSubmit={(event) => {
662 event.preventDefault();
663 if (!auth.flowId || !callbackUrl.trim()) return;
664 const value = callbackUrl.trim();
665 const submittedFlowId = auth.flowId;
666 void run(() =>
667 completeAuth({
668 environmentId,
669 input: { instanceId, flowId: submittedFlowId, callbackUrl: value },
670 }),
671 ).then((connected) => {
672 if (connected) setCallbackDraft({ flowId: submittedFlowId, value: "" });
673 });
674 }}
675 >
676 <div className="min-w-0 flex-1">
677 <Input
678 aria-label="ChatGPT sign-in redirect URL"
679 type="password"
680 autoComplete="off"
681 placeholder="Paste the URL from the sign-in tab"
682 value={callbackUrl}
683 maxLength={16_384}
684 disabled={pending || readOnly}
685 onChange={(event) =>
686 setCallbackDraft({ flowId: auth.flowId ?? "", value: event.target.value })
687 }
688 />
689 </div>
690 <Button
691 variant="outline"
692 type="submit"
693 disabled={pending || readOnly || !callbackUrl.trim()}
694 >
695 Connect
696 </Button>
697 </form>
698 {!remoteWeb ? (
699 <div>
700 <Button
701 size="xs"
702 variant="ghost-muted"
703 onClick={() => void ensureLocalApi().shell.openExternal(url)}
704 >
705 Try sign-in in your browser
706 <ExternalLinkIcon className="size-3.5" />
707 </Button>
708 </div>
709 ) : null}
710 {handoffUrl ? (
711 <details>
712 <summary className="cursor-pointer">Other ways to connect</summary>
713 <Button className="mt-2" size="sm" variant="outline" render={<a href={handoffUrl} />}>
714 Use T3 desktop for automatic return
715 </Button>
716 </details>
717 ) : null}
718 </div>
719 ) : null;
720 const callbackHelpContent =
721 callbackCompletion && callbackHelpOpen ? (
722 <div id={callbackHelpId} className="w-full border-t border-border/50 pt-3">
723 {callbackCompletion}
724 </div>
725 ) : null;
726 const callbackFallback = needsManualCallback ? (
727 callbackCompletion
728 ) : callbackCompletion ? (
729 <div className="space-y-3 text-xs leading-relaxed text-muted-foreground">
730 <CodexSignInDescription
731 label="Having trouble signing in?"
732 expanded={callbackHelpOpen}
733 controls={callbackHelpId}
734 onToggle={() => setCallbackHelpOpen((open) => !open)}
735 />
736 {callbackHelpContent}
737 </div>
738 ) : null;
739
740 const accountPicker = (
741 <ChatGptAccountPicker
742 open={accountPickerOpen}
743 methods={auth?.methods ?? []}
744 onClose={() => setAccountPickerOpen(false)}
745 onSelect={(methodId) => {
746 setAccountPickerOpen(false);
747 void setup(
748 auth?.methods?.some((method) => method.id === methodId)
749 ? methodId
750 : "chatgpt-change-account",
751 );
752 }}
753 />
754 );
755
756 const logoutWarning =
757 auth?.phase === "idle" && auth.message?.startsWith("Signed out locally.") ? auth.message : null;
758
759 if (presentation === "onboarding") {
760 const setupError =
761 logoutWarning ??
762 error ??
763 (authQuery.error || installQuery.error
764 ? "Could not read setup status. Reconnect and try again."
765 : installation?.phase === "failed"
766 ? installation.message
767 : null);
768 return (
769 <>
770 {accountPicker}
771 <CodexWelcomeCard
772 title={displayName || provider?.displayName || "Codex"}
773 description={
774 installActive ? (
775 runtimeDescription
776 ) : authenticated ? (
777 provider?.auth.email?.trim() ? (
778 <>
779 Signed in as{" "}
780 <RedactedSensitiveText
781 value={provider.auth.email.trim()}
782 ariaLabel="Toggle account email visibility"
783 revealTooltip="Click to reveal email"
784 hideTooltip="Click to hide email"
785 className="break-all"
786 />
787 .
788 </>
789 ) : (
790 "Connected to ChatGPT."
791 )
792 ) : callbackCompletion && !needsManualCallback ? (
793 <CodexSignInDescription
794 expanded={callbackHelpOpen}
795 controls={callbackHelpId}
796 onToggle={() => setCallbackHelpOpen((open) => !open)}
797 />
798 ) : startingAutomatically ||
799 waitingForAuthState ||
800 auth?.phase === "starting" ||
801 auth?.phase === "waiting" ||
802 (pending && !installActive) ? (
803 <CodexSignInDescription />
804 ) : authActive || auth?.phase === "failed" || auth?.phase === "cancelled" ? (
805 accountDescription
806 ) : (
807 "Code with your ChatGPT subscription."
808 )
809 }
810 control={
811 authenticated && !busy ? (
812 <span className="inline-flex items-center gap-1.5 text-xs font-medium text-success-foreground">
813 <CheckIcon className="size-3.5" />
814 Ready
815 </span>
816 ) : authActive ? (
817 waitingControl
818 ) : (
819 <ChatGptConnectionButton
820 size="sm"
821 disabled={
822 unavailable ||
823 busy ||
824 (!installed ? !provider?.setup?.canInstall : !provider?.setup?.canAuthenticate)
825 }
826 onClick={() => {
827 if (hasSavedAccount) setAccountPickerOpen(true);
828 else void setup();
829 }}
830 >
831 {installActive || pending
832 ? "Setting up..."
833 : hasSavedAccount
834 ? "Reconnect account"
835 : "Continue with ChatGPT"}
836 </ChatGptConnectionButton>
837 )
838 }
839 secondaryControl={
840 (authActive && !finishingSignIn) || installActive ? (
841 <Button
842 size="sm"
843 variant="ghost-muted"
844 disabled={pending || readOnly}
845 onClick={() => {
846 openRequested.current = false;
847 continueWithSignIn.current = null;
848 if (handoff || (authActive && auth?.flowId)) void cancelSignIn();
849 else if (installation?.operationId)
850 void run(() =>
851 cancelInstall({
852 environmentId,
853 input: { instanceId, operationId: installation.operationId! },
854 }),
855 );
856 }}
857 >
858 Cancel
859 </Button>
860 ) : !authActive && !authenticated && hasSavedAccount ? (
861 <Button
862 size="sm"
863 variant="ghost-muted"
864 disabled={unavailable || busy}
865 onClick={() => void setup("chatgpt-change-account")}
866 >
867 Use a different account
868 </Button>
869 ) : !authActive && !authenticated && allowExistingCli ? (
870 <Button
871 size="sm"
872 variant="ghost-muted"
873 disabled={readOnly || busy}
874 onClick={() => onModeChange("existing")}
875 >
876 Use existing CLI
877 </Button>
878 ) : null
879 }
880 >
881 {needsManualCallback ? callbackFallback : callbackHelpContent}
882 {setupError ? (
883 <p role="alert" className="text-xs text-destructive">
884 {setupError}
885 </p>
886 ) : null}
887 </CodexWelcomeCard>
888 </>
889 );
890 }
891
892 return (
893 <section aria-label="Codex setup" className="divide-y divide-border/50">
894 {accountPicker}
895 <SettingsRow
896 title="ChatGPT account"
897 description={accountDescription}
898 control={
899 <div className="flex flex-wrap items-center justify-end gap-2">
900 {authActive ? (
901 <>
902 {waitingControl}
903 {!finishingSignIn && (
904 <Button
905 size="sm"
906 variant="ghost"
907 disabled={pending || readOnly}
908 onClick={() => {
909 if (!handoff && !auth?.flowId) return;
910 openRequested.current = false;
911 void cancelSignIn();
912 onSignInCancelled?.();
913 }}
914 >
915 Cancel
916 </Button>
917 )}
918 </>
919 ) : installActive ? (
920 <Button
921 size="sm"
922 variant="ghost"
923 disabled={pending || readOnly}
924 onClick={() => {
925 if (!installation?.operationId) return;
926 continueWithSignIn.current = null;
927 void run(() =>
928 cancelInstall({
929 environmentId,
930 input: { instanceId, operationId: installation.operationId! },
931 }),
932 );
933 }}
934 >
935 Cancel
936 </Button>
937 ) : authenticated ? (
938 <>
939 <Button
940 size="sm"
941 variant="outline"
942 disabled={unavailable || busy}
943 onClick={() => setAccountPickerOpen(true)}
944 >
945 Change account
946 </Button>
947 <Button
948 size="sm"
949 variant="ghost"
950 disabled={unavailable || busy}
951 onClick={() => void run(() => logoutAuth(target))}
952 >
953 Disconnect
954 </Button>
955 </>
956 ) : (
957 <>
958 <ChatGptConnectionButton
959 size="sm"
960 disabled={
961 unavailable ||
962 busy ||
963 (!installed ? !provider?.setup?.canInstall : !provider?.setup?.canAuthenticate)
964 }
965 onClick={() => {
966 if (hasSavedAccount) setAccountPickerOpen(true);
967 else void setup();
968 }}
969 >
970 {pending
971 ? "Setting up..."
972 : hasSavedAccount
973 ? "Reconnect account"
974 : "Continue with ChatGPT"}
975 </ChatGptConnectionButton>
976 {hasSavedAccount ? (
977 <Button
978 size="sm"
979 variant="ghost"
980 disabled={unavailable || busy}
981 onClick={() => void setup("chatgpt-change-account")}
982 >
983 Use a different account
984 </Button>
985 ) : null}
986 </>
987 )}
988 </div>
989 }
990 />
991 {authenticated ? (
992 <div className="flex justify-end px-3 py-1 sm:px-4">
993 <ChatGptUsageButton />
994 </div>
995 ) : null}
996 {logoutWarning ? (
997 <p role="alert" className="px-3 py-2 text-xs text-muted-foreground sm:px-4">
998 {logoutWarning}
999 </p>
1000 ) : null}
1001 {callbackFallback ? <div className="px-3 py-3 sm:px-4">{callbackFallback}</div> : null}
1002 {error || authQuery.error || installQuery.error || installation?.phase === "failed" ? (
1003 <p role="alert" className="px-3 py-2 text-xs text-destructive sm:px-4">
1004 {error ??
1005 (installation?.phase === "failed"
1006 ? installation.message
1007 : "Could not read Codex setup status. Reconnect and try again.")}
1008 </p>
1009 ) : null}
1010 </section>
1011 );
1012}
1013
1014/** The server selects the executable for managed instances; local config cannot override it. */
1015export function CodexManagedRuntimeFields({
1016 environmentId,
1017 instanceId,
1018 provider,
1019}: {
1020 readonly environmentId: EnvironmentId;
1021 readonly instanceId: ProviderInstanceId;
1022 readonly provider: ServerProvider | undefined;
1023}) {
1024 const installation = useEnvironmentQuery(
1025 serverEnvironment.providerInstallState({
1026 environmentId,
1027 input: { instanceId },
1028 }),
1029 );
1030 const executablePath = installation.data?.executablePath ?? "";
1031 return (
1032 <>
1033 <SettingsRow
1034 title="Binary path"
1035 description="Selected by The Knife."
1036 control={
1037 <div className="w-full sm:w-80">
1038 <Input
1039 aria-label="Codex binary path"
1040 value={executablePath}
1041 title={executablePath}
1042 placeholder={installation.error ? "Could not read runtime path" : "Not installed"}
1043 disabled
1044 />
1045 </div>
1046 }
1047 />
1048 <SettingsRow
1049 title="CODEX_HOME path"
1050 description="Shared Codex config, sessions, and state."
1051 control={
1052 <div className="w-full sm:w-80">
1053 <Input
1054 aria-label="Codex home path"
1055 value={provider?.runtimePaths?.homePath ?? ""}
1056 title={provider?.runtimePaths?.homePath}
1057 placeholder="Unavailable"
1058 disabled
1059 />
1060 </div>
1061 }
1062 />
1063 <SettingsRow
1064 title="Shadow home path"
1065 description={
1066 provider?.runtimePaths?.shadowHomePath
1067 ? "Account-specific home sharing the Codex state above."
1068 : "This instance uses the shared Codex home directly."
1069 }
1070 control={
1071 <div className="w-full sm:w-80">
1072 <Input
1073 aria-label="Codex shadow home path"
1074 value={provider?.runtimePaths?.shadowHomePath ?? ""}
1075 title={provider?.runtimePaths?.shadowHomePath ?? undefined}
1076 placeholder={provider?.runtimePaths ? "Not used" : "Unavailable"}
1077 disabled
1078 />
1079 </div>
1080 }
1081 />
1082 </>
1083 );
1084}
1085
1086function CodexSignInDescription({
1087 label = "Complete sign-in in your browser.",
1088 expanded = false,
1089 controls,
1090 onToggle,
1091}: {
1092 readonly label?: string;
1093 readonly expanded?: boolean;
1094 readonly controls?: string;
1095 readonly onToggle?: () => void;
1096}) {
1097 if (!onToggle) return <div className="leading-relaxed">{label}</div>;
1098 return (
1099 <button
1100 type="button"
1101 className="inline-flex items-center gap-1.5 rounded-sm text-left leading-relaxed outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
1102 aria-label="Having trouble signing in?"
1103 aria-expanded={expanded}
1104 aria-controls={controls}
1105 onClick={onToggle}
1106 >
1107 {label}
1108 <ChevronRightIcon aria-hidden className={`size-3 shrink-0 ${expanded ? "rotate-90" : ""}`} />
1109 </button>
1110 );
1111}
1112
1113/** A single, calm setup row for the first-run welcome screen. */
1114function CodexWelcomeCard({
1115 title,
1116 description,
1117 control,
1118 secondaryControl,
1119 children,
1120}: {
1121 readonly title: string;
1122 readonly description: ReactNode;
1123 readonly control?: ReactNode;
1124 readonly secondaryControl?: ReactNode;
1125 readonly children?: ReactNode;
1126}) {
1127 const footer = Children.toArray(children);
1128 return (
1129 <div className="rounded-lg border border-border bg-background px-4 py-4">
1130 <div className="flex flex-wrap items-center gap-3">
1131 <OpenAI className="size-5 shrink-0 fill-foreground" />
1132 <div className="min-w-0 flex-1">
1133 <h3 className="text-sm font-medium">{title}</h3>
1134 <div className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{description}</div>
1135 </div>
1136 {control || secondaryControl ? (
1137 <div className="flex shrink-0 flex-wrap items-center justify-end gap-2 has-[button]:w-full has-[button]:flex-col-reverse has-[button]:items-end sm:has-[button]:w-auto sm:has-[button]:flex-row sm:has-[button]:items-center">
1138 {secondaryControl}
1139 {control}
1140 </div>
1141 ) : null}
1142 </div>
1143 {footer.length > 0 ? (
1144 <div className="ml-8 mt-2 flex flex-wrap items-center gap-2">{footer}</div>
1145 ) : null}
1146 </div>
1147 );
1148}