([]);
const [addingCustomModel, setAddingCustomModel] = useState(false);
const [addingPresetModel, setAddingPresetModel] = useState(false);
@@ -195,6 +206,22 @@ export default function ModelStep({
};
}, [presetForm]);
+ useEffect(() => {
+ if (!detectLocal) return;
+ let cancelled = false;
+ void localModelsApi
+ .probe()
+ .then((probe) => {
+ if (!cancelled) setLocalProbe(probe);
+ })
+ .catch(() => {
+ /* probe is best-effort; cloud key + skip still work */
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [detectLocal]);
+
useEffect(() => {
if (!loadingPresets && presets.length === 0) {
setMode("custom");
@@ -237,6 +264,42 @@ export default function ModelStep({
const canContinueWithoutTest =
(mode === "preset" && isOllama) || (mode === "custom" && customIsLocal);
+ const localDetectHint = (() => {
+ if (!detectLocal || !localProbe) return null;
+ const hardware = localProbe.hardware;
+ if (hardware.ollama_reachable) return t("wizard.model.ollamaDetected");
+ if (hardware.ollama_installed || hardware.ollama_binary) {
+ return t("wizard.model.ollamaInstalled");
+ }
+ return t("wizard.model.ollamaMissing");
+ })();
+
+ const renderStepIntro = (fallback: string) => (
+ <>
+
+ {intro ?? fallback}
+
+ {localDetectHint ? (
+
+ {localDetectHint}
+
+ ) : null}
+ {detectLocal ? (
+
+ {t("wizard.model.nextHint")}
+
+ ) : null}
+ {canContinueWithoutTest && (
+
+ {t("wizard.model.localOptionalTest")}
+
+ )}
+ >
+ );
+
const applyPreset = (p: ProviderPreset) => {
resetTest();
setSelectedPresetId(p.id);
@@ -573,9 +636,10 @@ export default function ModelStep({
disabled={!testPassed && !canContinueWithoutTest}
onClick={() => void handleContinue()}
>
- {testPassed || !canContinueWithoutTest
- ? t("wizard.model.continue")
- : t("wizard.model.continueLocal")}
+ {continueLabel ??
+ (testPassed || !canContinueWithoutTest
+ ? t("wizard.model.continue")
+ : t("wizard.model.continueLocal"))}
@@ -1231,6 +1295,11 @@ export default function ModelStep({
{t("models.noProvidersHint")}
+ {detectLocal ? (
+
+ {t("wizard.model.nextHint")}
+
+ ) : null}
@@ -1266,17 +1335,7 @@ export default function ModelStep({
>
{t("wizard.stepModel")}
-
- {t("wizard.model.intro")}
-
- {canContinueWithoutTest && (
-
- {t("wizard.model.localOptionalTest")}
-
- )}
+ {renderStepIntro(t("wizard.model.intro"))}
diff --git a/dashboard/src/utils/desktopOnboarding.test.ts b/dashboard/src/utils/desktopOnboarding.test.ts
index bea51d8e..cfcfdc24 100644
--- a/dashboard/src/utils/desktopOnboarding.test.ts
+++ b/dashboard/src/utils/desktopOnboarding.test.ts
@@ -1,8 +1,13 @@
import { afterEach, describe, expect, it } from "vitest";
import {
+ DESKTOP_FIRST_CHAT_PATH,
+ DESKTOP_MODEL_SETUP_PATH,
+ DESKTOP_RETURNING_HOME_PATH,
+ desktopAfterModelSetupPath,
desktopPostSessionPath,
isDesktopModelOnboardingDone,
markDesktopModelOnboardingDone,
+ needsDesktopModelOnboarding,
} from "./desktopOnboarding";
describe("desktopOnboarding", () => {
@@ -12,14 +17,23 @@ describe("desktopOnboarding", () => {
it("starts unfinished and remembers skip or save", () => {
expect(isDesktopModelOnboardingDone()).toBe(false);
- expect(desktopPostSessionPath()).toBe("/setup");
+ expect(needsDesktopModelOnboarding()).toBe(true);
+ expect(desktopPostSessionPath()).toBe(DESKTOP_MODEL_SETUP_PATH);
markDesktopModelOnboardingDone();
expect(isDesktopModelOnboardingDone()).toBe(true);
- expect(desktopPostSessionPath()).toBe("/projects");
+ expect(needsDesktopModelOnboarding()).toBe(false);
+ expect(desktopPostSessionPath()).toBe(DESKTOP_RETURNING_HOME_PATH);
});
it("treats an existing provider as already finished", () => {
- expect(desktopPostSessionPath(true)).toBe("/projects");
+ expect(needsDesktopModelOnboarding(true)).toBe(false);
+ expect(desktopPostSessionPath(true)).toBe(DESKTOP_RETURNING_HOME_PATH);
expect(isDesktopModelOnboardingDone()).toBe(true);
});
+
+ it("opens the default first-agent chat after skip or save", () => {
+ expect(desktopAfterModelSetupPath()).toBe(DESKTOP_FIRST_CHAT_PATH);
+ expect(isDesktopModelOnboardingDone()).toBe(true);
+ expect(desktopPostSessionPath()).toBe(DESKTOP_RETURNING_HOME_PATH);
+ });
});
diff --git a/dashboard/src/utils/desktopOnboarding.ts b/dashboard/src/utils/desktopOnboarding.ts
index af1cbbe4..37d44bc6 100644
--- a/dashboard/src/utils/desktopOnboarding.ts
+++ b/dashboard/src/utils/desktopOnboarding.ts
@@ -1,5 +1,16 @@
const ONBOARDING_KEY = "freeos:model-onboarding-done";
+/** Pinned default agent created for the desktop / loopback guest session. */
+export const DESKTOP_FIRST_AGENT_ID = "main";
+
+/** Canvas for the first-run assistant — not the shared conversation list. */
+export const DESKTOP_FIRST_CHAT_PATH = `/chat/${DESKTOP_FIRST_AGENT_ID}`;
+
+/** Returning desktop users land on the shared workspace conversation list. */
+export const DESKTOP_RETURNING_HOME_PATH = "/projects";
+
+export const DESKTOP_MODEL_SETUP_PATH = "/setup";
+
/** True after the desktop first-run model step was saved or skipped. */
export function isDesktopModelOnboardingDone(): boolean {
if (typeof window === "undefined") return false;
@@ -20,11 +31,25 @@ export function markDesktopModelOnboardingDone(): void {
}
}
-/** Path after a desktop local session: model setup once, then the shared conversation list. */
+/** First launch with no providers and no skip/save yet. */
+export function needsDesktopModelOnboarding(hasProviders = false): boolean {
+ return !hasProviders && !isDesktopModelOnboardingDone();
+}
+
+/**
+ * Path after a desktop local session is adopted.
+ * First launch → model setup; later launches → conversation list (not a loop).
+ */
export function desktopPostSessionPath(hasProviders = false): string {
- if (hasProviders || isDesktopModelOnboardingDone()) {
- if (hasProviders) markDesktopModelOnboardingDone();
- return "/projects";
+ if (needsDesktopModelOnboarding(hasProviders)) {
+ return DESKTOP_MODEL_SETUP_PATH;
}
- return "/setup";
+ if (hasProviders) markDesktopModelOnboardingDone();
+ return DESKTOP_RETURNING_HOME_PATH;
+}
+
+/** After skip or a successful model save: chat with the default first agent. */
+export function desktopAfterModelSetupPath(): string {
+ markDesktopModelOnboardingDone();
+ return DESKTOP_FIRST_CHAT_PATH;
}