Files
IdeA/frontend/src/features/first-run/FirstRunWizard.tsx
Blomios 1a1429f10e feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)
Lot en un seul commit: les deux features partagent les mêmes contrats
(domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes
surfaces UI — la séparation par fichiers était impossible sans chirurgie
de hunks risquant des états intermédiaires non compilables (convention
lot des batches #310-#313 et #315).

#325 — icône configurable par projet, watermark CLI custom:
- domaine: ProjectIconSet (devicon, simple-icons, lucide, idea),
  ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon
  optionnel sur ProjectSettings; sérialisation camelCase et try_from
  strict
- application: UpdateProjectSettings.execute accepte un patch
  theme_color OU icon (absent = inchangé, null = défaut produit), tests
  in-dependants avec store mémoire
- backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto
  avec icon optionnel (désérialisation additive), commandes
  update_project_settings alignées Tauri/HTTP, tests DTO et HTTP
  (défaut null, set, clear, nom invalide rejeté)
- frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en
  grille multi-lignes dans Paramètres projet, affichage de l'icône dans
  ProjectTabs et watermark grand format à faible opacité en fond de CLI
  custom; ports ProjectSettings.icon, Partial<ProjectSettings>,
  adapters tauri/mock/http et tests alignés

#326 — localisation FR/EN et traduction des textes IdeA:
- domaine: IdeaLanguage (fr | en), champ language optionnel sur
  IdeaUserSettings (absence = défaut produit, série additive validée)
- backend/web-server: UserSettingsDto.language, update_user_settings
  accepte language, tests HTTP alignés; fixtures structured_launch_d3
  mises à jour
- frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression
  des sources visibles), GeneralSettings avec sélecteur de langue dans
  les paramètres, setupI18n dans vitest; migration de l'ensemble des
  textes hardcodés vers le catalogue sur toutes les surfaces (agents,
  layout, projets, tickets, sprints, web, plugins, permissions, git,
  templates, skills, mémoire, model servers, embedder, first-run,
  devices, scheduled tasks, workstate, dialogs), FR conservé par défaut;
  recherche d'icônes #325 en anglais conformément au ticket

QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
2026-09-11 21:53:56 +02:00

458 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* First-run wizard (L5). Shown on the very first IDE launch: it offers the
* pre-filled, selectable reference profiles (Claude/Codex — only AIs drivable in
* structured mode, §17.6/D7) with **editable** commands, lets the user detect
* which CLIs are installed (✓/✗), then saves the chosen profiles and closes the
* first run.
*
* The candidate list is already filtered server-side (`reference_profiles` only
* exposes selectable profiles), so the wizard renders whatever it receives.
* Adding an arbitrary custom profile is no longer offered, since we cannot drive
* it in structured mode.
*
* Pure presentation: all behaviour comes from {@link useFirstRun} (the
* {@link ProfileGateway} port). Profile validation is the pure logic in
* `./profile`.
*/
import type {
AgentProfile,
HttpChatConfig,
LocalModelServerConfig,
} from "@/domain";
import { useEffect, useState } from "react";
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
import { useI18n } from "@/i18n";
import {
ModelServersPanel,
useModelServers,
} from "@/features/model-servers";
import { useFirstRun, type WizardEntry } from "./useFirstRun";
import {
defaultHttpChatConfig,
parseArgs,
validateProfile,
type ProfileErrors,
} from "./profile";
import {
OpenCodeModeFields,
useOpenCodeProviderCatalog,
type OpenCodeProviderCatalog,
} from "./OpenCodeModeFields";
/** A small caption above a control. */
function Caption({ children }: { children: React.ReactNode }) {
return <span className="text-xs font-medium text-muted">{children}</span>;
}
/**
* 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
* Returns `null` while loading. By default it also returns `null` once the first
* run is done (auto-show path in {@link App}); pass `forceOpen` to render it
* regardless — used by "Settings ▸ Configure profiles" to reopen the wizard after
* the first run.
*/
export function FirstRunWizard({
onDone,
forceOpen = false,
}: {
onDone?: () => void;
/** Render the wizard even when it is no longer the first run. */
forceOpen?: boolean;
}) {
const { tr } = useI18n();
// Mode is explicit (ticket #44), driven by the entry point — never inferred
// from `isFirstRun`: reopening from Settings (`forceOpen`) is the "edit" mode,
// 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;
async function finish() {
await vm.finish();
onDone?.();
}
return (
<Panel
aria-label={tr("Configuration initiale", "First run setup")}
className="border-primary/40"
title={
<div className="flex flex-col">
<h2 className="text-base font-semibold text-content">{tr("Bienvenue dans IdeA", "Welcome to IdeA")}</h2>
<p className="text-sm text-muted">
{tr("Choisissez les CLI d’IA à configurer. Les commandes sont préremplies et modifiables.", "Choose which AI CLIs to configure. Commands are pre-filled and editable.")}
</p>
</div>
}
>
<div className="flex flex-col gap-4">
{vm.error && (
<p role="alert" className="text-sm text-danger">
{vm.error}
</p>
)}
<Toolbar>
{/* `detecting` deliberately does NOT disable this button (ticket #28):
detection is best-effort and may never answer. */}
<Button
onClick={() => void vm.detect()}
disabled={vm.busy}
className="whitespace-nowrap"
>
{tr("Détecter les CLI installées", "Detect installed CLIs")}
</Button>
{vm.detecting && (
<span role="status" className="text-xs text-muted">
{tr("Détection…", "Detecting…")}
</span>
)}
{/* F36: declare several local OpenCode profiles. Each click clones the
canonical `opencode-llamacpp` seed into a new, editable row. */}
<Button
onClick={() => void vm.addOpenCodeProfile()}
disabled={vm.busy}
className="whitespace-nowrap"
>
{tr("Ajouter un profil OpenCode", "Add OpenCode profile")}
</Button>
</Toolbar>
{/* F35.2 — declare/edit/delete the local llama.cpp servers an OpenCode
profile can bind to. Sits above the profile list so a server exists
before it is picked in the OpenCode dropdown. */}
<ModelServersPanel vm={modelServers} />
<ul className="flex list-none flex-col gap-3 p-0">
{vm.entries.map((entry) => (
<ProfileRow
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)}
onDuplicate={
entry.profile.structuredAdapter === "openCode"
? () =>
vm.addOpenCodeProfile({
name: `${entry.profile.name} (${tr("copie", "copy")})`,
opencode: entry.profile.opencode,
})
: undefined
}
/>
))}
</ul>
<footer className="flex gap-2">
<Button variant="primary" onClick={() => void finish()} disabled={vm.busy}>
{tr("Enregistrer et continuer", "Save and continue")}
</Button>
</footer>
</div>
</Panel>
);
}
/** One editable candidate row: select, edit command/args, see availability. */
function ProfileRow({
entry,
servers,
providerCatalog,
onToggle,
onChange,
onRemove,
onDuplicate,
}: {
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;
/** Present only for OpenCode rows: clone this row into a new profile (F36). */
onDuplicate?: () => void;
}) {
const { tr } = useI18n();
const { profile, selected, available } = entry;
const errors = validateProfile(profile);
const isOpenCode = profile.structuredAdapter === "openCode";
const canonicalArgsText = profile.args.join(" ");
const [argsText, setArgsText] = useState(canonicalArgsText);
useEffect(() => {
setArgsText(canonicalArgsText);
}, [canonicalArgsText, profile.id]);
function commitArgs() {
onChange({ ...profile, args: parseArgs(argsText) });
}
return (
<li className="flex flex-col gap-2 rounded-md border border-border bg-raised p-3">
<div className="flex items-center gap-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={selected}
onChange={onToggle}
aria-label={tr(`Utiliser ${profile.name}`, `Use ${profile.name}`)}
className="accent-primary"
/>
<strong className="text-sm text-content">{profile.name}</strong>
</label>
<span
aria-label={tr(`Disponibilité de ${profile.name}`, `${profile.name} availability`)}
className={cn(
"text-xs",
available === null
? "text-faint"
: available
? "text-success"
: "text-danger",
)}
>
{available === null ? "—" : available ? tr("✓ installée", "✓ installed") : tr("✗ introuvable", "✗ not found")}
</span>
<div className="ml-auto flex items-center gap-1">
{onDuplicate && (
<Button
size="sm"
aria-label={tr(`Dupliquer ${profile.name}`, `Duplicate ${profile.name}`)}
onClick={onDuplicate}
>
{tr("Dupliquer", "Duplicate")}
</Button>
)}
<IconButton
size="sm"
aria-label={tr(`Retirer ${profile.name}`, `Remove ${profile.name}`)}
onClick={onRemove}
>
×
</IconButton>
</div>
</div>
{/* F36: an OpenCode profile's name is identity-neutral but user-facing, so
it is editable per profile (several local models coexist). */}
{isOpenCode && (
<label className="flex flex-col gap-1">
<Caption>{tr("Nom", "Name")}</Caption>
<Input
aria-label={tr(`Nom de ${profile.name}`, `Name for ${profile.name}`)}
value={profile.name}
invalid={Boolean(errors.name)}
onChange={(e) => onChange({ ...profile, name: e.target.value })}
/>
{errors.name && <small className="text-xs text-danger">{errors.name}</small>}
</label>
)}
<label className="flex flex-col gap-1">
<Caption>{tr("Commande", "Command")}</Caption>
<Input
aria-label={tr(`Commande de ${profile.name}`, `Command for ${profile.name}`)}
value={profile.command}
invalid={Boolean(errors.command)}
onChange={(e) => onChange({ ...profile, command: e.target.value })}
/>
{errors.command && <small className="text-xs text-danger">{errors.command}</small>}
</label>
<label className="flex flex-col gap-1">
<Caption>{tr("Arguments", "Arguments")}</Caption>
<Input
aria-label={tr(`Arguments de ${profile.name}`, `Arguments for ${profile.name}`)}
value={argsText}
onChange={(e) => setArgsText(e.target.value)}
onBlur={commitArgs}
/>
</label>
{profile.structuredAdapter === "openAiCompatible" && (
<HttpChatFields profile={profile} errors={errors} onChange={onChange} />
)}
{profile.structuredAdapter === "openCode" && (
<OpenCodeModeFields
profile={profile}
errors={errors}
servers={servers}
providerCatalog={providerCatalog}
onChange={onChange}
/>
)}
{(profile.structuredAdapter === "codex" ||
profile.structuredAdapter === "claude") &&
(selected || profile.model) && (
<CliModelField profile={profile} onChange={onChange} />
)}
</li>
);
}
function CliModelField({
profile,
onChange,
}: {
profile: AgentProfile;
onChange: (p: AgentProfile) => void;
}) {
const { tr } = useI18n();
return (
<label className="flex flex-col gap-1">
<Caption>{tr("Modèle", "Model")}</Caption>
<Input
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
value={profile.model ?? ""}
placeholder={tr("Laisser vide pour le modèle par défaut de la CLI", "Leave empty for the CLI default model")}
onChange={(e) => {
const model = e.target.value.trim();
onChange({
...profile,
model: model.length > 0 ? model : undefined,
});
}}
/>
<small className="text-xs text-faint">
{tr("Facultatif. L’authentification Codex/Claude reste gérée par la CLI.", "Optional. Codex/Claude authentication remains managed by the CLI.")}
</small>
</label>
);
}
/** Parses a number field: blank ⇒ `undefined` (backend applies its default). */
function parseOptInt(raw: string): number | undefined {
const trimmed = raw.trim();
if (trimmed.length === 0) return undefined;
const n = Number(trimmed);
return Number.isFinite(n) ? n : NaN;
}
/**
* The OpenAI-compatible (local/LAN) HTTP config section: endpoint, model, the
* *name* of the API-key env var (never the key), and optional timeouts / tool
* guard. Rendered only for a `openAiCompatible` profile; edits flow back through
* `onChange` as a patched `chatHttp`.
*/
function HttpChatFields({
profile,
errors,
onChange,
}: {
profile: AgentProfile;
errors: ProfileErrors;
onChange: (p: AgentProfile) => void;
}) {
const { tr } = useI18n();
const http = profile.chatHttp ?? defaultHttpChatConfig();
const patch = (next: Partial<HttpChatConfig>) =>
onChange({ ...profile, chatHttp: { ...http, ...next } });
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">
{tr("Modèle local / réseau (compatible OpenAI)", "Local / LAN model (OpenAI-compatible)")}
</legend>
<label className="flex flex-col gap-1">
<Caption>{tr("Point d’accès", "Endpoint")}</Caption>
<Input
aria-label={tr(`Point d’accès de ${profile.name}`, `Endpoint for ${profile.name}`)}
value={http.endpoint}
placeholder="http://localhost:11434/v1"
invalid={Boolean(errors.endpoint)}
onChange={(e) => patch({ endpoint: e.target.value })}
/>
{errors.endpoint && (
<small className="text-xs text-danger">{errors.endpoint}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>{tr("Modèle", "Model")}</Caption>
<Input
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
value={http.model}
placeholder="qwen2.5-coder"
invalid={Boolean(errors.model)}
onChange={(e) => patch({ model: e.target.value })}
/>
{errors.model && <small className="text-xs text-danger">{errors.model}</small>}
</label>
<label className="flex flex-col gap-1">
<Caption>{tr("Clé API — nom de la variable d’environnement (jamais la clé elle-même)", "API key — environment variable name (never the key itself)")}</Caption>
<Input
aria-label={tr(`Variable de clé API de ${profile.name}`, `API key environment variable for ${profile.name}`)}
value={http.apiKeyEnv ?? ""}
placeholder={tr("ex. OPENAI_API_KEY (nom de variable, pas le secret)", "e.g. OPENAI_API_KEY (variable name, not the secret)")}
invalid={Boolean(errors.apiKeyEnv)}
onChange={(e) => {
const v = e.target.value.trim();
patch({ apiKeyEnv: v.length === 0 ? undefined : v });
}}
/>
{errors.apiKeyEnv && (
<small className="text-xs text-danger">{errors.apiKeyEnv}</small>
)}
</label>
<div className="flex flex-wrap gap-2">
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
<Caption>{tr("Délai de requête (ms)", "Request timeout (ms)")}</Caption>
<Input
aria-label={tr(`Délai de requête de ${profile.name}`, `Request timeout for ${profile.name}`)}
value={http.requestTimeoutMs?.toString() ?? ""}
inputMode="numeric"
invalid={Boolean(errors.requestTimeoutMs)}
onChange={(e) => patch({ requestTimeoutMs: parseOptInt(e.target.value) })}
/>
{errors.requestTimeoutMs && (
<small className="text-xs text-danger">{errors.requestTimeoutMs}</small>
)}
</label>
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
<Caption>{tr("Délai de connexion (ms)", "Connect timeout (ms)")}</Caption>
<Input
aria-label={tr(`Délai de connexion de ${profile.name}`, `Connect timeout for ${profile.name}`)}
value={http.connectTimeoutMs?.toString() ?? ""}
inputMode="numeric"
invalid={Boolean(errors.connectTimeoutMs)}
onChange={(e) => patch({ connectTimeoutMs: parseOptInt(e.target.value) })}
/>
{errors.connectTimeoutMs && (
<small className="text-xs text-danger">{errors.connectTimeoutMs}</small>
)}
</label>
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
<Caption>{tr("Nombre maximal d’itérations d’outils", "Max tool iterations")}</Caption>
<Input
aria-label={tr(`Itérations d’outils maximales de ${profile.name}`, `Max tool iterations for ${profile.name}`)}
value={http.maxToolIterations?.toString() ?? ""}
inputMode="numeric"
invalid={Boolean(errors.maxToolIterations)}
onChange={(e) => patch({ maxToolIterations: parseOptInt(e.target.value) })}
/>
{errors.maxToolIterations && (
<small className="text-xs text-danger">{errors.maxToolIterations}</small>
)}
</label>
</div>
</fieldset>
);
}