import { useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import type { DesktopBootstrapEvent, DesktopBootstrapStageDescriptor, DesktopBootstrapStageResult, DesktopBootstrapStageState, DesktopBootstrapState } from '@/global' import { useI18n } from '@/i18n' import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons' import { cn } from '@/lib/utils' /** * DesktopInstallOverlay * * Renders the first-launch install progress for Hermes Agent. Mounted always; * shows itself only when main.cjs reports an in-flight bootstrap (state.active) * OR an error from a completed-failed bootstrap (state.error). When the * bootstrap finishes successfully the overlay fades out and the rest of the * app (existing onboarding overlay -> main UI) takes over. * * Subscribes to two channels: * - getBootstrapState() -- initial snapshot on mount * - onBootstrapEvent(callback) -- live event stream * * The reducer is intentionally simple: every event mutates an in-component * snapshot the same way main.cjs mutates its server-side snapshot. We don't * try to reconcile -- if we miss an event (shouldn't happen) the initial * getBootstrapState() call will resync the picture on the next render. * * Stages flagged needs_user_input render with a deliberately subdued style: * they're expected to come back as skipped=true (install.ps1 short-circuits * them under -NonInteractive). The post-install configuration flow that * those stages cover (API key, model, persona, gateway autostart) is handled * by the existing DesktopOnboardingOverlay, NOT by the install overlay. */ interface DesktopInstallOverlayProps { /** When false, the overlay never renders -- useful for dev when we want * to suppress it entirely. */ enabled?: boolean } interface StageRowProps { descriptor: DesktopBootstrapStageDescriptor result: DesktopBootstrapStageResult | undefined isCurrent: boolean now: number } function formatStageName(name: string): string { // 'system-packages' -> 'System packages'; 'uv' stays 'uv' if (name.length <= 3) { return name } return name .split('-') .map((word, i) => (i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word)) .join(' ') } function formatDuration(ms: number | null | undefined): string { if (typeof ms !== 'number' || !Number.isFinite(ms)) { return '' } if (ms < 1000) { return `${ms} ms` } const s = ms / 1000 if (s < 60) { return `${s.toFixed(1)}s` } const m = Math.floor(s / 60) const rs = Math.round(s - m * 60) return `${m}m ${rs}s` } // Live elapsed for a running stage, as m:ss (or s for sub-minute). function formatElapsed(ms: number): string { const s = Math.max(0, Math.floor(ms / 1000)) if (s < 60) { return `${s}s` } const m = Math.floor(s / 60) return `${m}:${String(s - m * 60).padStart(2, '0')}` } function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) { const { t } = useI18n() const copy = t.install const state: DesktopBootstrapStageState = result?.state || 'pending' const elapsed = state === 'running' && typeof result?.startedAt === 'number' ? formatElapsed(now - result.startedAt) : '' const icon = useMemo(() => { switch (state) { case 'running': return case 'succeeded': return case 'skipped': return case 'failed': return case 'pending': default: return
} }, [state]) const reason = result?.json?.reason || result?.error || null return (
  • {icon}
    {formatStageName(descriptor.name)} {state === 'running' ? elapsed ? `${copy.stageStates[state]} ยท ${elapsed}` : copy.stageStates[state] : null} {state === 'succeeded' || state === 'skipped' ? formatDuration(result?.durationMs) : null} {state === 'failed' ? copy.stageStates[state] : null}
    {reason && state !== 'pending' &&

    {reason}

    }
  • ) } const EMPTY_STATE: DesktopBootstrapState = { active: false, manifest: null, stages: {}, error: null, log: [], startedAt: null, completedAt: null, unsupportedPlatform: null } function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): DesktopBootstrapState { if (ev.type === 'manifest') { const stages: Record = {} for (const stage of ev.stages) { stages[stage.name] = { state: 'pending', durationMs: null, startedAt: null, json: null, error: null } } return { ...state, active: true, manifest: { type: 'manifest', stages: ev.stages, protocolVersion: ev.protocolVersion }, stages, error: null, startedAt: state.startedAt || Date.now() } } if (ev.type === 'stage') { const prev = state.stages[ev.name] return { ...state, stages: { ...state.stages, [ev.name]: { state: ev.state, durationMs: ev.durationMs ?? null, // Stamp the start time on the running transition so the UI can show // a live elapsed timer; preserve it across repeated running events. startedAt: ev.state === 'running' ? (prev?.startedAt ?? Date.now()) : (prev?.startedAt ?? null), json: ev.json ?? null, error: ev.error ?? null } } } } if (ev.type === 'log') { const next = state.log.concat({ ts: Date.now(), stage: ev.stage ?? null, line: ev.line, stream: ev.stream }) while (next.length > 500) { next.shift() } return { ...state, log: next } } if (ev.type === 'complete') { return { ...state, active: false, completedAt: Date.now(), error: null } } if (ev.type === 'failed') { return { ...state, active: false, error: ev.error || 'unknown error' } } if (ev.type === 'unsupported-platform') { return { ...state, active: false, unsupportedPlatform: { platform: ev.platform, activeRoot: ev.activeRoot, installCommand: ev.installCommand, docsUrl: ev.docsUrl } } } return state } export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayProps) { const { t } = useI18n() const copy = t.install const [state, setState] = useState(EMPTY_STATE) const [logOpen, setLogOpen] = useState(false) const [copied, setCopied] = useState(false) const [cancelling, setCancelling] = useState(false) const [now, setNow] = useState(() => Date.now()) const logEndRef = useRef(null) // Tick once a second while a bootstrap is in flight so running steps show a // live elapsed timer. Stops when nothing is active to avoid idle renders. useEffect(() => { if (!state.active) { return } const id = window.setInterval(() => setNow(Date.now()), 1000) return () => window.clearInterval(id) }, [state.active]) // Subscribe to bootstrap events + load initial snapshot useEffect(() => { if (!enabled) { return } const desktop = window.hermesDesktop if (!desktop || typeof desktop.onBootstrapEvent !== 'function') { return } let cancelled = false desktop .getBootstrapState() .then(snapshot => { if (!cancelled && snapshot) { setState(snapshot) } }) .catch(() => { // Older Electron build without the IPC handler -- bootstrap UI just // stays empty, app falls through to existing onboarding flow. }) const off = desktop.onBootstrapEvent(ev => setState(prev => applyEvent(prev, ev))) return () => { cancelled = true off?.() } }, [enabled]) // Autoscroll log to bottom when new lines arrive AND the log is open useEffect(() => { if (logOpen && logEndRef.current) { logEndRef.current.scrollIntoView({ behavior: 'auto', block: 'end' }) } }, [state.log.length, logOpen]) // Auto-expand the log panel when a bootstrap fails so the user immediately // sees the install.ps1 output. Without this, the failure block shows just // the top-level error message and the user has to click "Show installer // output" to see WHY the stage failed. useEffect(() => { if (state.error) { setLogOpen(true) } }, [state.error]) // Mount logic: show whenever a bootstrap is in flight, completed-with-error, // or actively running with a manifest. Hide entirely after a successful // completion so the rest of the UI can take over. const shouldShow = useMemo(() => { if (!enabled) { return false } if (state.active) { return true } if (state.error) { return true } if (state.unsupportedPlatform) { return true } return false }, [enabled, state.active, state.error, state.unsupportedPlatform]) if (!shouldShow) { return null } // Unsupported-platform branch: macOS/Linux packaged builds hit this when // there's no Hermes Agent installed yet and we can't drive install.sh // (no stage protocol equivalent yet). Show a copy-paste install command // and the docs URL; user runs it from Terminal and relaunches the app. if (state.unsupportedPlatform) { const ups = state.unsupportedPlatform const platformLabel = ups.platform === 'darwin' ? 'macOS' : ups.platform === 'linux' ? 'Linux' : ups.platform return (

    {copy.oneTimeTitle}

    {copy.unsupportedDesc(platformLabel)}

    {copy.installCommand}
                  {ups.installCommand}
                
    {copy.installTo} {ups.activeRoot}
    ) } const stages = state.manifest?.stages || [] const currentStage = stages.find(s => state.stages[s.name]?.state === 'running')?.name const completedCount = stages.filter( s => state.stages[s.name]?.state === 'succeeded' || state.stages[s.name]?.state === 'skipped' ).length const totalCount = stages.length const failed = Boolean(state.error) const progressPct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0 const currentStartedAt = currentStage ? state.stages[currentStage]?.startedAt : null const currentElapsed = typeof currentStartedAt === 'number' ? formatElapsed(now - currentStartedAt) : '' return (
    {/* Header -- always visible, never scrolls */}

    {failed ? copy.failedTitle : state.active ? copy.settingUpTitle : copy.finishingTitle}

    {failed ? copy.failedDesc : copy.activeDesc}

    {/* Scrollable middle: progress, stages, error block, log */}
    {totalCount > 0 && (
    {copy.progress(completedCount, totalCount)} {currentStage && copy.currentStage(formatStageName(currentStage))} {currentElapsed && ` (${currentElapsed})`} {progressPct}%
    )} {totalCount === 0 && state.active && (
    {copy.fetchingManifest}
    )} {failed && state.error && (
    {copy.error}

    {state.error}

    )} {stages.length > 0 && (
      {stages.map(stage => ( ))}
    )}
    {logOpen && (
    {state.log.length === 0 ? (
    {copy.noOutput}
    ) : ( <> {state.log.map((entry, i) => (
    {entry.stage ? [{entry.stage}] : null} {entry.line}
    ))}
    )}
    )}
    {/* Active footer: let the user actually cancel a running install. */} {state.active && !failed && (
    )} {/* Footer -- always visible, never scrolls; only renders on failure */} {failed && (
    {copy.transcriptSaved}{' '} %LOCALAPPDATA%\hermes\logs\
    )}
    ) }