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
38 changes: 21 additions & 17 deletions apps/desktop/e2e/onboarding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { join } from "node:path";
import { _electron as electron, expect, test } from "@playwright/test";
import { maximizeWindow } from "./helpers";

test("a first run explains setup and points out the workspace", async () => {
test("a first run leads into a real first-success task", async () => {
const app = await electron.launch({
args: [join(__dirname, "../out/main/index.js")],
env: {
Expand All @@ -20,6 +20,7 @@ test("a first run explains setup and points out the workspace", async () => {
await maximizeWindow(app, window);
await window.evaluate(() => {
localStorage.removeItem("lucid.onboarding");
localStorage.removeItem("lucid.firstSuccess");
});
await window.reload();

Expand All @@ -37,25 +38,28 @@ test("a first run explains setup and points out the workspace", async () => {
).toBeVisible();
await window.getByRole("button", { name: "Open my workspace" }).click();

const tour = window.getByRole("dialog", { name: "Lucid workspace tour" });
await expect(tour.getByRole("heading", { name: "Your pages live here" })).toBeVisible();
await tour.getByRole("button", { name: "Next" }).click();
await expect(tour.getByRole("heading", { name: "Change how Lucid listens" })).toBeVisible();
await expect
.poll(async () => {
const card = await window.locator(".tour-card").boundingBox();
const modes = await window.locator('[data-tour="modes"]').boundingBox();
return card && modes ? card.y + card.height <= modes.y : false;
})
.toBe(true);
await tour.getByRole("button", { name: "Next" }).click();
await expect(tour.getByRole("heading", { name: "Take the result with you" })).toBeVisible();
await tour.getByRole("button", { name: "Done" }).click();

await expect(tour).toBeHidden();
const guide = window.getByRole("complementary", { name: "First success guide" });
await expect(guide.getByRole("heading", { name: "Create your first page" })).toBeVisible();
await guide.getByRole("button", { name: "Create my page" }).click();
await expect(guide.getByRole("heading", { name: "Add your first thought" })).toBeVisible();
await expect(guide.getByText("Choose Edit mode", { exact: false })).toBeVisible();
await guide.getByRole("button", { name: "Skip for now", exact: true }).last().click();
await expect(guide).toBeHidden();
await expect
.poll(() => window.evaluate(() => localStorage.getItem("lucid.onboarding")))
.toBe("1");
await expect
.poll(() =>
window.evaluate(() => {
const stored = localStorage.getItem("lucid.firstSuccess");
return Boolean(
stored?.includes('"provider":"skipped"') &&
stored.includes('"paused":true') &&
stored.includes('"page-created"'),
);
}),
)
.toBe(true);
} finally {
await app.close();
}
Expand Down
75 changes: 58 additions & 17 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { startAgentBridge } from "./agent/bridge";
import { NewPageDialog } from "./pages/NewPageDialog";
import { SettingsDialog } from "./settings/SettingsDialog";
Expand All @@ -9,8 +9,17 @@ import { Sidebar } from "./pages/Sidebar";
import { Workspace } from "./pages/Workspace";
import { usePages } from "./pages/usePages";
import { FirstRun } from "./onboarding/FirstRun";
import { ProductTour } from "./onboarding/ProductTour";
import { FirstSuccessGuide } from "./onboarding/FirstSuccessGuide";
import { completeOnboarding, onboardingComplete } from "./onboarding/state";
import {
finishActivation,
loadActivationProgress,
pauseActivation,
recordMilestone,
rememberActivationPage,
resumeActivation,
type ActivationMilestone,
} from "./onboarding/activation";

export function App() {
if (typeof window.lucid === "undefined") {
Expand All @@ -36,7 +45,8 @@ function Shell() {
const [settings, setSettings] = useState<PublicSettings | undefined>();
const [settingsOpen, setSettingsOpen] = useState(false);
const [onboardingOpen, setOnboardingOpen] = useState(false);
const [tourOpen, setTourOpen] = useState(false);
const [guideOpen, setGuideOpen] = useState(false);
const [activation, setActivation] = useState(loadActivationProgress);
const [newPageOpen, setNewPageOpen] = useState(false);
const [imported, setImported] = useState<NoteImportResult | undefined>();

Expand Down Expand Up @@ -75,7 +85,14 @@ function Shell() {
useEffect(() => {
void loadSettings().then((loaded) => {
setSettings(loaded);
if (!loaded.onboardingDisabled && !onboardingComplete()) setOnboardingOpen(true);
if (loaded.onboardingDisabled) return;
if (!onboardingComplete()) {
setOnboardingOpen(true);
return;
}
const progress = loadActivationProgress();
setActivation(progress);
if (!progress.finished && !progress.paused) setGuideOpen(true);
});
// ⌘, opens Settings, as in most Mac apps.
const onKey = (e: KeyboardEvent) => {
Expand Down Expand Up @@ -109,14 +126,30 @@ function Shell() {
const finishOnboarding = (startTour: boolean) => {
completeOnboarding();
setOnboardingOpen(false);
if (!startTour) return;
void (async () => {
if (!pages.current) await pages.create();
requestAnimationFrame(() => {
setTourOpen(true);
});
})();
const latest = loadActivationProgress();
setActivation(startTour ? resumeActivation(latest) : pauseActivation(latest));
setGuideOpen(startTour);
};
const noteMilestone = useCallback((milestone: ActivationMilestone) => {
setActivation((current) => recordMilestone(current, milestone));
}, []);
const createFirstPage = useCallback(
async (options?: { title?: string; format?: string }) => {
const file = await pages.create(options);
if (!file) return;
setActivation((current) =>
recordMilestone(rememberActivationPage(current, file.id), "page-created"),
);
},
[pages.create],
);
useEffect(() => {
const currentPage = pages.current;
if (!guideOpen || !currentPage || activation.milestones.includes("page-created")) return;
setActivation((current) =>
recordMilestone(rememberActivationPage(current, currentPage.id), "page-created"),
);
}, [activation.milestones, guideOpen, pages.current]);
return (
<div className="app" data-sidebar={sidebarOpen ? "open" : "closed"}>
{sidebarOpen && (
Expand All @@ -125,7 +158,7 @@ function Shell() {
problems={pages.problems}
currentId={pages.current?.id}
onOpen={(id) => void pages.open(id)}
onCreate={() => void pages.create()}
onCreate={() => void createFirstPage()}
onCreateFrom={() => {
setNewPageOpen(true);
}}
Expand Down Expand Up @@ -157,6 +190,7 @@ function Shell() {
onRestoreBackup={pages.restoreBackup}
sidebarOpen={sidebarOpen}
onToggleSidebar={toggleSidebar}
onActivationMilestone={noteMilestone}
/>
) : pages.ready ? (
<div className="blank">
Expand All @@ -165,7 +199,7 @@ function Shell() {
Say or type what you’re learning. Lucid turns it into notes, diagrams and tables, and
makes the PDF.
</p>
<button className="primary big" type="button" onClick={() => void pages.create()}>
<button className="primary big" type="button" onClick={() => void createFirstPage()}>
+ New page
</button>
<ol className="first-steps">
Expand Down Expand Up @@ -193,7 +227,7 @@ function Shell() {
}}
onCreate={(page) => {
setNewPageOpen(false);
void pages.create(page);
void createFirstPage(page);
}}
/>
)}
Expand All @@ -212,10 +246,17 @@ function Shell() {
{onboardingOpen && settings && (
<FirstRun settings={settings} onSettingsChange={setSettings} onFinish={finishOnboarding} />
)}
{tourOpen && (
<ProductTour
{guideOpen && (
<FirstSuccessGuide
progress={activation}
onCreatePage={() => void createFirstPage()}
onPause={() => {
setActivation((current) => pauseActivation(current));
setGuideOpen(false);
}}
onFinish={() => {
setTourOpen(false);
setActivation((current) => finishActivation(current));
setGuideOpen(false);
}}
/>
)}
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src/jobs/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
} from "@lucid/llm";
import { startMockLmStudio, type Behaviour, type MockLmStudio } from "@lucid/mock-lmstudio";
import { afterEach, describe, expect, it } from "vitest";
import { JobQueue, type EditorPort, type JobStatus, type Utterance } from "./queue";
import { JobQueue, type EditorPort, type JobStatus, type Mode, type Utterance } from "./queue";

let mock: MockLmStudio | undefined;
afterEach(async () => {
Expand Down Expand Up @@ -68,6 +68,7 @@ async function setup(
const statuses: JobStatus[] = [];
const transcripts: TranscriptEntry[] = [];
const changes: ChangeEntry[] = [];
const applied: Mode[] = [];
const nativeClient = new LmStudioClient({
baseUrl: mock.url,
...(options.fetch ? { fetch: options.fetch } : {}),
Expand Down Expand Up @@ -97,6 +98,9 @@ async function setup(
onChange: (c) => {
changes.push(c);
},
onApplied: (mode) => {
applied.push(mode);
},
pollMs: 20,
ids: createSequentialIds("n"),
});
Expand All @@ -119,6 +123,7 @@ async function setup(
statuses,
transcripts,
changes,
applied,
queue,
say,
settled,
Expand Down Expand Up @@ -171,6 +176,7 @@ describe("JobQueue", () => {
source: "local-model",
affected: ["n1", "n2", "n3"],
});
expect(t.applied).toEqual(["input"]);
});

it("gives the model the page and the last things said", async () => {
Expand Down Expand Up @@ -386,6 +392,7 @@ describe("JobQueue in Command mode", () => {
text: "cross out MongoDB",
});
expect(t.changes[0]).toMatchObject({ summary: "Crossed out MongoDB", affected: ["o2"] });
expect(t.applied).toEqual(["command"]);
});

it("tells the model what a block number is, when the person numbers blocks, and says nothing otherwise", async () => {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/jobs/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export interface QueueOptions {
onStatus: (status: JobStatus) => void;
onTranscript: (entry: TranscriptEntry) => void;
onChange: (entry: ChangeEntry) => void;
/** A real input or command change was successfully applied to the page. */
onApplied?: (mode: Mode) => void;
/** Timing of every model call (for the developer console and, later, the job log). */
onCall?: (call: CallInfo) => void;
/** How often to look for LM Studio again while it is down. */
Expand Down Expand Up @@ -554,6 +556,7 @@ export class JobQueue {
affected: [...(affected ?? [])],
reverted: false,
});
this.options.onApplied?.(mode);
this.options.onStatus({ kind: "done", summary, notice, at, undoable: true });
}

Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/jobs/useJobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface Options {
format: () => string | undefined;
onTranscript: (entry: TranscriptEntry) => void;
onChange: (entry: ChangeEntry) => void;
onApplied?: ((mode: Mode) => void) | undefined;
onExportPdf?: (() => void) | undefined;
onExportMarkdown?: (() => void) | undefined;
/** "diagram mode", "edit this diagram", "table mode": open the guided builder. */
Expand Down Expand Up @@ -64,6 +65,9 @@ export function useJobs(options: Options) {
onChange: (e) => {
opts.current.onChange(e);
},
onApplied: (mode) => {
opts.current.onApplied?.(mode);
},
});
setReady(true);
});
Expand Down
83 changes: 83 additions & 0 deletions apps/desktop/src/onboarding/FirstSuccessGuide.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/** @vitest-environment jsdom */

import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ActivationProgress } from "./activation";
import { FirstSuccessGuide } from "./FirstSuccessGuide";

const progress = (overrides: Partial<ActivationProgress> = {}): ActivationProgress => ({
version: "1",
provider: "skipped",
setupStep: 0,
milestones: [],
finished: false,
paused: false,
...overrides,
});

let root: Root | undefined;
let container: HTMLDivElement | undefined;

afterEach(() => {
if (root) act(() => root?.unmount());
container?.remove();
root = undefined;
container = undefined;
});

function renderGuide(value: ActivationProgress, handlers = { create: vi.fn(), finish: vi.fn() }) {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => {
root?.render(
<FirstSuccessGuide
progress={value}
onCreatePage={handlers.create}
onPause={vi.fn()}
onFinish={handlers.finish}
/>,
);
});
return handlers;
}

describe("FirstSuccessGuide", () => {
it("starts with a real page action", () => {
const handlers = renderGuide(progress());
if (!container) throw new Error("Guide was not rendered");
const button = [...container.querySelectorAll("button")].find(
(item) => item.textContent === "Create my page",
);
act(() => button?.click());
expect(handlers.create).toHaveBeenCalledOnce();
});

it("uses the offline route and finishes only after an export", () => {
renderGuide(progress({ milestones: ["page-created"] }));
expect(container?.querySelector("h2")?.textContent).toBe("Add your first thought");
expect(container?.textContent).toContain("Choose Edit mode");

act(() => {
root?.render(
<FirstSuccessGuide
progress={progress({
milestones: ["page-created", "content-added", "export-saved"],
})}
onCreatePage={vi.fn()}
onPause={vi.fn()}
onFinish={vi.fn()}
/>,
);
});
expect(container?.querySelector("h2")?.textContent).toBe("Your first page is ready");
expect(container?.textContent).toContain("Finish");
});

it("guides a ready provider through command and visual milestones", () => {
renderGuide(progress({ provider: "ready", milestones: ["page-created", "content-added"] }));
expect(container?.querySelector("h2")?.textContent).toBe("Make one change with AI");
expect(container?.textContent).toContain("Build a diagram or table");
});
});
Loading
Loading