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
123 changes: 121 additions & 2 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3761,6 +3761,23 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

/**
* Push one provider-auth event down the same subscription the composer
* notice attaches to when it starts a sign-in.
*/
function emitProviderAuthEvent(
event:
| { type: "command"; flow: string; command: string }
| { type: "output"; data: string }
| { type: "status"; status: string; exitCode: number | null; detail: string | null },
) {
rpcHarness.emitStreamValue(WS_METHODS.providerAuthSubscribe, {
instanceId: "codex",
createdAt: new Date().toISOString(),
...event,
});
}

async function mountSignedOutProviderSend(options: {
/** Providers the recheck behind "I've signed in" resolves with. */
refreshedProviders: (signedOut: ServerProvider) => ReadonlyArray<ServerProvider>;
Expand All @@ -3785,6 +3802,11 @@ describe("ChatView timeline estimator parity (full app)", () => {
if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
return { sequence: fixture.snapshot.snapshotSequence + 1 };
}
// `providerAuth.start` succeeds with void; the harness's default `{}`
// would fail the response decode and surface as a start error.
if (body._tag === WS_METHODS.providerAuthStart) {
return null;
}
if (body._tag === WS_METHODS.serverRefreshProviders) {
return {
providers: encodeServerConfig({
Expand Down Expand Up @@ -3817,9 +3839,106 @@ describe("ChatView timeline estimator parity (full app)", () => {
"Explain this repo",
);

return { confirmSignedIn, mounted, turnStartRequests };
const providerAuthStartRequests = () =>
wsRequests.filter((request) => request._tag === WS_METHODS.providerAuthStart);

return { confirmSignedIn, mounted, providerAuthStartRequests, turnStartRequests };
}

it("signs in from the held-send notice and releases the message without a second click", async () => {
const { mounted, providerAuthStartRequests, turnStartRequests } =
await mountSignedOutProviderSend({
refreshedProviders: (signedOut) => [
{ ...signedOut, status: "ready", auth: { status: "authenticated" } },
],
});

try {
(await waitForButtonByText("Sign in")).click();

// The login runs in the server's own PTY, never in the thread's terminal.
await vi.waitFor(
() => {
expect(providerAuthStartRequests()).toHaveLength(1);
},
{ timeout: 8_000, interval: 16 },
);
expect(providerAuthStartRequests()[0]).toMatchObject({ instanceId: "codex", flow: "login" });

emitProviderAuthEvent({ type: "command", flow: "login", command: "codex login" });
emitProviderAuthEvent({ type: "status", status: "running", exitCode: null, detail: null });
emitProviderAuthEvent({ type: "output", data: "Opening browser to complete sign-in\r\n" });

await vi.waitFor(
() => {
expect(document.body.textContent).toContain(
"Signing in… Opening browser to complete sign-in",
);
},
{ timeout: 8_000, interval: 16 },
);

emitProviderAuthEvent({ type: "status", status: "succeeded", exitCode: 0, detail: null });

await vi.waitFor(
() => {
expect(turnStartRequests()).toHaveLength(1);
},
{ timeout: 8_000, interval: 16 },
);
// The success recheck is not a bypass, and it fires exactly once.
await waitForLayout();
expect(turnStartRequests()).toHaveLength(1);
} finally {
await mounted.cleanup();
}
});

it("keeps the held message and shows the last line when the sign-in fails", async () => {
const { mounted, providerAuthStartRequests, turnStartRequests } =
await mountSignedOutProviderSend({
refreshedProviders: (signedOut) => [signedOut],
});

try {
(await waitForButtonByText("Sign in")).click();

await vi.waitFor(
() => {
expect(providerAuthStartRequests()).toHaveLength(1);
},
{ timeout: 8_000, interval: 16 },
);

emitProviderAuthEvent({ type: "command", flow: "login", command: "codex login" });
emitProviderAuthEvent({ type: "status", status: "running", exitCode: null, detail: null });
emitProviderAuthEvent({ type: "output", data: "error: could not reach auth.openai.com\r\n" });
emitProviderAuthEvent({
type: "status",
status: "failed",
exitCode: 1,
detail: "codex login exited with code 1.",
});

await vi.waitFor(
() => {
expect(document.body.textContent).toContain(
"Sign-in failed. codex login exited with code 1.",
);
},
{ timeout: 8_000, interval: 16 },
);
expect(turnStartRequests()).toHaveLength(0);
// The action comes back so the user can try again, and the draft is intact.
await expect.element(page.getByRole("button", { name: "Sign in" })).toBeVisible();
expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt).toBe(
"Explain this repo",
);
} finally {
await mounted.cleanup();
}
});

it("sends the held message once the recheck behind I've signed in comes back clean", async () => {
const { confirmSignedIn, mounted, turnStartRequests } = await mountSignedOutProviderSend({
refreshedProviders: (signedOut) => [
Expand Down Expand Up @@ -3859,7 +3978,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
{ timeout: 8_000, interval: 16 },
);
expect(turnStartRequests()).toHaveLength(0);
expect(document.body.textContent).toContain("The terminal shows where the sign-in stopped.");
expect(document.body.textContent).toContain("The last sign-in did not complete.");
expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt).toBe(
"Explain this repo",
);
Expand Down
53 changes: 34 additions & 19 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ import {
} from "./chat/providerStatusNotice";
import { useSessionStartupNotice } from "./chat/sessionStartupNotice";
import { buildProviderSendPreflightNotice } from "./chat/providerReadinessNotice";
import { toProviderSignInFlowView } from "./chat/providerSignIn";
import { useProviderConnectFlow } from "./settings/useProviderConnectFlow";
import { buildThreadErrorNotice } from "./chat/threadErrorNotice";
import { type ComposerNotice, selectComposerNotices } from "./chat/composerNotices";
import {
Expand Down Expand Up @@ -2494,6 +2496,33 @@ export default function ChatView(props: ChatViewProps) {
// The recheck runs the ordinary send path, which is rebuilt every render.
// Holding it behind a ref keeps the notice itself stable.
const confirmProviderSignedInRef = useRef<() => void>(() => {});
// One sign-in flow serves every composer notice: the held-send row and the
// provider-status row are mutually suppressed, and both speak about the
// instance the composer would send to.
const composerSignInInstanceId =
providerSendPreflight?.instanceId ?? activeProviderStatus?.instanceId ?? null;
const hasHeldSendRef = useRef(false);
hasHeldSendRef.current = providerSendPreflight !== null;
const composerSignInController = useProviderConnectFlow({
instanceId: composerSignInInstanceId,
flow: "login",
// A held message is waiting on exactly this: re-probe and, if the provider
// agrees, send it. Without this the user would sign in and then still have
// to click "I've signed in".
onSucceeded: () => {
if (hasHeldSendRef.current) {
confirmProviderSignedInRef.current();
}
},
});
const composerSignInView = useMemo(
() =>
toProviderSignInFlowView({
instanceId: composerSignInInstanceId,
controller: composerSignInController,
}),
[composerSignInController, composerSignInInstanceId],
);
useEffect(() => {
// The notice belongs to the send it interrupted, so it must not follow the
// user into another thread.
Expand Down Expand Up @@ -3410,14 +3439,6 @@ export default function ChatView(props: ChatViewProps) {
projectCwd={firstRunProject?.cwd ?? null}
projectEnvironmentId={firstRunProject?.environmentId ?? environmentId}
isOnlyWorkspaceProject={firstRunWorkspaceProjects.length === 1}
onSignIn={(row) => {
if (!row.signInCommand) return;
void runProviderAuthReconnect({
provider: row.driverKind,
command: row.signInCommand,
message: `${row.name} is not signed in.`,
});
}}
onChooseProject={() => useCommandPaletteStore.getState().openAddProject()}
onSkip={dismissFirstRunSetupForEnvironment}
onStart={() => {
Expand All @@ -3432,7 +3453,6 @@ export default function ChatView(props: ChatViewProps) {
firstRunProject,
firstRunWorkspaceProjects.length,
providerInstanceEntries,
runProviderAuthReconnect,
scheduleComposerFocus,
showFirstRunSetupCard,
]);
Expand Down Expand Up @@ -5240,6 +5260,7 @@ export default function ChatView(props: ChatViewProps) {
const providerStatusNotice = useProviderStatusNotice({
status: activeProviderStatus,
activeTurnInProgress,
signIn: composerSignInView,
// The held-send notice and the setup card each already state this
// provider's problem with the actions that fix it; a second ambient row
// saying it again is the stacking noise the dock exists to end.
Expand All @@ -5262,15 +5283,15 @@ export default function ChatView(props: ChatViewProps) {
usageReset: threadErrorUsageResetAction,
retry: threadErrorRetryAction,
providerLabel: activeProviderLabel,
onRunAuthReconnect: runProviderAuthReconnect,
signIn: composerSignInView,
onDismiss: () => setThreadError(activeThread?.id ?? null, null),
}),
[
activeProviderLabel,
activeThread?.error,
activeThread?.id,
composerSignInView,
providerAuthReconnectPrompt,
runProviderAuthReconnect,
setThreadError,
threadErrorNoticeVisible,
threadErrorRetryAction,
Expand All @@ -5284,13 +5305,7 @@ export default function ChatView(props: ChatViewProps) {
prompt: providerSendPreflight,
recheckFailed: providerSendPreflightRecheckFailed,
isRechecking: isRecheckingProviderSendPreflight,
onRunSignIn: (prompt) => {
void runProviderAuthReconnect({
provider: prompt.provider,
command: prompt.command ?? "",
message: `${prompt.providerLabel} is not signed in.`,
});
},
signIn: composerSignInView,
onConfirmSignedIn: () => confirmProviderSignedInRef.current(),
onDismiss: () => {
setProviderSendPreflight(null);
Expand All @@ -5299,10 +5314,10 @@ export default function ChatView(props: ChatViewProps) {
})
: null,
[
composerSignInView,
isRecheckingProviderSendPreflight,
providerSendPreflight,
providerSendPreflightRecheckFailed,
runProviderAuthReconnect,
],
);
const composerNotices = useMemo(
Expand Down
Loading
Loading