feat(frontend): support des providers OpenCode cloud (#92)

Ajoute l'UI de configuration des providers OpenCode cloud dans le wizard
first-run (sélection, édition, sauvegarde, suppression), le domaine et
les ports associés, ainsi que les adapters HTTP/mock correspondants.

Build + 952 tests verts, comportements clés vérifiés en exécution réelle,
y compris un test de couverture ajouté pour le parcours d'édition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 08:18:02 +02:00
parent 23a3c2788f
commit c181b43d04
8 changed files with 649 additions and 6 deletions

View File

@ -15,12 +15,17 @@
* `./profile`.
*/
import { useCallback, useEffect, useState } from "react";
import type {
AgentProfile,
GatewayError,
HttpChatConfig,
LocalModelServerConfig,
OpenCodeConfig,
OpenCodeProviderCatalogEntry,
} from "@/domain";
import { useGateways } from "@/app/di";
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
import {
ModelServersPanel,
@ -41,6 +46,54 @@ function Caption({ children }: { children: React.ReactNode }) {
return <span className="text-xs font-medium text-muted">{children}</span>;
}
function describeError(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
/** View-model for the OpenCode cloud-provider catalogue (ticket #92). */
interface OpenCodeProviderCatalog {
providers: OpenCodeProviderCatalogEntry[] | null;
loading: boolean;
error: string | null;
reload: () => void;
}
/**
* Loads the static OpenCode cloud-provider catalogue once for the whole
* wizard (every Cloud row shares it), so the provider/model pickers can be
* populated. Exposes a `reload` for the blocking "Réessayer" state.
*/
function useOpenCodeProviderCatalog(): OpenCodeProviderCatalog {
const { profile } = useGateways();
const [providers, setProviders] = useState<OpenCodeProviderCatalogEntry[] | null>(
null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setProviders(await profile.listOpenCodeProviders());
} catch (e) {
setProviders(null);
setError(describeError(e));
} finally {
setLoading(false);
}
}, [profile]);
useEffect(() => {
void load();
}, [load]);
return { providers, loading, error, reload: () => void load() };
}
/**
* Renders the wizard when it is the first run. Calls `onDone` once the user
* finishes (so the host can drop the wizard and show the normal UI). Returns
@ -62,6 +115,7 @@ export function FirstRunWizard({
// which pre-loads and pre-selects the already-configured profiles.
const vm = useFirstRun(forceOpen ? "edit" : "firstRun");
const modelServers = useModelServers();
const providerCatalog = useOpenCodeProviderCatalog();
if (vm.isFirstRun === null) return null;
if (!forceOpen && vm.isFirstRun === false) return null;
@ -129,6 +183,7 @@ export function FirstRunWizard({
key={entry.profile.id}
entry={entry}
servers={modelServers.servers}
providerCatalog={providerCatalog}
onToggle={() => vm.toggle(entry.profile.id)}
onChange={(p) => vm.updateProfile(entry.profile.id, p)}
onRemove={() => vm.remove(entry.profile.id)}
@ -159,6 +214,7 @@ export function FirstRunWizard({
function ProfileRow({
entry,
servers,
providerCatalog,
onToggle,
onChange,
onRemove,
@ -167,6 +223,8 @@ function ProfileRow({
entry: WizardEntry;
/** Declared local model servers (F35.2), for the OpenCode binding dropdown. */
servers: LocalModelServerConfig[];
/** OpenCode cloud-provider catalogue (ticket #92), shared across rows. */
providerCatalog: OpenCodeProviderCatalog;
onToggle: () => void;
onChange: (p: AgentProfile) => void;
onRemove: () => void;
@ -263,6 +321,77 @@ function ProfileRow({
)}
{profile.structuredAdapter === "openCode" && (
<OpenCodeModeFields
profile={profile}
errors={errors}
servers={servers}
providerCatalog={providerCatalog}
onChange={onChange}
/>
)}
</li>
);
}
/**
* Segmented control (F — ticket #92) choosing whether an OpenCode profile runs
* against the local `llama.cpp` endpoint or a cloud provider from the OpenCode
* registry, and renders the matching sub-form. The two sub-forms never overlap;
* switching segments keeps the inactive one's draft in memory (component-local
* state) without touching `profile` until it is actually submitted.
*/
function OpenCodeModeFields({
profile,
errors,
servers,
providerCatalog,
onChange,
}: {
profile: AgentProfile;
errors: ProfileErrors;
servers: LocalModelServerConfig[];
providerCatalog: OpenCodeProviderCatalog;
onChange: (p: AgentProfile) => void;
}) {
const [mode, setMode] = useState<"local" | "cloud">(
profile.opencodeProvider ? "cloud" : "local",
);
return (
<div className="mt-1 flex flex-col gap-2">
<div
role="radiogroup"
aria-label="mode du profil OpenCode"
className="flex w-fit gap-1 rounded-md border border-border bg-raised p-0.5"
>
{(
[
{ id: "local", label: "Local (llama.cpp)" },
{ id: "cloud", label: "Provider cloud" },
] as const
).map((seg) => {
const active = mode === seg.id;
return (
<button
key={seg.id}
type="button"
role="radio"
aria-checked={active}
onClick={() => setMode(seg.id)}
className={cn(
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
active
? "bg-primary text-on-primary"
: "text-muted hover:text-content",
)}
>
{seg.label}
</button>
);
})}
</div>
{mode === "local" && (
<OpenCodeFields
profile={profile}
errors={errors}
@ -270,7 +399,223 @@ function ProfileRow({
onChange={onChange}
/>
)}
</li>
{mode === "cloud" && (
<OpenCodeProviderFields
profile={profile}
catalog={providerCatalog}
onChange={onChange}
/>
)}
</div>
);
}
/** Field-keyed validation errors for the Cloud sub-form, surfaced on submit. */
interface CloudFieldErrors {
providerId?: string;
model?: string;
apiKey?: string;
}
/**
* The OpenCode **cloud** provider config section (ticket #92): provider ➜
* model (cascading selects fed by the static catalogue) ➜ API key. The key is
* never pre-filled (create or edit) since the backend never returns it — it
* resigns whatever literal it receives on every save, so editing an existing
* cloud profile requires re-entering it every time (§3 of the spec).
*/
function OpenCodeProviderFields({
profile,
catalog,
onChange,
}: {
profile: AgentProfile;
catalog: OpenCodeProviderCatalog;
onChange: (p: AgentProfile) => void;
}) {
const { profile: profileGateway } = useGateways();
const existing = profile.opencodeProvider;
const [providerId, setProviderId] = useState(existing?.providerId ?? "");
const [model, setModel] = useState(existing?.model ?? "");
const [apiKey, setApiKey] = useState("");
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<CloudFieldErrors>({});
const isEditing = existing !== undefined;
const models =
catalog.providers?.find((p) => p.providerId === providerId)?.models ?? [];
const catalogReady = catalog.providers !== null && !catalog.loading;
const saveDisabled =
saving || !catalogReady || Boolean(catalog.error) || apiKey.length === 0;
async function save() {
const errors: CloudFieldErrors = {};
if (providerId.length === 0) errors.providerId = "Le provider est obligatoire.";
if (model.length === 0) errors.model = "Le modèle est obligatoire.";
if (apiKey.length === 0) errors.apiKey = "La clé API est obligatoire.";
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
setSaving(true);
setSaveError(null);
try {
const saved = await profileGateway.saveOpenCodeProviderProfile({
profile,
providerId,
model,
apiKey,
});
onChange(saved);
setApiKey("");
} catch (e) {
setSaveError(describeError(e));
} finally {
setSaving(false);
}
}
return (
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Provider cloud (OpenCode)
</legend>
{saveError && (
<p role="alert" className="text-sm text-danger">
{saveError}
</p>
)}
{catalog.loading && (
<p className="text-xs text-faint">Chargement des providers</p>
)}
{catalog.error && (
<div className="flex flex-col gap-1">
<p role="alert" className="text-sm text-danger">
Impossible de charger la liste des providers cloud.
</p>
<Button size="sm" onClick={() => catalog.reload()} className="w-fit">
Réessayer
</Button>
</div>
)}
<label className="flex flex-col gap-1">
<Caption>Provider</Caption>
<select
aria-label={`${profile.name} provider`}
value={providerId}
disabled={!catalogReady}
onChange={(e) => {
setProviderId(e.target.value);
setModel("");
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
}}
className={cn(
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
fieldErrors.providerId ? "border-danger" : "border-border",
)}
>
<option value="" disabled>
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"}
</option>
{catalog.providers?.map((p) => (
<option key={p.providerId} value={p.providerId}>
{p.displayName}
</option>
))}
</select>
{fieldErrors.providerId && (
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Modèle</Caption>
<select
aria-label={`${profile.name} model`}
value={model}
disabled={providerId.length === 0}
onChange={(e) => {
setModel(e.target.value);
setFieldErrors((prev) => ({ ...prev, model: undefined }));
}}
className={cn(
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
fieldErrors.model ? "border-danger" : "border-border",
)}
>
<option value="" disabled>
{providerId.length === 0 ? "—" : "Choisir un modèle…"}
</option>
{models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
{fieldErrors.model && (
<small className="text-xs text-danger">{fieldErrors.model}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Clé API</Caption>
<div className="flex items-center gap-1">
<Input
aria-label={`${profile.name} api key`}
type={showKey ? "text" : "password"}
value={apiKey}
placeholder={
isEditing
? "Ressaisissez la clé API pour confirmer l'enregistrement"
: "ex. sk-ant-…"
}
invalid={Boolean(fieldErrors.apiKey)}
onChange={(e) => {
setApiKey(e.target.value);
setFieldErrors((prev) => ({ ...prev, apiKey: undefined }));
}}
/>
<IconButton
size="sm"
aria-label="afficher/masquer la clé API"
onClick={() => setShowKey((v) => !v)}
>
{showKey ? "🙈" : "👁"}
</IconButton>
</div>
{fieldErrors.apiKey && (
<small className="text-xs text-danger">{fieldErrors.apiKey}</small>
)}
<small className="text-xs text-faint">
Jamais affichée ni renvoyée par IdeA une fois enregistrée ; stockée
chiffrée localement.
</small>
{isEditing && (
<small className="text-xs text-muted">
Pour des raisons de sécurité, la clé n&apos;est jamais réaffichée :
ressaisissez-la à chaque modification de ce profil.
</small>
)}
</label>
<Button
variant="primary"
size="sm"
loading={saving}
disabled={saveDisabled}
onClick={() => void save()}
className="w-fit"
>
Enregistrer
</Button>
</fieldset>
);
}