feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
304
frontend/src/features/first-run/FirstRunWizard.tsx
Normal file
304
frontend/src/features/first-run/FirstRunWizard.tsx
Normal file
@ -0,0 +1,304 @@
|
||||
/**
|
||||
* First-run wizard (L5). Shown on the very first IDE launch: it offers the
|
||||
* pre-filled reference profiles (Claude/Codex/Gemini/Aider) with **editable**
|
||||
* commands, lets the user detect which CLIs are installed (✓/✗), add a **custom**
|
||||
* profile, then saves the chosen profiles and closes the first run.
|
||||
*
|
||||
* Pure presentation: all behaviour comes from {@link useFirstRun} (the
|
||||
* {@link ProfileGateway} port). Custom-profile validation is the pure logic in
|
||||
* `./profile`.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { AgentProfile, InjectionStrategy } from "@/domain";
|
||||
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
||||
import { useFirstRun, type WizardEntry } from "./useFirstRun";
|
||||
import {
|
||||
defaultInjection,
|
||||
emptyCustomProfile,
|
||||
isProfileValid,
|
||||
parseArgs,
|
||||
validateProfile,
|
||||
} from "./profile";
|
||||
|
||||
/** Shared classes for the native `<select>` so it matches the Input control. */
|
||||
const SELECT_CLASS =
|
||||
"h-9 w-full rounded-md border border-border bg-raised px-3 text-sm text-content " +
|
||||
"outline-none focus:border-primary";
|
||||
|
||||
/** 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 vm = useFirstRun();
|
||||
|
||||
if (vm.isFirstRun === null) return null;
|
||||
if (!forceOpen && vm.isFirstRun === false) return null;
|
||||
|
||||
async function finish() {
|
||||
await vm.finish();
|
||||
onDone?.();
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
aria-label="first run setup"
|
||||
className="border-primary/40"
|
||||
title={
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-base font-semibold text-content">Welcome to IdeA</h2>
|
||||
<p className="text-sm text-muted">
|
||||
Choose which AI CLIs to configure. Commands are pre-filled and
|
||||
editable; you can also add your own.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{vm.error && (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Toolbar>
|
||||
<Button
|
||||
onClick={() => void vm.detect()}
|
||||
disabled={vm.busy}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Detect installed CLIs
|
||||
</Button>
|
||||
</Toolbar>
|
||||
|
||||
<ul className="flex list-none flex-col gap-3 p-0">
|
||||
{vm.entries.map((entry) => (
|
||||
<ProfileRow
|
||||
key={entry.profile.id}
|
||||
entry={entry}
|
||||
onToggle={() => vm.toggle(entry.profile.id)}
|
||||
onChange={(p) => vm.updateProfile(entry.profile.id, p)}
|
||||
onRemove={() => vm.remove(entry.profile.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<AddCustomProfile onAdd={(p) => vm.addCustom(p)} />
|
||||
|
||||
<footer className="flex gap-2">
|
||||
<Button variant="primary" onClick={() => void finish()} disabled={vm.busy}>
|
||||
Save and continue
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
/** One editable candidate row: select, edit command/args, see availability. */
|
||||
function ProfileRow({
|
||||
entry,
|
||||
onToggle,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
entry: WizardEntry;
|
||||
onToggle: () => void;
|
||||
onChange: (p: AgentProfile) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { profile, selected, available } = entry;
|
||||
const errors = validateProfile(profile);
|
||||
|
||||
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={`use ${profile.name}`}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<strong className="text-sm text-content">{profile.name}</strong>
|
||||
</label>
|
||||
<span
|
||||
aria-label={`${profile.name} availability`}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
available === null
|
||||
? "text-faint"
|
||||
: available
|
||||
? "text-success"
|
||||
: "text-danger",
|
||||
)}
|
||||
>
|
||||
{available === null ? "—" : available ? "✓ installed" : "✗ not found"}
|
||||
</span>
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={`remove ${profile.name}`}
|
||||
onClick={onRemove}
|
||||
className="ml-auto"
|
||||
>
|
||||
×
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Command</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} command`}
|
||||
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>Arguments</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} args`}
|
||||
value={profile.args.join(" ")}
|
||||
onChange={(e) => onChange({ ...profile, args: parseArgs(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline form to add a custom profile, validated before it is accepted. */
|
||||
function AddCustomProfile({ onAdd }: { onAdd: (p: AgentProfile) => void }) {
|
||||
const [draft, setDraft] = useState<AgentProfile>(emptyCustomProfile());
|
||||
const errors = validateProfile(draft);
|
||||
const valid = isProfileValid(draft);
|
||||
const ci = draft.contextInjection;
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!valid) return;
|
||||
onAdd(draft);
|
||||
setDraft(emptyCustomProfile());
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
aria-label="add custom profile"
|
||||
className="flex flex-col gap-2 rounded-md border border-dashed border-border-strong p-3"
|
||||
>
|
||||
<strong className="text-sm text-content">Add a custom profile</strong>
|
||||
|
||||
<Input
|
||||
aria-label="custom name"
|
||||
placeholder="Name"
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
aria-label="custom command"
|
||||
placeholder="Command (e.g. my-ai)"
|
||||
value={draft.command}
|
||||
onChange={(e) => setDraft({ ...draft, command: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
aria-label="custom args"
|
||||
placeholder="Arguments (space-separated)"
|
||||
value={draft.args.join(" ")}
|
||||
onChange={(e) => setDraft({ ...draft, args: parseArgs(e.target.value) })}
|
||||
/>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Context injection</Caption>
|
||||
<select
|
||||
aria-label="injection strategy"
|
||||
className={SELECT_CLASS}
|
||||
value={ci.strategy}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
contextInjection: defaultInjection(
|
||||
e.target.value as InjectionStrategy,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="conventionFile">Convention file</option>
|
||||
<option value="flag">Flag</option>
|
||||
<option value="stdin">Stdin</option>
|
||||
<option value="env">Environment variable</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{ci.strategy === "conventionFile" && (
|
||||
<Input
|
||||
aria-label="injection target"
|
||||
placeholder="Target file (e.g. CONTEXT.md)"
|
||||
value={ci.target}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
contextInjection: { strategy: "conventionFile", target: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{ci.strategy === "flag" && (
|
||||
<Input
|
||||
aria-label="injection flag"
|
||||
placeholder="Flag (e.g. --context-file {path})"
|
||||
value={ci.flag}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
contextInjection: { strategy: "flag", flag: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{ci.strategy === "env" && (
|
||||
<Input
|
||||
aria-label="injection var"
|
||||
placeholder="Env var (e.g. AGENT_CONTEXT_FILE)"
|
||||
value={ci.var}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
contextInjection: { strategy: "env", var: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{Object.values(errors).map((msg) => (
|
||||
<small key={msg} className="text-xs text-danger">
|
||||
{msg}
|
||||
</small>
|
||||
))}
|
||||
|
||||
<Button type="submit" variant="primary" disabled={!valid}>
|
||||
Add custom profile
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user