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
16 changes: 14 additions & 2 deletions src/hal0/api/routes/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -161,25 +162,36 @@ 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()
sentinel_present = sentinel.exists()
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,
}


Expand Down
37 changes: 37 additions & 0 deletions tests/api/test_installer_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion ui/src/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
51 changes: 51 additions & 0 deletions ui/src/api/hooks/useInstallState.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>
}

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<InstallState>(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
}
10 changes: 7 additions & 3 deletions ui/src/dash/firstrun.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="fr-inner">
<div className="fr-prog-h">
<h2>Installing hal0-Pro…</h2>
<h2>Installing {bundleName}…</h2>
<span className="meta">~38 GB total · est 12 min · downloads continue in background</span>
</div>

Expand Down Expand Up @@ -481,7 +485,7 @@ function FirstRunView({ frStage, setFrStage, frBundle, setFrBundle, onComplete,
/>
)}
{frStage === "progress" && (
<FirstRunProgress onDone={() => onComplete()} />
<FirstRunProgress bundleId={frBundle} onDone={() => onComplete()} />
)}
<SkipBundleDialog
open={skipOpen}
Expand Down
29 changes: 22 additions & 7 deletions ui/src/dash/primitives.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Modal, Drawer, ConfirmDialog, Banner, BannerStack, Dropdown menu

import { useUpdateState } from '@/api/hooks/useUpdates'
import { useInstallState, bundleNameOr } from '@/api/hooks/useInstallState'

const { useState: useStateP, useEffect: useEffectP, useRef: useRefP, createContext: createContextP, useContext: useContextP } = React;

Expand Down Expand Up @@ -159,9 +160,23 @@ function useBanners() {
return useContextP(BannerContext);
}

// ─── Banner template substitution ────────────────────────────────────────
// Banner catalog entries embed `{bundleName}` (and similar `{key}` slots)
// so the heading/body can carry live state without per-banner branching.
// Substituted at render time from the install/firstrun stores so a fresh
// `/api/install/state` keeps banner copy in sync (issue #214).
function _interpolateBannerString(s, vars) {
if (typeof s !== "string") return s;
return s.replace(/\{(\w+)\}/g, (m, k) => (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" ||
Expand All @@ -176,9 +191,9 @@ function BannerStack({ scope = "global", route }) {
<Banner
key={b.id}
kind={b.kind}
eyebrow={b.eyebrow}
heading={b.heading}
body={b.body}
eyebrow={_interpolateBannerString(b.eyebrow, vars)}
heading={_interpolateBannerString(b.heading, vars)}
body={_interpolateBannerString(b.body, vars)}
actions={b.actions && b.actions.map((a, i) => (
<button
key={i}
Expand Down Expand Up @@ -329,7 +344,7 @@ const BANNER_CATALOG = [
{
id: "fr-reentered", scope: "firstrun", kind: "warn",
eyebrow: "Picker · post-install",
heading: "You currently have hal0-Pro installed",
heading: "You currently have {bundleName} installed",
body: "Picking another tier will replace your slot selections. Models already on disk won't be re-downloaded.",
},
{
Expand Down Expand Up @@ -364,8 +379,8 @@ const BANNER_CATALOG = [
{
id: "post-install", scope: "dashboard", kind: "info",
eyebrow: "FirstRun · just installed",
heading: "Welcome to hal0 — hal0-Pro is loaded",
body: <span>Try a message below. <span className="mono" style={{color: "var(--fg)"}}>primary</span> (Qwen3.6-27B-MTP) is your default chat persona. The persona dropdown lets you swap to <span className="mono">coder</span> or the NPU <span className="mono">agent</span>.</span>,
heading: "Welcome to hal0 — {bundleName} is loaded",
body: <span>Try a message below. <span className="mono" style={{color: "var(--fg)"}}>primary</span> is your default chat persona. The persona dropdown lets you swap to <span className="mono">coder</span> or the NPU <span className="mono">agent</span>.</span>,
actions: [
{ label: "Take the tour", primary: true, onClick: () => window.dispatchEvent(new CustomEvent("hal0:tour-start")) },
{ label: "Dismiss" },
Expand Down
6 changes: 3 additions & 3 deletions ui/src/stores/useBannerStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ export const BANNER_CATALOG: ReadonlyArray<BannerEntry> = Object.freeze([
scope: 'firstrun',
kind: 'warn',
eyebrow: 'Picker · post-install',
heading: 'You currently have hal0-Pro installed',
heading: 'You currently have {bundleName} installed',
body: "Picking another tier will replace your slot selections. Models already on disk won't be re-downloaded.",
},
{
Expand Down Expand Up @@ -237,8 +237,8 @@ export const BANNER_CATALOG: ReadonlyArray<BannerEntry> = Object.freeze([
scope: 'dashboard',
kind: 'info',
eyebrow: 'FirstRun · just installed',
heading: 'Welcome to hal0 — hal0-Pro is loaded',
body: 'Try a message below. primary (Qwen3.6-27B-MTP) is your default chat persona. The persona dropdown lets you swap to coder or the NPU agent.',
heading: 'Welcome to hal0 — {bundleName} is loaded',
body: 'Try a message below. primary is your default chat persona. The persona dropdown lets you swap to coder or the NPU agent.',
actions: [
{ label: 'Take the tour', primary: true },
{ label: 'Dismiss' },
Expand Down
Loading