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:
227
frontend/src/features/first-run/FirstRunWizard.test.tsx
Normal file
227
frontend/src/features/first-run/FirstRunWizard.test.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
/**
|
||||
* L5 — the first-run wizard wired to the stateful {@link MockProfileGateway} via
|
||||
* the real {@link DIProvider}. Covers: pre-filled editable rows, detection ✓/✗,
|
||||
* adding a custom profile, and finishing (configure ⇒ first run closed ⇒ onDone).
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
} from "@testing-library/react";
|
||||
|
||||
import { MockProfileGateway } from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { FirstRunWizard } from "./FirstRunWizard";
|
||||
|
||||
function renderWizard(
|
||||
profile: MockProfileGateway = new MockProfileGateway(),
|
||||
onDone = vi.fn(),
|
||||
) {
|
||||
const gateways = { profile } as unknown as Gateways;
|
||||
return {
|
||||
profile,
|
||||
onDone,
|
||||
...render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<FirstRunWizard onDone={onDone} />
|
||||
</DIProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForLoaded() {
|
||||
await screen.findByLabelText("first run setup");
|
||||
}
|
||||
|
||||
describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
it("renders the four pre-filled, editable reference profiles", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
for (const name of ["Claude Code", "OpenAI Codex CLI", "Gemini CLI", "Aider"]) {
|
||||
expect(screen.getByText(name)).toBeTruthy();
|
||||
}
|
||||
// Commands are editable inputs, pre-filled.
|
||||
const claudeCmd = screen.getByLabelText(
|
||||
"Claude Code command",
|
||||
) as HTMLInputElement;
|
||||
expect(claudeCmd.value).toBe("claude");
|
||||
});
|
||||
|
||||
it("editing a command updates the input value", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
const cmd = screen.getByLabelText("Aider command") as HTMLInputElement;
|
||||
fireEvent.change(cmd, { target: { value: "aider-2" } });
|
||||
expect(cmd.value).toBe("aider-2");
|
||||
});
|
||||
|
||||
it("detection shows ✓ for claude and ✗ for the rest", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Detect installed CLIs" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByLabelText("Claude Code availability").textContent,
|
||||
).toMatch(/installed/);
|
||||
});
|
||||
expect(screen.getByLabelText("Aider availability").textContent).toMatch(
|
||||
/not found/,
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a valid custom profile as a new row", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
const form = screen.getByLabelText("add custom profile");
|
||||
fireEvent.change(within(form).getByLabelText("custom name"), {
|
||||
target: { value: "My AI" },
|
||||
});
|
||||
fireEvent.change(within(form).getByLabelText("custom command"), {
|
||||
target: { value: "my-ai" },
|
||||
});
|
||||
fireEvent.click(
|
||||
within(form).getByRole("button", { name: "Add custom profile" }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText("My AI")).toBeTruthy();
|
||||
// The custom command input now exists as a row.
|
||||
expect((screen.getByLabelText("My AI command") as HTMLInputElement).value).toBe(
|
||||
"my-ai",
|
||||
);
|
||||
});
|
||||
|
||||
it("add button is disabled until the custom draft is valid", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
const form = screen.getByLabelText("add custom profile");
|
||||
const addBtn = within(form).getByRole("button", {
|
||||
name: "Add custom profile",
|
||||
}) as HTMLButtonElement;
|
||||
expect(addBtn.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(within(form).getByLabelText("custom name"), {
|
||||
target: { value: "X" },
|
||||
});
|
||||
fireEvent.change(within(form).getByLabelText("custom command"), {
|
||||
target: { value: "x" },
|
||||
});
|
||||
expect(addBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-detects on open and pre-checks only installed CLIs", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
// The mock reports only `claude` as installed → only it is pre-checked.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
||||
).toBe(true),
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText("use OpenAI Codex CLI") as HTMLInputElement).checked,
|
||||
).toBe(false);
|
||||
expect(
|
||||
(screen.getByLabelText("use Aider") as HTMLInputElement).checked,
|
||||
).toBe(false);
|
||||
// Availability was filled automatically, without clicking the button.
|
||||
expect(
|
||||
screen.getByLabelText("Claude Code availability").textContent,
|
||||
).toMatch(/installed/);
|
||||
});
|
||||
|
||||
it("finishing persists the auto-selected (installed) profiles and closes the first run", async () => {
|
||||
const { profile, onDone } = renderWizard();
|
||||
await waitForLoaded();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
||||
).toBe(true),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText("first run setup")).toBeNull(),
|
||||
);
|
||||
|
||||
// Only the installed CLI was pre-selected, so only it is persisted.
|
||||
const saved = await profile.listProfiles();
|
||||
expect(saved.map((p) => p.command)).toEqual(["claude"]);
|
||||
expect((await profile.firstRunState()).isFirstRun).toBe(false);
|
||||
});
|
||||
|
||||
it("ticking an extra profile persists it alongside the installed ones", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
||||
).toBe(true),
|
||||
);
|
||||
|
||||
// Keep Aider too (not installed, unchecked by default).
|
||||
fireEvent.click(screen.getByLabelText("use Aider"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
expect(saved.map((p) => p.command)).toEqual(["claude", "aider"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders nothing when it is not the first run", async () => {
|
||||
const profile = new MockProfileGateway();
|
||||
await profile.configureProfiles([]); // closes first run
|
||||
const { container } = renderWizard(profile);
|
||||
|
||||
// No wizard section ever appears.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText("first run setup")).toBeNull(),
|
||||
);
|
||||
expect(container.querySelector("section")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FirstRunWizard reopening after the first run (forceOpen)", () => {
|
||||
/** A gateway whose first run is already done (profiles configured). */
|
||||
async function configuredGateway() {
|
||||
const profile = new MockProfileGateway();
|
||||
await profile.configureProfiles([]); // marks first run as done
|
||||
return profile;
|
||||
}
|
||||
|
||||
it("stays hidden once the first run is done (default)", async () => {
|
||||
const profile = await configuredGateway();
|
||||
const gateways = { profile } as unknown as Gateways;
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<FirstRunWizard />
|
||||
</DIProvider>,
|
||||
);
|
||||
// Give the async first-run state time to resolve to `false`.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText("first run setup")).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders when forced open (Settings ▸ Configure profiles)", async () => {
|
||||
const profile = await configuredGateway();
|
||||
const gateways = { profile } as unknown as Gateways;
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<FirstRunWizard forceOpen />
|
||||
</DIProvider>,
|
||||
);
|
||||
expect(await screen.findByLabelText("first run setup")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
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>
|
||||
);
|
||||
}
|
||||
102
frontend/src/features/first-run/ProfilesSettings.tsx
Normal file
102
frontend/src/features/first-run/ProfilesSettings.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Minimal "Settings → AI Profiles" panel (L5). An always-available entry point
|
||||
* to review the configured profiles and re-run the setup wizard after the first
|
||||
* run. Kept intentionally small; richer per-profile editing reuses the wizard.
|
||||
*
|
||||
* Pure presentation over the {@link ProfileGateway} port (no `invoke()`).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { AgentProfile, GatewayError } from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { Button, Panel } from "@/shared";
|
||||
import { FirstRunWizard } from "./FirstRunWizard";
|
||||
|
||||
export function ProfilesSettings() {
|
||||
const { profile } = useGateways();
|
||||
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
setProfiles(await profile.listProfiles());
|
||||
} catch (e) {
|
||||
setError(
|
||||
e && typeof e === "object" && "message" in e
|
||||
? String((e as GatewayError).message)
|
||||
: String(e),
|
||||
);
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function del(id: string) {
|
||||
await profile.deleteProfile(id);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
// Reopened after the first run, so force the wizard to render.
|
||||
return (
|
||||
<FirstRunWizard
|
||||
forceOpen
|
||||
onDone={() => {
|
||||
setEditing(false);
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
aria-label="ai profiles settings"
|
||||
title="AI Profiles"
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setEditing(true)}>
|
||||
Configure profiles
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{profiles.length === 0 ? (
|
||||
<p className="text-sm text-muted">No profiles configured.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{profiles.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0"
|
||||
>
|
||||
<span className="flex items-baseline gap-2">
|
||||
<strong className="text-sm text-content">{p.name}</strong>
|
||||
<code className="text-xs text-muted">{p.command}</code>
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`delete ${p.name}`}
|
||||
onClick={() => void del(p.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
19
frontend/src/features/first-run/index.ts
Normal file
19
frontend/src/features/first-run/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* First-run & AI-profiles feature (L5). Public surface: the wizard, the settings
|
||||
* panel, the view-model hook, and the pure profile helpers.
|
||||
*/
|
||||
|
||||
export { FirstRunWizard } from "./FirstRunWizard";
|
||||
export { ProfilesSettings } from "./ProfilesSettings";
|
||||
export { useFirstRun, type FirstRunViewModel, type WizardEntry } from "./useFirstRun";
|
||||
export {
|
||||
validateProfile,
|
||||
isProfileValid,
|
||||
isRelativeSafe,
|
||||
isValidEnvVar,
|
||||
defaultInjection,
|
||||
emptyCustomProfile,
|
||||
newProfileId,
|
||||
parseArgs,
|
||||
type ProfileErrors,
|
||||
} from "./profile";
|
||||
131
frontend/src/features/first-run/profile.test.ts
Normal file
131
frontend/src/features/first-run/profile.test.ts
Normal file
@ -0,0 +1,131 @@
|
||||
/**
|
||||
* L5 — pure first-run profile logic: validation mirrors of the backend invariants
|
||||
* (`validateProfile`/`isProfileValid`), `isRelativeSafe`, `isValidEnvVar`,
|
||||
* `defaultInjection`, `emptyCustomProfile`, and `parseArgs`.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import type { AgentProfile } from "@/domain";
|
||||
import {
|
||||
defaultInjection,
|
||||
emptyCustomProfile,
|
||||
isProfileValid,
|
||||
isRelativeSafe,
|
||||
isValidEnvVar,
|
||||
parseArgs,
|
||||
validateProfile,
|
||||
} from "./profile";
|
||||
|
||||
function base(overrides: Partial<AgentProfile> = {}): AgentProfile {
|
||||
return {
|
||||
id: "p1",
|
||||
name: "Claude",
|
||||
command: "claude",
|
||||
args: [],
|
||||
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
|
||||
detect: null,
|
||||
cwdTemplate: "{projectRoot}",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isRelativeSafe", () => {
|
||||
it("accepts a plain relative file name", () => {
|
||||
expect(isRelativeSafe("CLAUDE.md")).toBe(true);
|
||||
expect(isRelativeSafe("docs/AGENTS.md")).toBe(true);
|
||||
});
|
||||
it("rejects empty, absolute, traversal and drive paths", () => {
|
||||
expect(isRelativeSafe("")).toBe(false);
|
||||
expect(isRelativeSafe("/etc/passwd")).toBe(false);
|
||||
expect(isRelativeSafe("\\windows")).toBe(false);
|
||||
expect(isRelativeSafe("../secret")).toBe(false);
|
||||
expect(isRelativeSafe("a/../b")).toBe(false);
|
||||
expect(isRelativeSafe("C:/tmp")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidEnvVar", () => {
|
||||
it("accepts valid identifiers", () => {
|
||||
expect(isValidEnvVar("AGENT_CONTEXT")).toBe(true);
|
||||
expect(isValidEnvVar("_x1")).toBe(true);
|
||||
});
|
||||
it("rejects invalid identifiers", () => {
|
||||
expect(isValidEnvVar("")).toBe(false);
|
||||
expect(isValidEnvVar("1ABC")).toBe(false);
|
||||
expect(isValidEnvVar("HAS-DASH")).toBe(false);
|
||||
expect(isValidEnvVar("has space")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateProfile / isProfileValid", () => {
|
||||
it("a well-formed profile is valid", () => {
|
||||
expect(validateProfile(base())).toEqual({});
|
||||
expect(isProfileValid(base())).toBe(true);
|
||||
});
|
||||
it("blank name and command are reported", () => {
|
||||
const errors = validateProfile(base({ name: " ", command: "" }));
|
||||
expect(errors.name).toBeDefined();
|
||||
expect(errors.command).toBeDefined();
|
||||
expect(isProfileValid(base({ name: "" }))).toBe(false);
|
||||
});
|
||||
it("convention file target must be relative-safe", () => {
|
||||
const errors = validateProfile(
|
||||
base({ contextInjection: { strategy: "conventionFile", target: "../x" } }),
|
||||
);
|
||||
expect(errors.target).toBeDefined();
|
||||
});
|
||||
it("flag must be non-empty", () => {
|
||||
const errors = validateProfile(
|
||||
base({ contextInjection: { strategy: "flag", flag: " " } }),
|
||||
);
|
||||
expect(errors.flag).toBeDefined();
|
||||
});
|
||||
it("env var must be a valid identifier", () => {
|
||||
const errors = validateProfile(
|
||||
base({ contextInjection: { strategy: "env", var: "1bad" } }),
|
||||
);
|
||||
expect(errors.var).toBeDefined();
|
||||
});
|
||||
it("stdin needs no extra field", () => {
|
||||
expect(
|
||||
isProfileValid(base({ contextInjection: { strategy: "stdin" } })),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultInjection", () => {
|
||||
it("produces a sensible default per strategy", () => {
|
||||
expect(defaultInjection("conventionFile")).toEqual({
|
||||
strategy: "conventionFile",
|
||||
target: "CONTEXT.md",
|
||||
});
|
||||
expect(defaultInjection("flag")).toEqual({
|
||||
strategy: "flag",
|
||||
flag: "--context-file {path}",
|
||||
});
|
||||
expect(defaultInjection("env")).toEqual({
|
||||
strategy: "env",
|
||||
var: "AGENT_CONTEXT_FILE",
|
||||
});
|
||||
expect(defaultInjection("stdin")).toEqual({ strategy: "stdin" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("emptyCustomProfile", () => {
|
||||
it("is a blank, invalid draft with a fresh id", () => {
|
||||
const a = emptyCustomProfile();
|
||||
const b = emptyCustomProfile();
|
||||
expect(a.name).toBe("");
|
||||
expect(a.command).toBe("");
|
||||
expect(isProfileValid(a)).toBe(false);
|
||||
expect(a.id).not.toEqual(b.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseArgs", () => {
|
||||
it("splits on whitespace, trims and drops empties", () => {
|
||||
expect(parseArgs(" --foo bar\t baz ")).toEqual(["--foo", "bar", "baz"]);
|
||||
expect(parseArgs("")).toEqual([]);
|
||||
expect(parseArgs(" ")).toEqual([]);
|
||||
});
|
||||
});
|
||||
103
frontend/src/features/first-run/profile.ts
Normal file
103
frontend/src/features/first-run/profile.ts
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Pure, framework-free helpers for the first-run wizard (testable without React
|
||||
* or Tauri, ARCHITECTURE §1.3). Validation of a custom/edited profile and small
|
||||
* factories live here; the components only render.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentProfile,
|
||||
ContextInjection,
|
||||
InjectionStrategy,
|
||||
} from "@/domain";
|
||||
|
||||
/** A field-keyed validation error map (empty ⇒ valid). */
|
||||
export type ProfileErrors = Partial<
|
||||
Record<"name" | "command" | "target" | "flag" | "var", string>
|
||||
>;
|
||||
|
||||
/** Whether a path is a relative, traversal-free file name (mirror of backend). */
|
||||
export function isRelativeSafe(path: string): boolean {
|
||||
if (path.length === 0) return false;
|
||||
if (path.startsWith("/") || path.startsWith("\\")) return false;
|
||||
// Windows drive (C:) / UNC.
|
||||
if (/^[a-zA-Z]:/.test(path)) return false;
|
||||
return !path.split(/[/\\]/).includes("..");
|
||||
}
|
||||
|
||||
/** Whether a string is a valid environment-variable identifier. */
|
||||
export function isValidEnvVar(v: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a profile draft the way the backend would, so the wizard can surface
|
||||
* errors before any `invoke`. Returns an empty object when the draft is valid.
|
||||
*/
|
||||
export function validateProfile(p: AgentProfile): ProfileErrors {
|
||||
const errors: ProfileErrors = {};
|
||||
if (p.name.trim().length === 0) errors.name = "Name is required.";
|
||||
if (p.command.trim().length === 0) errors.command = "Command is required.";
|
||||
|
||||
const ci = p.contextInjection;
|
||||
if (ci.strategy === "conventionFile") {
|
||||
if (!isRelativeSafe(ci.target)) {
|
||||
errors.target = "Target must be a relative file name (no .. or absolute).";
|
||||
}
|
||||
} else if (ci.strategy === "flag") {
|
||||
if (ci.flag.trim().length === 0) errors.flag = "Flag is required.";
|
||||
} else if (ci.strategy === "env") {
|
||||
if (!isValidEnvVar(ci.var)) errors.var = "Must be a valid env var identifier.";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Whether a profile draft is valid (no errors). */
|
||||
export function isProfileValid(p: AgentProfile): boolean {
|
||||
return Object.keys(validateProfile(p)).length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a default {@link ContextInjection} for a strategy, so switching the
|
||||
* strategy dropdown produces a sensible editable shape.
|
||||
*/
|
||||
export function defaultInjection(strategy: InjectionStrategy): ContextInjection {
|
||||
switch (strategy) {
|
||||
case "conventionFile":
|
||||
return { strategy, target: "CONTEXT.md" };
|
||||
case "flag":
|
||||
return { strategy, flag: "--context-file {path}" };
|
||||
case "env":
|
||||
return { strategy, var: "AGENT_CONTEXT_FILE" };
|
||||
case "stdin":
|
||||
return { strategy };
|
||||
}
|
||||
}
|
||||
|
||||
/** A fresh, empty custom-profile draft (id minted client-side). */
|
||||
export function emptyCustomProfile(): AgentProfile {
|
||||
return {
|
||||
id: newProfileId(),
|
||||
name: "",
|
||||
command: "",
|
||||
args: [],
|
||||
contextInjection: { strategy: "conventionFile", target: "CONTEXT.md" },
|
||||
detect: null,
|
||||
cwdTemplate: "{projectRoot}",
|
||||
};
|
||||
}
|
||||
|
||||
/** Generates a UUID for a client-created profile (crypto when available). */
|
||||
export function newProfileId(): string {
|
||||
const c = globalThis.crypto as Crypto | undefined;
|
||||
if (c && typeof c.randomUUID === "function") return c.randomUUID();
|
||||
// Fallback for non-secure contexts/tests.
|
||||
return `profile-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/** Parses a whitespace-separated args string into a trimmed, non-empty list. */
|
||||
export function parseArgs(raw: string): string[] {
|
||||
return raw
|
||||
.split(/\s+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
178
frontend/src/features/first-run/useFirstRun.ts
Normal file
178
frontend/src/features/first-run/useFirstRun.ts
Normal file
@ -0,0 +1,178 @@
|
||||
/**
|
||||
* `useFirstRun` — view-model for the first-run wizard and profile management
|
||||
* (L5). Owns the wizard state (reference candidates + selection + availability +
|
||||
* custom profiles) and the actions. Consumes the {@link ProfileGateway}
|
||||
* exclusively — never `invoke()` — so the feature is testable with a mock.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type {
|
||||
AgentProfile,
|
||||
FirstRunState,
|
||||
GatewayError,
|
||||
ProfileAvailability,
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
/** One row in the wizard: a candidate profile, selected state, availability. */
|
||||
export interface WizardEntry {
|
||||
profile: AgentProfile;
|
||||
selected: boolean;
|
||||
/** `null` until detection ran; then `true`/`false`. */
|
||||
available: boolean | null;
|
||||
}
|
||||
|
||||
/** What the first-run wizard UI needs from this hook. */
|
||||
export interface FirstRunViewModel {
|
||||
/** Whether the wizard should be shown (null while loading). */
|
||||
isFirstRun: boolean | null;
|
||||
/** The candidate rows (reference + added custom profiles). */
|
||||
entries: WizardEntry[];
|
||||
/** Last error message, or null. */
|
||||
error: string | null;
|
||||
/** Whether a request is in flight. */
|
||||
busy: boolean;
|
||||
/** Toggles selection of a candidate by id. */
|
||||
toggle: (id: string) => void;
|
||||
/** Replaces a candidate's profile (edited command/args/injection). */
|
||||
updateProfile: (id: string, profile: AgentProfile) => void;
|
||||
/** Adds a custom profile (selected by default). */
|
||||
addCustom: (profile: AgentProfile) => void;
|
||||
/** Removes a candidate by id (e.g. a custom one). */
|
||||
remove: (id: string) => void;
|
||||
/** Runs detection over all candidates, filling availability. */
|
||||
detect: () => Promise<void>;
|
||||
/** Persists the selected profiles and closes the wizard. */
|
||||
finish: () => Promise<void>;
|
||||
/** Re-checks first-run state (e.g. reopening profile management). */
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
export function useFirstRun(): FirstRunViewModel {
|
||||
const { profile } = useGateways();
|
||||
const [isFirstRun, setIsFirstRun] = useState<boolean | null>(null);
|
||||
const [entries, setEntries] = useState<WizardEntry[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const state: FirstRunState = await profile.firstRunState();
|
||||
setIsFirstRun(state.isFirstRun);
|
||||
const refs = state.referenceProfiles;
|
||||
// Show the rows immediately (nothing pre-checked yet).
|
||||
setEntries(
|
||||
refs.map((p) => ({ profile: p, selected: false, available: null })),
|
||||
);
|
||||
// Auto-detect once on open: only the CLIs actually installed end up
|
||||
// pre-checked. The manual "Detect installed CLIs" button stays available to
|
||||
// re-probe. If detection is unavailable, rows stay unchecked (the user can
|
||||
// still tick them by hand and re-run detection).
|
||||
try {
|
||||
const results = await profile.detectProfiles(refs);
|
||||
const byId = new Map(results.map((r) => [r.profile.id, r.available]));
|
||||
setEntries(
|
||||
refs.map((p) => {
|
||||
const available = byId.get(p.id) ?? null;
|
||||
return { profile: p, selected: available === true, available };
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* detection unavailable: leave rows unchecked */
|
||||
}
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
setIsFirstRun(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const toggle = useCallback((id: string) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.profile.id === id ? { ...e, selected: !e.selected } : e,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const updateProfile = useCallback((id: string, next: AgentProfile) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => (e.profile.id === id ? { ...e, profile: next } : e)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const addCustom = useCallback((p: AgentProfile) => {
|
||||
setEntries((prev) => [
|
||||
...prev,
|
||||
{ profile: p, selected: true, available: null },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
setEntries((prev) => prev.filter((e) => e.profile.id !== id));
|
||||
}, []);
|
||||
|
||||
const detect = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const candidates = entries.map((e) => e.profile);
|
||||
const results: ProfileAvailability[] =
|
||||
await profile.detectProfiles(candidates);
|
||||
const byId = new Map(results.map((r) => [r.profile.id, r.available]));
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => ({
|
||||
...e,
|
||||
available: byId.get(e.profile.id) ?? e.available,
|
||||
})),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [profile, entries]);
|
||||
|
||||
const finish = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const chosen = entries.filter((e) => e.selected).map((e) => e.profile);
|
||||
await profile.configureProfiles(chosen);
|
||||
setIsFirstRun(false);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [profile, entries]);
|
||||
|
||||
return {
|
||||
isFirstRun,
|
||||
entries,
|
||||
error,
|
||||
busy,
|
||||
toggle,
|
||||
updateProfile,
|
||||
addCustom,
|
||||
remove,
|
||||
detect,
|
||||
finish,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user