Studio: finish Codex UI flow (test, sign-in, parallel tabs)

Second reviewer.py pass surfaced three follow-ups missed in the
earlier round. All caught by 12 parallel reviewers + cross-block
audit; each fix is small but user-facing.

* `testProvider` no longer pushes a Codex connection back to the
  edit form to "add an API key". Codex has no remote endpoint to
  ping, so the Test button now calls `/api/codex/status` directly:
  toasts success with the CLI version when installed+logged in,
  prompts to sign in when installed+logged out, and errors when
  the CLI or SDK is missing.

* The Sign-in to Codex affordance is now actually mounted. When
  the selected provider is Codex and `/api/codex/status` reports
  `installed:true, logged_in:false`, the dialog renders the new
  `CodexLoginButton` above the (hidden) API key row. The button's
  `onLoggedIn` callback re-probes status so the UI flips to the
  ready state without a page reload.

* The chat adapter now handles `codex_*` `_toolEvent` types
  instead of silently swallowing them. Per-tab chunks render
  inline with a `[Codex tab N/M]` header so users see each
  parallel attempt; `codex_gather` adds a `--- Synthesis ---`
  divider before the final unified content delta the backend
  also emits as plain text. This unblocks the existing fan-out
  path while a dedicated `CodexParallelTabs` UI is wired in a
  future change.

Verified: 26/26 codex_provider tests pass; `tsc --noEmit` on
studio/frontend completes clean.
This commit is contained in:
Daniel Han 2026-05-24 15:01:33 +00:00
commit 028b7b7187
2 changed files with 102 additions and 1 deletions

View file

@ -1585,6 +1585,47 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
chunk as unknown as { _toolEvent?: Record<string, unknown> }
)._toolEvent;
if (toolEvent !== undefined) {
// Codex parallel-calls fan-out events: render each
// per-tab chunk inline with a "Tab N:" prefix so users
// see the N independent attempts even before the
// dedicated CodexParallelTabs UI is wired into the
// chat surface. `codex_gather` carries the synthesis
// payload which the backend also emits as a normal
// content delta, so we drop it here to avoid showing
// the synthesis twice. tab_open / tab_close / tab_error
// are header markers we surface as one-line notes.
if (typeof toolEvent.type === "string" && toolEvent.type.startsWith("codex_")) {
if (toolEvent.type === "codex_tab_open") {
const tabId = Number(toolEvent.tab_id);
const total = Number(toolEvent.total_tabs);
if (Number.isFinite(tabId) && Number.isFinite(total)) {
cumulativeText += `\n\n[Codex tab ${tabId}/${total}]\n`;
}
} else if (toolEvent.type === "codex_tab_chunk") {
const text = typeof toolEvent.text === "string" ? toolEvent.text : "";
if (text) cumulativeText += text;
} else if (toolEvent.type === "codex_tab_error") {
const tabId = Number(toolEvent.tab_id);
const err = typeof toolEvent.error === "string" ? toolEvent.error : "error";
if (Number.isFinite(tabId)) {
cumulativeText += `\n[Codex tab ${tabId} error: ${err}]\n`;
}
} else if (toolEvent.type === "codex_tab_close") {
// Mark end of tab block so synthesis is visually separated.
cumulativeText += "\n";
} else if (toolEvent.type === "codex_gather") {
// Synthesis is also emitted as a normal content
// delta later in the same SSE stream; nothing to
// add here. Surface a divider so the user can tell
// where the synthesis starts.
cumulativeText += "\n--- Synthesis ---\n";
}
const codexParts = parseAssistantContent(cumulativeText);
yield {
content: [...toolCallParts, ...codexParts],
};
continue;
}
// OpenAI shell-tool container persistence — see
// ThreadRecord.openaiCodeExecContainerId. The backend
// emits these synthetic events on the OpenAI Responses

View file

@ -68,7 +68,8 @@ import {
supportsRemoteModelCatalog,
toExternalBackendProviderType,
} from "./external-providers";
import { fetchCodexStatus } from "./api/codex-api";
import { fetchCodexStatus, type CodexStatus } from "./api/codex-api";
import { CodexLoginButton } from "./components/codex-login-button";
import { useExternalProvidersStore } from "./stores/external-providers-store";
/** Matches navbar / thread layout easing (see index.css --ease-out-quart) */
@ -224,6 +225,20 @@ export function ChatProvidersSettings({
null,
);
const [registry, setRegistry] = useState<ProviderRegistryEntry[]>([]);
// Codex CLI / SDK availability snapshot. Used to (a) decide whether
// to render the synthetic Codex registry row, and (b) drive the
// sign-in button when the host is installed but logged out.
const [codexStatus, setCodexStatus] = useState<CodexStatus | null>(null);
const refreshCodexStatus = async () => {
try {
const next = await fetchCodexStatus();
setCodexStatus(next);
return next;
} catch {
setCodexStatus(null);
return null;
}
};
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([]);
const [syncingProviders, setSyncingProviders] = useState(false);
@ -376,6 +391,7 @@ export function ChatProvidersSettings({
);
if (!isMounted) return;
syncSucceeded = true;
setCodexStatus(codexStatusRaw);
const registryRows: ProviderRegistryEntry[] =
codexStatusRaw && codexStatusRaw.installed &&
!registryRowsRaw.some(
@ -964,6 +980,30 @@ export function ChatProvidersSettings({
async function testProvider(provider: ExternalProviderConfig) {
const savedKey = getExternalProviderApiKey(provider.id).trim();
// Codex dispatches via the local CLI / SDK -- there is no remote
// endpoint to ping. Reuse `/api/codex/status` as the test result.
if (isCodexProviderType(provider.providerType)) {
try {
const status = await fetchCodexStatus();
if (!status.installed) {
toast.error("Codex CLI or SDK is not available on this host.");
return;
}
if (!status.logged_in) {
toast.info("Sign in to Codex before testing this connection.");
return;
}
toast.success(
status.version
? `Codex is available (${status.version}).`
: "Codex is available.",
);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
toast.error(`Codex status check failed: ${message}`);
}
return;
}
// Local OpenAI-compat presets skip API keys — run the connection check.
if (!savedKey && !supportsRemoteModelCatalog(provider.providerType)) {
if (isCustomProviderType(provider.providerType)) {
@ -1115,6 +1155,26 @@ export function ChatProvidersSettings({
</Select>
</div>
{isCodexProvider &&
codexStatus?.installed &&
!codexStatus.logged_in ? (
<div className="grid grid-cols-[minmax(150px,0.8fr)_minmax(260px,1.2fr)] items-center gap-4 px-4 py-3 max-sm:grid-cols-1">
<div className="flex min-w-0 flex-col gap-0.5">
<Label className="text-sm font-medium">
Codex sign-in
</Label>
<p className="text-xs leading-snug text-muted-foreground">
Authenticate the local Codex CLI before chatting.
</p>
</div>
<CodexLoginButton
onLoggedIn={() => {
void refreshCodexStatus();
}}
/>
</div>
) : null}
{showApiKeyField ? (
<div className="grid grid-cols-[minmax(150px,0.8fr)_minmax(260px,1.2fr)] items-center gap-4 px-4 py-3 max-sm:grid-cols-1">
<div className="flex min-w-0 flex-col gap-0.5">