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
40 changes: 40 additions & 0 deletions src/renderer/state/welcomeGateStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useWelcomeGateStore, WELCOME_SEEN_STORAGE_KEY } from "./welcomeGateStore";

describe("welcomeGateStore", () => {
beforeEach(() => {
localStorage.clear();
useWelcomeGateStore.setState({ backgroundWorkReleased: false });
});

afterEach(() => {
vi.resetModules();
});

it("releaseBackgroundWork flips the gate open", () => {
expect(useWelcomeGateStore.getState().backgroundWorkReleased).toBe(false);
useWelcomeGateStore.getState().releaseBackgroundWork();
expect(useWelcomeGateStore.getState().backgroundWorkReleased).toBe(true);
});

it("releaseBackgroundWork is idempotent and keeps the same state reference once open", () => {
useWelcomeGateStore.getState().releaseBackgroundWork();
const released = useWelcomeGateStore.getState();
useWelcomeGateStore.getState().releaseBackgroundWork();
expect(useWelcomeGateStore.getState()).toBe(released);
});

it("seeds released=false on a fresh install (welcome unseen)", async () => {
vi.resetModules();
localStorage.clear();
const mod = await import("./welcomeGateStore");
expect(mod.useWelcomeGateStore.getState().backgroundWorkReleased).toBe(false);
});

it("seeds released=true for a returning user (welcome already seen)", async () => {
vi.resetModules();
localStorage.setItem(WELCOME_SEEN_STORAGE_KEY, "true");
const mod = await import("./welcomeGateStore");
expect(mod.useWelcomeGateStore.getState().backgroundWorkReleased).toBe(true);
});
});
32 changes: 32 additions & 0 deletions src/renderer/state/welcomeGateStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { create } from "zustand";
import { readStoredBoolean } from "@/renderer/utils/localStorage";

/**
* localStorage flag set once the user has seen (and dismissed) the first-launch
* welcome overlay. Owned here so the overlay and this background-work gate read
* a single shared key.
*/
export const WELCOME_SEEN_STORAGE_KEY = "lightcode-welcome-seen-v16";

interface WelcomeGateStore {
/**
* True once it is safe to start deferred first-launch background work — most
* importantly the agent-detection sweep kicked off by `getAgentStatuses`,
* whose cold process spawns and `agent-detected` re-render churn would
* otherwise starve the welcome animation's first paint and make it snap
* straight to its final frame.
*
* Seeded from {@link WELCOME_SEEN_STORAGE_KEY} so returning users (who never
* see the welcome overlay) are released on the very first render and never
* delayed. On a genuine first launch it starts `false`; the welcome overlay
* flips it once the intro animation has settled or the user dismisses it.
*/
backgroundWorkReleased: boolean;
releaseBackgroundWork: () => void;
}

export const useWelcomeGateStore = create<WelcomeGateStore>((set) => ({
backgroundWorkReleased: readStoredBoolean(WELCOME_SEEN_STORAGE_KEY, false),
releaseBackgroundWork: () =>
set((prev) => (prev.backgroundWorkReleased ? prev : { backgroundWorkReleased: true })),
}));
11 changes: 9 additions & 2 deletions src/renderer/views/MainView/MainView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ensureHomeScopeProject } from "@/renderer/actions/projectActions";

import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore";
import { useAppStore } from "@/renderer/state/appStore";
import { useWelcomeGateStore } from "@/renderer/state/welcomeGateStore";
import { buildWslProjectDistrosKey, parseWslProjectDistrosKey } from "@/renderer/state/projectKeys";
import { useSharedSettings } from "@/renderer/state/sharedSettingsStore";
import { AppDndProvider } from "@/renderer/dnd";
Expand Down Expand Up @@ -36,6 +37,7 @@ export function MainView(props: { storeHydrated: boolean; loadT0: number }) {
const wslProjectDistrosKey = useAppStore((state) => buildWslProjectDistrosKey(state.projects));
const homeScopeEnabled = useSharedSettings((state) => state.homeScopeEnabled);
const sharedSettingsHydrated = useSharedSettings((state) => state.sharedSettingsHydrated);
const backgroundWorkReleased = useWelcomeGateStore((state) => state.backgroundWorkReleased);

useThreadLifecycle(storeHydrated);
useKeyboardShortcuts();
Expand All @@ -53,7 +55,12 @@ export function MainView(props: { storeHydrated: boolean; loadT0: number }) {
}, [storeHydrated, sharedSettingsHydrated, homeScopeEnabled]);

useEffect(() => {
if (!storeHydrated) {
// Hold first-launch detection until the welcome animation has settled (or
// the user dismissed it). `backgroundWorkReleased` is seeded true for
// returning users, so this only defers on a genuine first launch — the
// cold detection sweep's process spawns and `agent-detected` re-render
// churn would otherwise starve the welcome animation's first paint.
if (!storeHydrated || !backgroundWorkReleased) {
return;
}

Expand Down Expand Up @@ -88,7 +95,7 @@ export function MainView(props: { storeHydrated: boolean; loadT0: number }) {
);
})
.catch(() => undefined);
}, [storeHydrated, wslProjectDistrosKey]);
}, [storeHydrated, wslProjectDistrosKey, backgroundWorkReleased]);

console.log(`[renderer] +${Date.now() - loadT0}ms: rendering main UI`);
return (
Expand Down
24 changes: 22 additions & 2 deletions src/renderer/views/WelcomeOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,25 @@ import { isHomeProject } from "@/shared/homeScope";
import { loadHomeScopeLocation } from "@/renderer/actions/projectActions";
import { useAppStore } from "@/renderer/state/appStore";
import { useSharedSettings } from "@/renderer/state/sharedSettingsStore";
import { useWelcomeGateStore, WELCOME_SEEN_STORAGE_KEY } from "@/renderer/state/welcomeGateStore";
import { readStoredBoolean, writeStoredBoolean } from "@/renderer/utils/localStorage";
import { CreateProjectMenu } from "@/renderer/views/MainView/parts/CreateProject/CreateProjectMenu";
import { WELCOME_BACKGROUND_CODE } from "./welcomeBackgroundCode";
import appIconUrl from "../../../build/icon.png";

const WELCOME_SEEN_STORAGE_KEY = "lightcode-welcome-seen-v16";

// Orbit + reveal animations finish ~2.4s after the overlay mounts. After
// that the comet has scaled to 0 and no longer needs its center sampled, so
// the rAF loop driving `--comet-x/y` can stop.
const ORBIT_DURATION_MS = 2400;

// The intro reveal fully settles ~3.2s after mount — the CTA buttons carry the
// latest reveal (2.4s delay + 0.8s duration). Until then we hold first-launch
// background work (agent detection) so its cold process spawns and re-render
// churn don't starve the animation's first paint and snap it to its final
// frame. A user who clicks a CTA sooner releases the gate immediately in
// `dismissWelcome`.
const WELCOME_SETTLE_MS = 3200;

export function WelcomeOverlay() {
const containerRef = useRef<HTMLDivElement>(null);
const cometRef = useRef<HTMLSpanElement>(null);
Expand Down Expand Up @@ -51,6 +58,16 @@ export function WelcomeOverlay() {
setVisible(false);
}, [open]);

// First launch only: defer heavy background work until the intro animation
// has settled, then release the gate so MainView can start agent detection.
useEffect(() => {
if (!open) return;
const releaseTimer = window.setTimeout(() => {
useWelcomeGateStore.getState().releaseBackgroundWork();
}, WELCOME_SETTLE_MS);
return () => clearTimeout(releaseTimer);
}, [open]);

useEffect(() => {
if (!mounted) return;
let rafId = 0;
Expand Down Expand Up @@ -88,6 +105,9 @@ export function WelcomeOverlay() {
function dismissWelcome() {
writeStoredBoolean(WELCOME_SEEN_STORAGE_KEY, true);
setWelcomeSeen(true);
// The user is moving on — let deferred startup work (agent detection) run
// now rather than waiting out the settle timer.
useWelcomeGateStore.getState().releaseBackgroundWork();
}

function handleAskQuestion() {
Expand Down
4 changes: 4 additions & 0 deletions src/supervisor/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,10 @@ describe("SupervisorRuntime thread input", () => {
).handlePtyData(session, "second");

expect(appendFileMock).not.toHaveBeenCalled();
// Advance only past the 25ms dev-log buffer flush, not `runAllTimersAsync`:
// the runtime's UsageService auto-refresh tick reschedules itself forever
// (by design), so draining every timer trips fake-timers' 10000-iteration
// infinite-loop guard non-deterministically under load.
await vi.advanceTimersByTimeAsync(25);
expect(appendFileMock).toHaveBeenCalledTimes(1);
expect(appendFileMock.mock.calls[0]?.[1]).toBe("firstsecond");
Expand Down