diff --git a/src/hal0/api/routes/installer.py b/src/hal0/api/routes/installer.py index db82fe16..89a97e2f 100644 --- a/src/hal0/api/routes/installer.py +++ b/src/hal0/api/routes/installer.py @@ -34,6 +34,7 @@ from fastapi import APIRouter, BackgroundTasks, Request from hal0.api.middleware.error_codes import BadRequest, Hal0Error +from hal0.bundles import store as bundle_store from hal0.config import paths from hal0.hardware.probe import HardwareProbe from hal0.registry.curated import CURATED_MODELS, get_curated @@ -161,12 +162,21 @@ async def install_state(request: Request) -> dict[str, Any]: "has_models": false, "has_default_slot": false, "openwebui_running": false, - "sentinel_path": "/var/lib/hal0/.first_run_done" + "sentinel_path": "/var/lib/hal0/.first_run_done", + "bundle": { + "name": "hal0-Pro", + "skipped": false, + "npu_opt_in": false, + "chosen_at": "2026-05-25T18:00:00+00:00" + } | null } ``first_run`` is true when ``/var/lib/hal0/models/`` is empty AND the sentinel hasn't been written. Either condition flipping false hides - the FirstRun wizard. + the FirstRun wizard. ``bundle`` is the persisted bundle-picker + decision (or ``null`` before the picker runs) — the dashboard reads + this to render the actual tier name in the post-install hero and + "currently have …" banners (issue #214). """ has_models = _models_dir_populated() sentinel = _first_run_sentinel() @@ -174,12 +184,14 @@ async def install_state(request: Request) -> dict[str, Any]: has_default = _has_default_slot() openwebui = await _openwebui_running() first_run = (not has_models) and (not sentinel_present) + choice = bundle_store.read_choice() return { "first_run": first_run, "has_models": has_models, "has_default_slot": has_default, "openwebui_running": openwebui, "sentinel_path": str(sentinel), + "bundle": choice.to_dict() if choice is not None else None, } diff --git a/tests/api/test_installer_routes.py b/tests/api/test_installer_routes.py index e5670824..a8c74077 100644 --- a/tests/api/test_installer_routes.py +++ b/tests/api/test_installer_routes.py @@ -81,6 +81,43 @@ def test_install_state_has_default_slot_when_primary_toml_exists( assert r.json()["has_default_slot"] is True +def test_install_state_bundle_null_before_pick( + isolated_client: TestClient, tmp_hal0_home: str +) -> None: + """No bundle pick yet → ``bundle`` is null. Issue #214.""" + r = isolated_client.get("/api/install/state") + assert r.status_code == 200 + assert r.json()["bundle"] is None + + +def test_install_state_bundle_echoes_chosen_tier( + isolated_client: TestClient, tmp_hal0_home: str +) -> None: + """After ``mark_bundle_chosen``, state echoes the tier name. Issue #214.""" + from hal0.bundles import store as bundle_store + + bundle_store.mark_bundle_chosen("hal0-Pro", npu_opt_in=False) + r = isolated_client.get("/api/install/state") + assert r.status_code == 200 + body = r.json() + assert body["bundle"] is not None + assert body["bundle"]["name"] == "hal0-Pro" + assert body["bundle"]["skipped"] is False + + +def test_install_state_bundle_skipped_branch( + isolated_client: TestClient, tmp_hal0_home: str +) -> None: + """Skip path: ``bundle.skipped=True`` and ``name`` is empty. Issue #214.""" + from hal0.bundles import store as bundle_store + + bundle_store.mark_skipped() + r = isolated_client.get("/api/install/state") + body = r.json() + assert body["bundle"]["skipped"] is True + assert body["bundle"]["name"] == "" + + def test_install_complete_writes_sentinel_atomically( isolated_client: TestClient, tmp_hal0_home: str ) -> None: diff --git a/ui/src/api/endpoints.ts b/ui/src/api/endpoints.ts index fee3c9ca..564ef76e 100644 --- a/ui/src/api/endpoints.ts +++ b/ui/src/api/endpoints.ts @@ -107,7 +107,8 @@ export const ENDPOINTS = { // Secrets secrets: '/api/secrets', secret: (name: string) => `/api/secrets/${encodeURIComponent(name)}`, - // FirstRun + // Install / FirstRun + installState: '/api/install/state', firstrunState: '/api/firstrun/state', firstrunCuratedModels: '/api/firstrun/curated-models', firstrunPickDefault: '/api/firstrun/pick-default', diff --git a/ui/src/api/hooks/useInstallState.ts b/ui/src/api/hooks/useInstallState.ts new file mode 100644 index 00000000..be014a64 --- /dev/null +++ b/ui/src/api/hooks/useInstallState.ts @@ -0,0 +1,51 @@ +// hal0 v3 dashboard — install-state hook. +// +// Backs the post-install banner heading + the FirstRun progress card. +// `/api/install/state` already gates the first-run wizard; this hook +// also exposes the bundle the operator picked so the dashboard renders +// the real tier name instead of a hardcoded `hal0-Pro` (issue #214). + +import { useQuery } from '@tanstack/react-query' +import { apiGet } from '../client' +import { ENDPOINTS } from '../endpoints' + +export interface InstallStateBundle { + name: string + npu_opt_in?: boolean + chosen_at?: string + skipped?: boolean + assignments?: Array> +} + +export interface InstallState { + first_run: boolean + has_models: boolean + has_default_slot: boolean + openwebui_running: boolean + sentinel_path: string + bundle: InstallStateBundle | null +} + +export function useInstallState() { + return useQuery({ + queryKey: ['install', 'state'], + queryFn: () => apiGet(ENDPOINTS.installState), + // Bundle pick only changes via the picker flow; cache for a minute + // so banners don't refetch on every render. + staleTime: 60_000, + }) +} + +/** + * Resolve the current bundle's display name with a sensible fallback. + * + * Returns `'hal0'` when no pick has been made (fresh install) or when + * the user explicitly skipped the picker — the banner copy already + * carries the rest of the sentence ("Welcome to hal0 — hal0 is loaded" + * reads cleanly when no tier exists). + */ +export function bundleNameOr(state: InstallState | undefined, fallback = 'hal0'): string { + const name = state?.bundle?.name + if (!name || state?.bundle?.skipped) return fallback + return name +} diff --git a/ui/src/dash/firstrun.jsx b/ui/src/dash/firstrun.jsx index 88c63a1c..852a7442 100644 --- a/ui/src/dash/firstrun.jsx +++ b/ui/src/dash/firstrun.jsx @@ -389,16 +389,20 @@ function FirstRunConfirm({ bundleId, onBack, onInstall }) { } // ─── Install progress (state 3) ─── -function FirstRunProgress({ onDone }) { +function FirstRunProgress({ onDone, bundleId }) { // Phase B1: complete-mutation flips the backend's firstrun.completed // flag when the user clicks "Open dashboard". Downloads list is // intentionally still HAL0_DATA — per-row SSE wiring via // `usePullJob(id)` lands in B2 when DownloadRow swaps in the hook. const completeM = useFirstRunComplete(); + const bundlesQuery = useCuratedBundles(); + const bundle = (bundlesQuery.data?.bundles ?? HAL0_DATA.bundles).find(b => b.id === bundleId) + || HAL0_DATA.bundles.find(b => b.id === bundleId); + const bundleName = bundle?.name ? `hal0-${bundle.name}` : 'hal0'; return (
-

Installing hal0-Pro…

+

Installing {bundleName}…

~38 GB total · est 12 min · downloads continue in background
@@ -481,7 +485,7 @@ function FirstRunView({ frStage, setFrStage, frBundle, setFrBundle, onComplete, /> )} {frStage === "progress" && ( - onComplete()} /> + onComplete()} /> )} (vars && vars[k] != null ? String(vars[k]) : m)); +} + // ─── BannerStack — renders the active banners for a given view scope ───── -function BannerStack({ scope = "global", route }) { +function BannerStack({ scope = "global", route, vars: extraVars }) { const { active, toggle } = useBanners(); + const installQuery = useInstallState(); + // Merge install-derived defaults (bundleName) with caller-supplied vars so + // a specific view (FirstRun confirm) can override with an in-flight pick. + const vars = { bundleName: bundleNameOr(installQuery.data), ...(extraVars || {}) }; const items = BANNER_CATALOG.filter(b => active[b.id] && ( b.scope === "global" || @@ -176,9 +191,9 @@ function BannerStack({ scope = "global", route }) { (