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:
parent
d6c47f6664
commit
028b7b7187
2 changed files with 102 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue