Merge feature/ticket44-firstrun-keep-profile-info into develop (#44)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -14,7 +14,11 @@ import {
|
|||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
|
|
||||||
import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
|
import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
|
||||||
import type { LocalModelServerConfig, ProfileAvailability } from "@/domain";
|
import type {
|
||||||
|
AgentProfile,
|
||||||
|
LocalModelServerConfig,
|
||||||
|
ProfileAvailability,
|
||||||
|
} from "@/domain";
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { FirstRunWizard } from "./FirstRunWizard";
|
import { FirstRunWizard } from "./FirstRunWizard";
|
||||||
@ -553,3 +557,186 @@ describe("FirstRunWizard reopening after the first run (forceOpen)", () => {
|
|||||||
expect(await screen.findByLabelText("first run setup")).toBeTruthy();
|
expect(await screen.findByLabelText("first run setup")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ticket #44 — reopening the wizard from Settings (`forceOpen` ⇒ edit mode) must
|
||||||
|
// pre-fill the already-configured profiles, pre-selected with their real config,
|
||||||
|
// so a save doesn't silently drop them and generic reference candidates don't
|
||||||
|
// take over. Dedup is by id only; several local OpenCode profiles are preserved.
|
||||||
|
describe("FirstRunWizard edit mode reopening (#44)", () => {
|
||||||
|
const injection = {
|
||||||
|
strategy: "conventionFile" as const,
|
||||||
|
target: "AGENTS.md",
|
||||||
|
};
|
||||||
|
function openCodeProfile(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
baseURL: string,
|
||||||
|
model = "qwen3-coder-30b",
|
||||||
|
): AgentProfile {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
command: "opencode",
|
||||||
|
args: [],
|
||||||
|
contextInjection: injection,
|
||||||
|
detect: "opencode --version",
|
||||||
|
cwdTemplate: "{projectRoot}",
|
||||||
|
structuredAdapter: "openCode",
|
||||||
|
opencode: { baseURL, apiKey: "sk-x", model },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEdit(profile: MockProfileGateway) {
|
||||||
|
const gateways = {
|
||||||
|
profile,
|
||||||
|
modelServer: new MockModelServerGateway(),
|
||||||
|
} as unknown as Gateways;
|
||||||
|
return render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<FirstRunWizard forceOpen />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("pre-fills the configured profiles, pre-selected, with config intact", async () => {
|
||||||
|
const profile = new MockProfileGateway();
|
||||||
|
await profile.configureProfiles([
|
||||||
|
openCodeProfile("cfg-oc-1", "Local A", "http://localhost:9090/v1", "qwen3-coder-14b"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderEdit(profile);
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
// The configured OpenCode comes back checked, with its real endpoint/model.
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText("use Local A") as HTMLInputElement).checked,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText("Local A base url") as HTMLInputElement).value,
|
||||||
|
).toBe("http://localhost:9090/v1");
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText("Local A model") as HTMLInputElement).value,
|
||||||
|
).toBe("qwen3-coder-14b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate a configured reference (matched by id, configured wins)", async () => {
|
||||||
|
// The reference OpenCode (id `mock-opencode`) was edited and is now
|
||||||
|
// configured under the same id — it must appear exactly once, pre-selected.
|
||||||
|
const profile = new MockProfileGateway();
|
||||||
|
await profile.configureProfiles([
|
||||||
|
openCodeProfile("mock-opencode", "OpenCode (edited)", "http://localhost:7777/v1"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderEdit(profile);
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
// Exactly one row for the edited OpenCode; the generic reference name is gone.
|
||||||
|
expect(screen.getAllByText("OpenCode (edited)")).toHaveLength(1);
|
||||||
|
expect(screen.queryByText("OpenCode + llama.cpp")).toBeNull();
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText("use OpenCode (edited)") as HTMLInputElement).checked,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps several distinct OpenCode profiles (same command, different ids)", async () => {
|
||||||
|
const profile = new MockProfileGateway();
|
||||||
|
await profile.configureProfiles([
|
||||||
|
openCodeProfile("oc-1", "Local A", "http://localhost:8080/v1"),
|
||||||
|
openCodeProfile("oc-2", "Local B", "http://localhost:9191/v1"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderEdit(profile);
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
// Both distinct profiles are present and pre-selected, none collapsed.
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText("use Local A") as HTMLInputElement).checked,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText("use Local B") as HTMLInputElement).checked,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText("Local A base url") as HTMLInputElement).value,
|
||||||
|
).toBe("http://localhost:8080/v1");
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText("Local B base url") as HTMLInputElement).value,
|
||||||
|
).toBe("http://localhost:9191/v1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving in edit mode does not drop configured profiles by omission", async () => {
|
||||||
|
const profile = new MockProfileGateway();
|
||||||
|
await profile.configureProfiles([
|
||||||
|
openCodeProfile("oc-1", "Local A", "http://localhost:8080/v1"),
|
||||||
|
openCodeProfile("oc-2", "Local B", "http://localhost:9191/v1"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderEdit(profile);
|
||||||
|
await waitForLoaded();
|
||||||
|
// Save straight away without touching anything.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const saved = await profile.listProfiles();
|
||||||
|
expect(saved.map((p) => p.id).sort()).toEqual(["oc-1", "oc-2"]);
|
||||||
|
// Real config preserved through the round-trip.
|
||||||
|
expect(
|
||||||
|
saved.find((p) => p.id === "oc-1")?.opencode?.baseURL,
|
||||||
|
).toBe("http://localhost:8080/v1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unchecking a configured row removes it on save (deliberate deletion)", async () => {
|
||||||
|
const profile = new MockProfileGateway();
|
||||||
|
await profile.configureProfiles([
|
||||||
|
openCodeProfile("oc-1", "Local A", "http://localhost:8080/v1"),
|
||||||
|
openCodeProfile("oc-2", "Local B", "http://localhost:9191/v1"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderEdit(profile);
|
||||||
|
await waitForLoaded();
|
||||||
|
// Deselect Local B, then save — an explicit removal, not an omission.
|
||||||
|
fireEvent.click(screen.getByLabelText("use Local B"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const saved = await profile.listProfiles();
|
||||||
|
expect(saved.map((p) => p.id)).toEqual(["oc-1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-detection in edit mode does not re-check or uncheck the initial selection", async () => {
|
||||||
|
// A configured Codex (not installed per the mock) reuses the reference id, so
|
||||||
|
// it dedups to one pre-selected row. Claude is installed but only a reference.
|
||||||
|
const profile = new MockProfileGateway();
|
||||||
|
await profile.configureProfiles([
|
||||||
|
{
|
||||||
|
id: "mock-codex",
|
||||||
|
name: "Codex Configured",
|
||||||
|
command: "codex",
|
||||||
|
args: [],
|
||||||
|
contextInjection: injection,
|
||||||
|
detect: "codex --version",
|
||||||
|
cwdTemplate: "{projectRoot}",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderEdit(profile);
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
// Wait until detection has filled availability (Claude reads as installed).
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByLabelText("Claude Code availability").textContent,
|
||||||
|
).toMatch(/installed/),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Selection is untouched by detection: the configured (uninstalled) Codex
|
||||||
|
// stays checked; the installed Claude reference stays UNchecked.
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText("use Codex Configured") as HTMLInputElement).checked,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -57,7 +57,10 @@ export function FirstRunWizard({
|
|||||||
/** Render the wizard even when it is no longer the first run. */
|
/** Render the wizard even when it is no longer the first run. */
|
||||||
forceOpen?: boolean;
|
forceOpen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const vm = useFirstRun();
|
// 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 modelServers = useModelServers();
|
||||||
|
|
||||||
if (vm.isFirstRun === null) return null;
|
if (vm.isFirstRun === null) return null;
|
||||||
|
|||||||
@ -23,18 +23,13 @@ import type {
|
|||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
import { buildWizardEntries, type WizardEntry, type WizardMode } from "./wizardEntries";
|
||||||
|
|
||||||
|
export type { WizardEntry, WizardMode } from "./wizardEntries";
|
||||||
|
|
||||||
/** How long the UI waits for a detection round before giving up on it. */
|
/** How long the UI waits for a detection round before giving up on it. */
|
||||||
export const DETECT_TIMEOUT_MS = 3_000;
|
export const DETECT_TIMEOUT_MS = 3_000;
|
||||||
|
|
||||||
/** 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. */
|
/** What the first-run wizard UI needs from this hook. */
|
||||||
export interface FirstRunViewModel {
|
export interface FirstRunViewModel {
|
||||||
/** Whether the wizard should be shown (null while loading). */
|
/** Whether the wizard should be shown (null while loading). */
|
||||||
@ -77,7 +72,13 @@ function describe(e: unknown): string {
|
|||||||
return String(e);
|
return String(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFirstRun(): FirstRunViewModel {
|
/**
|
||||||
|
* @param mode Which wizard to build (ticket #44): `"firstRun"` (default) for the
|
||||||
|
* very first launch, or `"edit"` when reopened via *Settings ▸ Configure
|
||||||
|
* profiles* — the latter pre-loads and pre-selects the already-configured
|
||||||
|
* profiles and does NOT let detection alter that initial selection.
|
||||||
|
*/
|
||||||
|
export function useFirstRun(mode: WizardMode = "firstRun"): FirstRunViewModel {
|
||||||
const { profile } = useGateways();
|
const { profile } = useGateways();
|
||||||
const [isFirstRun, setIsFirstRun] = useState<boolean | null>(null);
|
const [isFirstRun, setIsFirstRun] = useState<boolean | null>(null);
|
||||||
const [entries, setEntries] = useState<WizardEntry[]>([]);
|
const [entries, setEntries] = useState<WizardEntry[]>([]);
|
||||||
@ -154,15 +155,25 @@ export function useFirstRun(): FirstRunViewModel {
|
|||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
let refs: AgentProfile[] | null = null;
|
let candidates: AgentProfile[] | null = null;
|
||||||
try {
|
try {
|
||||||
const state: FirstRunState = await profile.firstRunState();
|
// In edit mode we also load the already-configured profiles so they come
|
||||||
|
// back pre-selected with their real config (ticket #44). Fetched together
|
||||||
|
// so a single blocking window covers the whole load.
|
||||||
|
const [state, configured]: [FirstRunState, AgentProfile[]] =
|
||||||
|
await Promise.all([
|
||||||
|
profile.firstRunState(),
|
||||||
|
mode === "edit" ? profile.listProfiles() : Promise.resolve([]),
|
||||||
|
]);
|
||||||
setIsFirstRun(state.isFirstRun);
|
setIsFirstRun(state.isFirstRun);
|
||||||
refs = state.referenceProfiles;
|
const built = buildWizardEntries({
|
||||||
// Show the rows immediately (nothing pre-checked yet).
|
mode,
|
||||||
setEntries(
|
configured,
|
||||||
refs.map((p) => ({ profile: p, selected: false, available: null })),
|
references: state.referenceProfiles,
|
||||||
);
|
});
|
||||||
|
candidates = built.map((e) => e.profile);
|
||||||
|
// Show the rows immediately (availability not probed yet).
|
||||||
|
setEntries(built);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(describe(e));
|
setError(describe(e));
|
||||||
setIsFirstRun(false);
|
setIsFirstRun(false);
|
||||||
@ -171,13 +182,18 @@ export function useFirstRun(): FirstRunViewModel {
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-detect once on open, detached: only the CLIs actually installed end
|
// Auto-detect once on open, detached: only fills availability. In firstRun
|
||||||
// up pre-checked. The manual "Detect installed CLIs" button stays available
|
// mode it also pre-checks the installed CLIs (`preselect`); in edit mode it
|
||||||
// to re-probe, whatever happens to this round.
|
// must NOT touch the initial selection — the configured profiles stay
|
||||||
if (refs !== null) {
|
// selected and references stay unselected (ticket #44), so `preselect:false`.
|
||||||
void runDetection(refs, { preselect: true, reportError: false });
|
// The manual "Detect installed CLIs" button stays available to re-probe.
|
||||||
|
if (candidates !== null) {
|
||||||
|
void runDetection(candidates, {
|
||||||
|
preselect: mode === "firstRun",
|
||||||
|
reportError: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [profile, runDetection]);
|
}, [profile, runDetection, mode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void reload();
|
void reload();
|
||||||
|
|||||||
173
frontend/src/features/first-run/wizardEntries.test.ts
Normal file
173
frontend/src/features/first-run/wizardEntries.test.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* #44 — pure construction of the wizard rows. Covers the two modes and the
|
||||||
|
* strict id-only deduplication that keeps several OpenCode profiles distinct.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
import type { AgentProfile } from "@/domain";
|
||||||
|
import { buildWizardEntries } from "./wizardEntries";
|
||||||
|
|
||||||
|
/** Minimal profile factory (only the fields the helper reads/carries). */
|
||||||
|
function profile(id: string, over: Partial<AgentProfile> = {}): AgentProfile {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
command: "claude",
|
||||||
|
args: [],
|
||||||
|
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
|
||||||
|
detect: "claude --version",
|
||||||
|
cwdTemplate: "{projectRoot}",
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const REFERENCES: AgentProfile[] = [
|
||||||
|
profile("mock-claude", { name: "Claude Code", command: "claude" }),
|
||||||
|
profile("mock-codex", { name: "Codex", command: "codex" }),
|
||||||
|
profile("mock-opencode", {
|
||||||
|
name: "OpenCode",
|
||||||
|
command: "opencode",
|
||||||
|
structuredAdapter: "openCode",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("buildWizardEntries — firstRun mode", () => {
|
||||||
|
it("maps references to unselected rows with unknown availability", () => {
|
||||||
|
const entries = buildWizardEntries({
|
||||||
|
mode: "firstRun",
|
||||||
|
configured: [],
|
||||||
|
references: REFERENCES,
|
||||||
|
});
|
||||||
|
expect(entries).toHaveLength(3);
|
||||||
|
expect(entries.every((e) => e.selected === false)).toBe(true);
|
||||||
|
expect(entries.every((e) => e.available === null)).toBe(true);
|
||||||
|
expect(entries.map((e) => e.profile.id)).toEqual([
|
||||||
|
"mock-claude",
|
||||||
|
"mock-codex",
|
||||||
|
"mock-opencode",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores configured profiles entirely in firstRun mode", () => {
|
||||||
|
const entries = buildWizardEntries({
|
||||||
|
mode: "firstRun",
|
||||||
|
configured: [profile("mock-claude")],
|
||||||
|
references: REFERENCES,
|
||||||
|
});
|
||||||
|
// Still exactly the reference rows, all unselected.
|
||||||
|
expect(entries.map((e) => e.profile.id)).toEqual([
|
||||||
|
"mock-claude",
|
||||||
|
"mock-codex",
|
||||||
|
"mock-opencode",
|
||||||
|
]);
|
||||||
|
expect(entries.every((e) => e.selected === false)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildWizardEntries — edit mode", () => {
|
||||||
|
it("puts configured profiles first, pre-selected, config intact", () => {
|
||||||
|
const configured = [
|
||||||
|
profile("cfg-oc-1", {
|
||||||
|
name: "Local A",
|
||||||
|
command: "opencode",
|
||||||
|
structuredAdapter: "openCode",
|
||||||
|
opencode: {
|
||||||
|
baseURL: "http://localhost:9090/v1",
|
||||||
|
apiKey: "sk-a",
|
||||||
|
model: "qwen3-coder-14b",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const entries = buildWizardEntries({
|
||||||
|
mode: "edit",
|
||||||
|
configured,
|
||||||
|
references: REFERENCES,
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = entries[0];
|
||||||
|
expect(first.profile.id).toBe("cfg-oc-1");
|
||||||
|
expect(first.selected).toBe(true);
|
||||||
|
// Real config carried through untouched.
|
||||||
|
expect(first.profile.opencode?.baseURL).toBe("http://localhost:9090/v1");
|
||||||
|
expect(first.profile.opencode?.model).toBe("qwen3-coder-14b");
|
||||||
|
// Then all three references follow, unselected.
|
||||||
|
expect(entries.slice(1).map((e) => e.profile.id)).toEqual([
|
||||||
|
"mock-claude",
|
||||||
|
"mock-codex",
|
||||||
|
"mock-opencode",
|
||||||
|
]);
|
||||||
|
expect(entries.slice(1).every((e) => e.selected === false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates by id only: a configured reference is not shown twice", () => {
|
||||||
|
// The user edited the OpenCode reference and it is now configured under the
|
||||||
|
// SAME id — it must appear once (the configured, pre-selected version wins).
|
||||||
|
const configured = [
|
||||||
|
profile("mock-opencode", {
|
||||||
|
name: "OpenCode (edited)",
|
||||||
|
command: "opencode",
|
||||||
|
structuredAdapter: "openCode",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const entries = buildWizardEntries({
|
||||||
|
mode: "edit",
|
||||||
|
configured,
|
||||||
|
references: REFERENCES,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ocRows = entries.filter((e) => e.profile.id === "mock-opencode");
|
||||||
|
expect(ocRows).toHaveLength(1);
|
||||||
|
expect(ocRows[0].selected).toBe(true);
|
||||||
|
expect(ocRows[0].profile.name).toBe("OpenCode (edited)"); // configured wins
|
||||||
|
// The other references remain (unselected).
|
||||||
|
expect(entries.map((e) => e.profile.id)).toEqual([
|
||||||
|
"mock-opencode",
|
||||||
|
"mock-claude",
|
||||||
|
"mock-codex",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps several distinct OpenCode profiles that share command/adapter", () => {
|
||||||
|
// Same command + structuredAdapter, different ids ⇒ distinct identities: all
|
||||||
|
// preserved. Never matched/collapsed by command/adapter/endpoint/model.
|
||||||
|
const configured = [
|
||||||
|
profile("oc-1", {
|
||||||
|
command: "opencode",
|
||||||
|
structuredAdapter: "openCode",
|
||||||
|
opencode: { baseURL: "http://a/v1", model: "m", apiKey: "" },
|
||||||
|
}),
|
||||||
|
profile("oc-2", {
|
||||||
|
command: "opencode",
|
||||||
|
structuredAdapter: "openCode",
|
||||||
|
opencode: { baseURL: "http://b/v1", model: "m", apiKey: "" },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const entries = buildWizardEntries({
|
||||||
|
mode: "edit",
|
||||||
|
configured,
|
||||||
|
references: REFERENCES,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ocIds = entries
|
||||||
|
.filter((e) => e.profile.structuredAdapter === "openCode")
|
||||||
|
.map((e) => e.profile.id);
|
||||||
|
// Both configured OpenCode + the reference OpenCode (different id) survive.
|
||||||
|
expect(ocIds).toEqual(["oc-1", "oc-2", "mock-opencode"]);
|
||||||
|
expect(entries[0].selected).toBe(true);
|
||||||
|
expect(entries[1].selected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("with no configured profiles behaves like references-only, still unselected", () => {
|
||||||
|
const entries = buildWizardEntries({
|
||||||
|
mode: "edit",
|
||||||
|
configured: [],
|
||||||
|
references: REFERENCES,
|
||||||
|
});
|
||||||
|
expect(entries.map((e) => e.profile.id)).toEqual([
|
||||||
|
"mock-claude",
|
||||||
|
"mock-codex",
|
||||||
|
"mock-opencode",
|
||||||
|
]);
|
||||||
|
expect(entries.every((e) => e.selected === false)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
73
frontend/src/features/first-run/wizardEntries.ts
Normal file
73
frontend/src/features/first-run/wizardEntries.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Pure construction of the first-run/edit wizard rows (ticket #44).
|
||||||
|
*
|
||||||
|
* The wizard runs in one of two **modes**, passed explicitly (never inferred
|
||||||
|
* from `isFirstRun`):
|
||||||
|
*
|
||||||
|
* - `"firstRun"` — the very first launch. Rows come from the selectable
|
||||||
|
* reference catalogue only, all unselected, availability unknown. Detection
|
||||||
|
* later pre-checks the installed CLIs.
|
||||||
|
* - `"edit"` — reopened via *Settings ▸ Configure profiles*. The user already
|
||||||
|
* configured profiles (possibly several local OpenCode ones with distinct
|
||||||
|
* endpoints); those must come back **pre-selected with their real config**, so
|
||||||
|
* a save doesn't silently drop them. Reference profiles are still offered, but
|
||||||
|
* only those not already configured — deduplicated **by `id` only**.
|
||||||
|
*
|
||||||
|
* Deduplication is by `profile.id` and nothing else: several OpenCode profiles
|
||||||
|
* legitimately share the same `command`/`structuredAdapter` while differing in
|
||||||
|
* endpoint/model, so their product identity is the id. A reference is "already
|
||||||
|
* configured" iff a configured profile carries the same id.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentProfile } from "@/domain";
|
||||||
|
|
||||||
|
/** Which wizard the rows are being built for (ticket #44). */
|
||||||
|
export type WizardMode = "firstRun" | "edit";
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the wizard rows for the given mode. Pure — no I/O, no React — so every
|
||||||
|
* invariant (pre-selection, id-only dedup, ordering) is unit-testable.
|
||||||
|
*
|
||||||
|
* `firstRun`: references only, all `selected:false`, `available:null`.
|
||||||
|
* `edit`: the configured profiles first (pre-selected, real config intact), then
|
||||||
|
* the reference profiles whose id is not already configured (`selected:false`).
|
||||||
|
*/
|
||||||
|
export function buildWizardEntries({
|
||||||
|
mode,
|
||||||
|
configured,
|
||||||
|
references,
|
||||||
|
}: {
|
||||||
|
mode: WizardMode;
|
||||||
|
configured: AgentProfile[];
|
||||||
|
references: AgentProfile[];
|
||||||
|
}): WizardEntry[] {
|
||||||
|
if (mode === "firstRun") {
|
||||||
|
return references.map((profile) => ({
|
||||||
|
profile,
|
||||||
|
selected: false,
|
||||||
|
available: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit mode: configured profiles come first, pre-selected and untouched.
|
||||||
|
const configuredIds = new Set(configured.map((p) => p.id));
|
||||||
|
const configuredEntries: WizardEntry[] = configured.map((profile) => ({
|
||||||
|
profile,
|
||||||
|
selected: true,
|
||||||
|
available: null,
|
||||||
|
}));
|
||||||
|
// Then the references not already configured — matched by id ONLY.
|
||||||
|
const referenceEntries: WizardEntry[] = references
|
||||||
|
.filter((p) => !configuredIds.has(p.id))
|
||||||
|
.map((profile) => ({ profile, selected: false, available: null }));
|
||||||
|
|
||||||
|
return [...configuredEntries, ...referenceEntries];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user