Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/e2e/onboarding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand Down
137 changes: 137 additions & 0 deletions apps/desktop/src/onboarding/FirstRun.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -45,6 +46,7 @@ afterEach(() => {
container?.remove();
root = undefined;
container = undefined;
localStorage.clear();
});

describe("FirstRun", () => {
Expand Down Expand Up @@ -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(<FirstRun settings={settings} onSettingsChange={vi.fn()} onFinish={vi.fn()} />);
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(<FirstRun settings={settings} onSettingsChange={vi.fn()} onFinish={vi.fn()} />);
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(<FirstRun settings={settings} onSettingsChange={vi.fn()} onFinish={vi.fn()} />);
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);
});
});
115 changes: 102 additions & 13 deletions apps/desktop/src/onboarding/FirstRun.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand All @@ -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<string>();
const [providerConnection, setProviderConnection] = useState<ProviderConnectionResult>();
const [activation, setActivation] = useState(loadActivationProgress);
const heading = useRef<HTMLHeadingElement>(null);

useEffect(() => {
Expand Down Expand Up @@ -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 (
<div className="modal-backdrop onboarding-backdrop">
<section
Expand Down Expand Up @@ -153,9 +188,43 @@ export function FirstRun({ settings, onSettingsChange, onFinish }: Props) {
lmStudioUrlLocked={settings.llmUrlLocked}
onChange={setAi}
onPersist={persistAi}
onConnectionChange={(result, profile) => {
setProviderConnection(result);
setActivation((current) => {
if (providerIsReady(result, profile)) return decideProvider(current, "ready");
if (current.provider === "ready") return decideProvider(current, "unknown");
return current;
});
}}
/>
<div
className="onboarding-readiness"
data-state={providerState}
role="status"
aria-live="polite"
>
<i aria-hidden="true" />
<span>
<strong>
{providerState === "ready"
? "AI ready"
: providerState === "skipped"
? "Continuing without AI"
: providerState === "attention"
? "Connection needs attention"
: "Check your provider before continuing"}
</strong>
{providerState === "ready"
? " The selected model was found."
: providerState === "skipped"
? " You can connect one later in Settings."
: providerState === "attention"
? " Follow the message above, then test it again."
: " Use Test connection after choosing a model."}
</span>
</div>
<p className="onboarding-note">
You can continue without AI. Notes, manual editing, import and export still work.
AI is optional. Notes, manual editing, import and export still work without it.
</p>
</div>
)}
Expand Down Expand Up @@ -217,27 +286,47 @@ export function FirstRun({ settings, onSettingsChange, onFinish }: Props) {
className="ghost"
disabled={working}
onClick={() => {
setStep((current) => current - 1);
const previous = (step - 1) as 0 | 1;
setStep(previous);
setActivation((current) => rememberSetupStep(current, previous));
}}
>
Back
</button>
)}
<span />
{step < STEP_NAMES.length - 1 ? (
<button
type="button"
className="primary"
disabled={working}
onClick={() => void next()}
>
{working ? "Saving…" : step === 0 ? "Set up Lucid" : "Continue"}
</button>
<>
{step === 1 && activation.provider !== "ready" && (
<button
type="button"
className="ghost"
disabled={working}
onClick={() => void continueWithoutAi()}
>
Continue without AI
</button>
)}
<button
type="button"
className="primary"
disabled={
working ||
(step === 1 &&
activation.provider !== "ready" &&
activation.provider !== "skipped")
}
onClick={() => void next()}
>
{working ? "Saving…" : step === 0 ? "Set up Lucid" : "Continue"}
</button>
</>
) : (
<button
type="button"
className="primary"
onClick={() => {
setActivation((current) => rememberSetupStep(current, 0));
onFinish(true);
}}
>
Expand Down
Loading
Loading