diff --git a/apps/desktop/e2e/onboarding.spec.ts b/apps/desktop/e2e/onboarding.spec.ts index 644a640..df8d1d8 100644 --- a/apps/desktop/e2e/onboarding.spec.ts +++ b/apps/desktop/e2e/onboarding.spec.ts @@ -30,7 +30,7 @@ test("a first run explains setup and points out the workspace", async () => { await expect(window.getByRole("heading", { name: "Connect an AI provider" })).toBeVisible(); await expect(window.getByText("LM Studio or Ollama", { exact: false })).toBeVisible(); - await window.getByRole("button", { name: "Continue" }).click(); + await window.getByRole("button", { name: "Continue without AI" }).click(); await expect( window.getByRole("heading", { name: "Everything happens around one page" }), diff --git a/apps/desktop/src/onboarding/FirstRun.test.tsx b/apps/desktop/src/onboarding/FirstRun.test.tsx index 7a41696..ccaaf3a 100644 --- a/apps/desktop/src/onboarding/FirstRun.test.tsx +++ b/apps/desktop/src/onboarding/FirstRun.test.tsx @@ -5,6 +5,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { PublicSettings } from "../../electron/ipc/types"; +import { decideProvider, loadActivationProgress, rememberSetupStep } from "./activation"; import { FirstRun } from "./FirstRun"; const settings: PublicSettings = { @@ -45,6 +46,7 @@ afterEach(() => { container?.remove(); root = undefined; container = undefined; + localStorage.clear(); }); describe("FirstRun", () => { @@ -93,4 +95,139 @@ describe("FirstRun", () => { }); expect(finish).toHaveBeenCalledWith(false); }); + + it("makes continuing without AI explicit and remembers that choice", async () => { + Object.defineProperty(window, "lucid", { + configurable: true, + value: { + credentials: { + status: vi.fn().mockResolvedValue({ + present: false, + storage: "none", + securePersistenceAvailable: true, + }), + }, + settings: { + save: vi.fn().mockResolvedValue({ ok: true, settings }), + }, + }, + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + const view = container; + + await act(async () => { + root?.render(); + await Promise.resolve(); + }); + await act(async () => { + [...view.querySelectorAll("button")] + .find((button) => button.textContent === "Set up Lucid") + ?.click(); + await Promise.resolve(); + }); + + const normalContinue = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Continue", + ); + expect(normalContinue?.disabled).toBe(true); + await act(async () => { + [...view.querySelectorAll("button")] + .find((button) => button.textContent === "Continue without AI") + ?.click(); + await Promise.resolve(); + }); + + expect(container.querySelector("h1")?.textContent).toBe("Everything happens around one page"); + expect(loadActivationProgress().provider).toBe("skipped"); + }); + + it("continues after the selected provider and model are verified", async () => { + Object.defineProperty(window, "lucid", { + configurable: true, + value: { + credentials: { + status: vi.fn().mockResolvedValue({ + present: false, + storage: "none", + securePersistenceAvailable: true, + }), + }, + settings: { + save: vi.fn().mockResolvedValue({ ok: true, settings }), + }, + providers: { + test: vi.fn().mockResolvedValue({ + ok: true, + message: "Connected to LM Studio. 1 model available.", + models: ["qwen/qwen3.8-27b"], + }), + models: vi.fn(), + }, + }, + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + const view = container; + + await act(async () => { + root?.render(); + await Promise.resolve(); + }); + await act(async () => { + [...view.querySelectorAll("button")] + .find((button) => button.textContent === "Set up Lucid") + ?.click(); + await Promise.resolve(); + }); + await act(async () => { + [...view.querySelectorAll("button")] + .find((button) => button.textContent === "Test connection") + ?.click(); + await Promise.resolve(); + }); + + expect( + container.querySelector('.onboarding-readiness[data-state="ready"]')?.textContent, + ).toContain("AI ready"); + const normalContinue = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Continue", + ); + expect(normalContinue?.disabled).toBe(false); + expect(loadActivationProgress().provider).toBe("ready"); + }); + + it("resumes an unfinished verified setup after a restart", async () => { + const ready = decideProvider(loadActivationProgress(), "ready"); + rememberSetupStep(ready, 1); + Object.defineProperty(window, "lucid", { + configurable: true, + value: { + credentials: { + status: vi.fn().mockResolvedValue({ + present: false, + storage: "none", + securePersistenceAvailable: true, + }), + }, + }, + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + await Promise.resolve(); + }); + + expect(container.querySelector("h1")?.textContent).toBe("Connect an AI provider"); + expect(container.querySelector('.onboarding-readiness[data-state="ready"]')).not.toBeNull(); + expect( + [...container.querySelectorAll("button")].find((button) => button.textContent === "Continue") + ?.disabled, + ).toBe(false); + }); }); diff --git a/apps/desktop/src/onboarding/FirstRun.tsx b/apps/desktop/src/onboarding/FirstRun.tsx index 2bccbff..d0a5935 100644 --- a/apps/desktop/src/onboarding/FirstRun.tsx +++ b/apps/desktop/src/onboarding/FirstRun.tsx @@ -1,8 +1,15 @@ import { useEffect, useRef, useState } from "react"; -import type { AiSettings, PublicSettings, SettingsInput } from "../../electron/ipc/types"; +import type { + AiSettings, + ProviderConnectionResult, + ProviderProfile, + PublicSettings, + SettingsInput, +} from "../../electron/ipc/types"; import { ProviderSettings } from "../settings/ProviderSettings"; import { saveSettings } from "../settings/settingsStore"; import { BrandMark } from "../ui/BrandMark"; +import { decideProvider, loadActivationProgress, rememberSetupStep } from "./activation"; interface Props { settings: PublicSettings; @@ -12,6 +19,15 @@ interface Props { const STEP_NAMES = ["Welcome", "Connect AI", "Your workspace"] as const; +const providerIsReady = ( + result: ProviderConnectionResult | undefined, + profile: ProviderProfile, +): boolean => + result?.ok === true && + profile.model.trim().length > 0 && + Array.isArray(result.models) && + result.models.includes(profile.model); + const settingsWithAi = (settings: PublicSettings, ai: AiSettings): SettingsInput => { const selected = ai.profiles.find((profile) => profile.id === ai.defaultProfileId); return { @@ -30,10 +46,12 @@ const settingsWithAi = (settings: PublicSettings, ai: AiSettings): SettingsInput }; export function FirstRun({ settings, onSettingsChange, onFinish }: Props) { - const [step, setStep] = useState(0); + const [step, setStep] = useState<0 | 1 | 2>(() => loadActivationProgress().setupStep); const [ai, setAi] = useState(settings.ai); const [working, setWorking] = useState(false); const [problem, setProblem] = useState(); + const [providerConnection, setProviderConnection] = useState(); + const [activation, setActivation] = useState(loadActivationProgress); const heading = useRef(null); useEffect(() => { @@ -72,9 +90,26 @@ export function FirstRun({ settings, onSettingsChange, onFinish }: Props) { const next = async () => { if (step === 1 && !(await persistAi())) return; - setStep((current) => Math.min(current + 1, STEP_NAMES.length - 1)); + const nextStep = Math.min(step + 1, STEP_NAMES.length - 1) as 0 | 1 | 2; + setStep(nextStep); + setActivation((current) => rememberSetupStep(current, nextStep)); + }; + + const continueWithoutAi = async () => { + if (!(await persistAi())) return; + setActivation((current) => rememberSetupStep(decideProvider(current, "skipped"), 2)); + setStep(2); }; + const providerState = + activation.provider === "ready" + ? "ready" + : providerConnection + ? "attention" + : activation.provider === "skipped" + ? "skipped" + : "unchecked"; + return (
{ + setProviderConnection(result); + setActivation((current) => { + if (providerIsReady(result, profile)) return decideProvider(current, "ready"); + if (current.provider === "ready") return decideProvider(current, "unknown"); + return current; + }); + }} /> +
+