'use client'; import { useState, useRef, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { Github, Download, AlertCircle, CheckCircle2, RefreshCw, ExternalLink, X, Terminal, Server, Copy, } from 'lucide-react'; import { API_BASE } from '@/lib/api'; import { useTranslation } from '@/i18n'; import { controlPlaneFetch } from '@/lib/controlPlane'; import { checkDesktopUpdaterUpdate, classifyUpdateRuntime, getDesktopUpdateContext, getPreferredManualUpdateUrl, getUpdateAction, installDesktopUpdaterUpdate, type GitHubLatestRelease, type UpdateActionKind, } from '@/lib/updateRuntime'; import { requestMeshTerminalOpen, subscribeSecureMeshTerminalLauncherOpen, } from '@/lib/meshTerminalLauncher'; import { purgeBrowserContactGraph, purgeBrowserSigningMaterial, setSecureModeCached, getNodeIdentity, generateNodeKeys } from '@/mesh/meshIdentity'; import { purgeBrowserDmState } from '@/mesh/meshDmWorkerClient'; import { fetchInfonetNodeStatusSnapshot, startTorHiddenService, type InfonetNodeStatusSnapshot, } from '@/mesh/controlPlaneStatusClient'; import { fetchWormholeStatus, prepareWormholeInteractiveLane, } from '@/mesh/wormholeIdentityClient'; import { fetchWormholeSettings } from '@/mesh/wormholeClient'; import packageJson from '../../package.json'; type UpdateStatus = | 'idle' | 'checking' | 'available' | 'uptodate' | 'error' | 'confirming' | 'updating' | 'restarting' | 'update_error' | 'docker_update'; const DEFAULT_RELEASES_URL = 'https://github.com/BigBodyCobain/Shadowbroker/releases/latest'; const AUTO_UPDATE_DETAIL = 'This runtime can use the backend-managed update path. Docker deployments will show pull instructions instead of modifying files in place.'; const DESKTOP_UPDATER_DETAIL = 'This packaged desktop app can install the signed update in place. It will restart ShadowBroker after the installer finishes.'; function packagedUpdateDetail(ownsLocalBackend: boolean): string { if (ownsLocalBackend) { return 'This desktop installer updates the app and its bundled local backend together.'; } return 'This packaged desktop app updates through a new installer download. It does not update the separately running backend service.'; } interface TopRightControlsProps { onTerminalToggle?: () => void; onInfonetToggle?: () => void; dmCount?: number; onSettingsClick?: () => void; onMeshChatNavigate?: (tab: 'infonet' | 'meshtastic' | 'dms') => void; } export default function TopRightControls({ onTerminalToggle, onInfonetToggle, dmCount, onMeshChatNavigate, }: TopRightControlsProps = {}) { const { t } = useTranslation(); const [updateStatus, setUpdateStatus] = useState('idle'); const [latestVersion, setLatestVersion] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [manualUpdateUrl, setManualUpdateUrl] = useState(DEFAULT_RELEASES_URL); const [releasePageUrl, setReleasePageUrl] = useState(DEFAULT_RELEASES_URL); const [dockerCommands, setDockerCommands] = useState(''); // Pre-detection initial value: the right action depends on the runtime. // For desktop installs (Tauri webview), the default should be // ``manual_download`` so that clicking Update before the async runtime // probe completes opens the release page in a browser instead of POSTing // to /api/system/update — which throws ``admin_session_required`` on // fresh sessions and confused v0.9.79/v0.9.8 users with a cryptic error. // ``window.__TAURI__`` is injected synchronously by Tauri before React // mounts, so this check is safe to do at useState init time. const initialUpdateAction: UpdateActionKind = typeof window !== 'undefined' && (window as { __TAURI__?: unknown }).__TAURI__ ? 'manual_download' : 'auto_apply'; const [updateAction, setUpdateAction] = useState(initialUpdateAction); const [updateDetail, setUpdateDetail] = useState(AUTO_UPDATE_DETAIL); const pollRef = useRef | null>(null); const timeoutRef = useRef | null>(null); const [launcherOpen, setLauncherOpen] = useState(false); const [nodeStep, setNodeStep] = useState<'prompt' | 'terms' | 'activating' | 'disable'>('prompt'); const [activatingPhase, setActivatingPhase] = useState<'keys' | 'peers' | 'sync' | 'done'>('keys'); const activatingPollRef = useRef | null>(null); const activatingTimeoutRef = useRef | null>(null); const [activatingTimedOut, setActivatingTimedOut] = useState(false); const [nodeStatus, setNodeStatus] = useState(null); const [nodeStatusError, setNodeStatusError] = useState(''); const [portalReady, setPortalReady] = useState(false); const [nodeToggleBusy, setNodeToggleBusy] = useState(false); const [nodeToggleError, setNodeToggleError] = useState(''); const [terminalLauncherOpen, setTerminalLauncherOpen] = useState(false); const [terminalLaunchBusy, setTerminalLaunchBusy] = useState(false); const [terminalLaunchError, setTerminalLaunchError] = useState(''); const [terminalPrivateEnabled, setTerminalPrivateEnabled] = useState(false); const [terminalPrivateReady, setTerminalPrivateReady] = useState(false); const [terminalTransportTier, setTerminalTransportTier] = useState('public_degraded'); const currentVersion = packageJson.version; const launchTerminalDirect = () => { if (onTerminalToggle) { onTerminalToggle(); return; } if (onInfonetToggle) { onInfonetToggle(); return; } requestMeshTerminalOpen('top-right-controls'); }; const openTerminalLauncher = useCallback(async () => { setTerminalLaunchError(''); try { const [settings, status] = await Promise.all([ fetchWormholeSettings(true).catch(() => null), fetchWormholeStatus().catch(() => null), ]); const enabled = Boolean(settings?.enabled ?? status?.running ?? status?.ready ?? false); const ready = Boolean(status?.ready); setTerminalPrivateEnabled(enabled); setTerminalPrivateReady(ready); setTerminalTransportTier( String(status?.transport_tier || status?.transport_active || 'public_degraded'), ); } catch (error) { const message = typeof error === 'object' && error !== null && 'message' in error ? String((error as { message?: string }).message || '') : ''; setTerminalPrivateEnabled(false); setTerminalPrivateReady(false); setTerminalTransportTier('public_degraded'); setTerminalLaunchError(message || 'Private-lane status unavailable.'); } setTerminalLauncherOpen(true); }, []); useEffect(() => { return subscribeSecureMeshTerminalLauncherOpen(() => { void openTerminalLauncher(); }); }, [openTerminalLauncher]); const closeTerminalLauncher = () => { if (terminalLaunchBusy) return; setTerminalLauncherOpen(false); setTerminalLaunchError(''); }; const applySecureModeBoundary = async (enabled: boolean) => { setSecureModeCached(enabled); if (!enabled) return; purgeBrowserSigningMaterial(); purgeBrowserContactGraph(); await purgeBrowserDmState(); }; const continueTerminalLaunchInBackground = useCallback(async () => { try { const prepared = await prepareWormholeInteractiveLane({ bootstrapIdentity: true }); const settings = await fetchWormholeSettings(true).catch(() => null); let runtime = await fetchWormholeStatus().catch(() => null); const enabled = Boolean( settings?.enabled ?? prepared.settingsEnabled ?? runtime?.running ?? runtime?.ready ?? false, ); const identityNodeId = String(prepared.identity?.node_id || '').trim(); await applySecureModeBoundary(enabled); runtime = await fetchWormholeStatus().catch(() => runtime); setTerminalPrivateEnabled(enabled); setTerminalPrivateReady(Boolean(runtime?.ready ?? prepared.ready ?? false)); setTerminalTransportTier( String( runtime?.transport_tier || runtime?.transport_active || prepared.transportTier || 'private_control_only', ), ); setTerminalLaunchError(''); setSecureModeCached(enabled); if (identityNodeId) { console.info('[top-right] Wormhole terminal launch ready', identityNodeId); } } catch (error) { const message = typeof error === 'object' && error !== null && 'message' in error ? String((error as { message?: string }).message || '') : ''; const settings = await fetchWormholeSettings(true).catch(() => null); const runtime = await fetchWormholeStatus().catch(() => null); setTerminalPrivateEnabled(Boolean(settings?.enabled ?? runtime?.running ?? runtime?.ready ?? false)); setTerminalPrivateReady(Boolean(runtime?.ready)); setTerminalTransportTier( String(runtime?.transport_tier || runtime?.transport_active || 'public_degraded'), ); setTerminalLaunchError(message || 'Wormhole is still warming up in the background.'); } finally { setTerminalLaunchBusy(false); } }, [applySecureModeBoundary]); const activateWormholeAndLaunchTerminal = async () => { setTerminalLaunchBusy(true); setTerminalLaunchError(''); setTerminalPrivateEnabled(true); setTerminalPrivateReady(false); setTerminalLauncherOpen(false); launchTerminalDirect(); void continueTerminalLaunchInBackground(); }; // Cleanup polling on unmount useEffect(() => { return () => { if (pollRef.current) clearInterval(pollRef.current); if (timeoutRef.current) clearTimeout(timeoutRef.current); if (activatingPollRef.current) clearInterval(activatingPollRef.current); if (activatingTimeoutRef.current) clearTimeout(activatingTimeoutRef.current); }; }, []); const refreshNodeStatus = async () => { const data = await fetchInfonetNodeStatusSnapshot(true); setNodeStatus(data); setNodeStatusError(''); return data; }; const stopActivatingPolls = useCallback(() => { if (activatingPollRef.current) { clearInterval(activatingPollRef.current); activatingPollRef.current = null; } if (activatingTimeoutRef.current) { clearTimeout(activatingTimeoutRef.current); activatingTimeoutRef.current = null; } }, []); const setNodeEnabled = async (enabled: boolean) => { setNodeToggleBusy(true); setNodeToggleError(''); try { // Auto-generate keys on first activation if (enabled) { setActivatingPhase('keys'); setActivatingTimedOut(false); setNodeStep('activating'); const existing = getNodeIdentity(); if (!existing) { await generateNodeKeys(); } setActivatingPhase('peers'); const torStatus = await startTorHiddenService(); if (!torStatus?.running || !torStatus?.onion_address) { throw new Error(torStatus?.detail || 'Tor onion service did not start'); } } const res = await controlPlaneFetch('/api/settings/node', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }), requireAdminSession: false, }); const data = (await res.json().catch(() => ({}))) as { detail?: string; message?: string; }; if (!res.ok) { throw new Error(data?.detail || data?.message || 'Node settings update failed'); } await refreshNodeStatus(); if (enabled) { // Start fast-polling for sync progress setActivatingPhase('sync'); stopActivatingPolls(); activatingPollRef.current = setInterval(async () => { try { const snap = await fetchInfonetNodeStatusSnapshot(true); setNodeStatus(snap); const outcome = String(snap?.sync_runtime?.last_outcome || '').toLowerCase(); if (outcome === 'ok' || outcome === 'solo') { setActivatingPhase('done'); stopActivatingPolls(); // Auto-transition to 'disable' after brief success display setTimeout(() => setNodeStep('disable'), 2500); } } catch { /* ignore poll errors */ } }, 3000); // Timeout after 90s activatingTimeoutRef.current = setTimeout(() => { setActivatingTimedOut(true); }, 90000); } else { // Disabling — close modal setLauncherOpen(false); setNodeStep('prompt'); } } catch (error) { const message = typeof error === 'object' && error !== null && 'message' in error ? String((error as { message?: string }).message || '') : ''; setNodeToggleError(message || 'Node settings update failed'); if (enabled) setNodeStep('terms'); // Go back to terms on error } finally { setNodeToggleBusy(false); } }; useEffect(() => { setPortalReady(true); }, []); useEffect(() => { let alive = true; const fetchWormhole = async () => { try { const data = await fetchWormholeSettings(); const enabled = Boolean(data?.enabled); await applySecureModeBoundary(enabled); } catch { /* ignore */ } }; const fetchNodeStatus = async () => { try { const data = await fetchInfonetNodeStatusSnapshot(true); if (alive) { setNodeStatus(data); setNodeStatusError(''); } } catch (error) { if (!alive) return; const message = typeof error === 'object' && error !== null && 'message' in error ? String((error as { message?: string }).message || '') : ''; setNodeStatusError(message || 'node runtime unavailable'); } }; const poll = () => { fetchWormhole(); fetchNodeStatus(); }; poll(); const interval = setInterval(poll, 15000); return () => { alive = false; clearInterval(interval); }; }, []); const checkForUpdates = async () => { setUpdateStatus('checking'); try { const desktopContext = await getDesktopUpdateContext(); const runtime = classifyUpdateRuntime(desktopContext); const res = await fetch( 'https://api.github.com/repos/BigBodyCobain/Shadowbroker/releases/latest', ); if (!res.ok) throw new Error('Failed to fetch'); const data = (await res.json()) as GitHubLatestRelease; const latest = data.tag_name?.replace('v', '') || data.name?.replace('v', ''); const current = currentVersion.replace('v', ''); const releaseUrl = typeof data.html_url === 'string' && data.html_url.trim().length > 0 ? data.html_url : DEFAULT_RELEASES_URL; const platform = desktopContext?.platform || 'unknown'; const ownsLocalBackend = Boolean(desktopContext?.owns_local_backend); setReleasePageUrl(releaseUrl); setManualUpdateUrl(getPreferredManualUpdateUrl(data, runtime, platform)); let resolvedAction = getUpdateAction(runtime); let resolvedDetail = runtime === 'desktop_packaged' ? packagedUpdateDetail(ownsLocalBackend) : AUTO_UPDATE_DETAIL; if (runtime === 'desktop_packaged') { try { const desktopUpdate = await checkDesktopUpdaterUpdate(); if (desktopUpdate?.version) { resolvedAction = 'desktop_updater'; resolvedDetail = DESKTOP_UPDATER_DETAIL; setLatestVersion(desktopUpdate.version.replace(/^v/i, '')); setUpdateAction(resolvedAction); setUpdateDetail(resolvedDetail); setUpdateStatus('available'); return; } } catch (desktopUpdaterError) { console.warn('Desktop updater check failed; falling back to release download:', desktopUpdaterError); } } setUpdateAction(resolvedAction); setUpdateDetail( resolvedDetail, ); if (latest && latest !== current) { setLatestVersion(latest); setUpdateStatus('available'); } else { setUpdateStatus('uptodate'); setTimeout(() => setUpdateStatus('idle'), 3000); } } catch (err) { console.error('Update check failed:', err); setUpdateStatus('error'); setTimeout(() => setUpdateStatus('idle'), 3000); } }; const startRestartPolling = () => { setUpdateStatus('restarting'); // Poll /api/health until backend comes back pollRef.current = setInterval(async () => { try { const h = await fetch(`${API_BASE}/api/health`); if (h.ok) { if (pollRef.current) clearInterval(pollRef.current); if (timeoutRef.current) clearTimeout(timeoutRef.current); window.location.reload(); } } catch { // Backend still down — keep polling } }, 3000); // Give up after 90 seconds timeoutRef.current = setTimeout(() => { if (pollRef.current) clearInterval(pollRef.current); setErrorMessage('Restart timed out — the app may need to be started manually.'); setUpdateStatus('update_error'); }, 90000); }; const triggerUpdate = async () => { if (updateAction === 'manual_download') { window.open(manualUpdateUrl, '_blank', 'noopener,noreferrer'); setUpdateStatus('idle'); return; } if (updateAction === 'desktop_updater') { setUpdateStatus('updating'); setErrorMessage(''); try { await installDesktopUpdaterUpdate(); setUpdateStatus('restarting'); } catch (err) { const message = typeof err === 'object' && err !== null && 'message' in err ? String((err as { message?: string }).message) : ''; setErrorMessage( message === 'desktop_update_installed_restart_required' ? 'Update installed. Restart ShadowBroker to finish applying it.' : message || 'Desktop updater failed. Use manual download if this keeps happening.', ); setUpdateStatus('update_error'); } return; } setUpdateStatus('updating'); setErrorMessage(''); try { const res = await controlPlaneFetch('/api/system/update', { method: 'POST' }); const data = (await res.json().catch(() => ({}))) as { ok?: boolean; status?: string; message?: string; detail?: string; manual_url?: string; release_url?: string; docker_commands?: string; }; if (typeof data.manual_url === 'string' && data.manual_url.trim().length > 0) { setManualUpdateUrl(data.manual_url); } if (typeof data.release_url === 'string' && data.release_url.trim().length > 0) { setReleasePageUrl(data.release_url); } if (data?.status === 'docker') { setDockerCommands(data.docker_commands || 'docker compose pull && docker compose up -d'); setUpdateStatus('docker_update'); return; } if (data?.status === 'manual') { const targetUrl = typeof data.manual_url === 'string' && data.manual_url.trim().length > 0 ? data.manual_url : manualUpdateUrl; window.open(targetUrl, '_blank', 'noopener,noreferrer'); setUpdateStatus('idle'); return; } if (!res.ok || data?.ok === false || data?.status === 'error') { const message = data?.detail || data?.message || 'control_plane_request_failed'; const error = new Error(message) as Error & { manualUrl?: string }; error.manualUrl = data?.manual_url; throw error; } startRestartPolling(); } catch (err) { // The update extracts files over the project, which causes the Next.js // dev server to hot-reload and drop the proxy connection mid-request. // A network error during update likely means it SUCCEEDED and the // server is restarting — transition to polling instead of showing failure. const message = typeof err === 'object' && err !== null && 'message' in err ? String((err as { message?: string }).message) : ''; const isNetworkDrop = err instanceof TypeError || message === 'Failed to fetch'; if (isNetworkDrop) { startRestartPolling(); } else { const manualUrl = typeof err === 'object' && err !== null && 'manualUrl' in err ? String((err as { manualUrl?: string }).manualUrl || '') : ''; if (manualUrl) { setManualUpdateUrl(manualUrl); } setErrorMessage(message || 'Unknown error'); setUpdateStatus('update_error'); } } }; // ── Confirmation Dialog ── const renderConfirmDialog = () => (
{/* Header */}
{t('update.autoUpdate').toUpperCase()} v{currentVersion} → v{latestVersion}
{/* Actions */}

{updateDetail}

{updateAction === 'manual_download' ? t('update.viewRelease') : t('update.manualDownload')}
); // ── Error Dialog ── const renderErrorDialog = () => (
{t('update.updateFailed')}

{errorMessage}

{updateAction === 'manual_download' ? t('update.viewRelease') : t('update.manualDownload')}
); // ── Docker Update Dialog ── const renderDockerDialog = () => (
{t('update.dockerUpdate')} — v{latestVersion}

{t('update.dockerUpdateDetail')}

{dockerCommands}
{t('update.viewRelease')}
); const nodeMode = String(nodeStatus?.node_mode || 'participant').trim().toUpperCase(); const nodeEnabled = Boolean(nodeStatus?.node_enabled); const syncOutcomeRaw = String(nodeStatus?.sync_runtime?.last_outcome || 'idle') .trim() .toLowerCase(); const syncError = String(nodeStatus?.sync_runtime?.last_error || '').trim().toLowerCase(); const syncOutcome = !nodeEnabled ? 'OFF' : syncOutcomeRaw === 'solo' || syncError === 'no active sync peers' ? 'SOLO' : syncOutcomeRaw === 'ok' ? 'CONNECTED' : syncOutcomeRaw === 'running' ? 'SYNCING' : syncOutcomeRaw === 'fork' ? 'FORK STOP' : syncOutcomeRaw === 'error' ? 'SYNC ISSUE' : 'ACTIVE'; const bootstrapFailed = Boolean(nodeStatus?.bootstrap?.last_bootstrap_error); const nodeIndicatorClass = !nodeEnabled ? 'bg-rose-400' : syncOutcomeRaw === 'solo' || syncError === 'no active sync peers' ? 'bg-cyan-400' : syncOutcomeRaw === 'ok' ? 'bg-green-400' : syncOutcomeRaw === 'fork' || bootstrapFailed ? 'bg-amber-400' : syncOutcomeRaw === 'error' ? 'bg-rose-400' : 'bg-cyan-400'; const nodeTitle = !nodeEnabled ? `${nodeMode} node • off` : bootstrapFailed ? `${nodeMode} node • bootstrap warning` : `${nodeMode} node • ${syncOutcome.toLowerCase()}`; const closeLauncher = () => { stopActivatingPolls(); setLauncherOpen(false); setNodeStep('prompt'); setNodeToggleError(''); setActivatingTimedOut(false); }; // Uniform button style (matches UPDATES button) const btnBase = 'flex items-center justify-center gap-1 px-2 py-1.5 bg-[var(--bg-primary)]/70 border border-[var(--border-primary)] hover:border-cyan-500/50 hover:bg-[var(--hover-accent)] transition-all text-[10px] text-[var(--text-secondary)] font-mono cursor-pointer flex-1'; const nodeLauncherModal = portalReady && launcherOpen ? createPortal(
{nodeStep === 'disable' ? ( <>
{t('node.nodeActivated')}. {(() => { const id = getNodeIdentity(); return id?.nodeId ? (
{id.nodeId}
) : null; })()}
{syncOutcome.toLowerCase()} {(nodeStatus?.total_events ?? 0) > 0 && {nodeStatus?.total_events} {t('node.events')}} {(nodeStatus?.bootstrap?.sync_peer_count ?? 0) > 0 && {nodeStatus?.bootstrap?.sync_peer_count} {t('node.peers')}}
{t('node.keepSyncing')}
{nodeToggleError && (
{nodeToggleError}
)}
) : nodeStep === 'activating' ? ( <>
{/* Step: Generate identity */}
{activatingPhase === 'keys' ? ( ) : ( )} {activatingPhase === 'keys' ? t('node.generatingIdentity') : t('node.identityReady')} {activatingPhase !== 'keys' && (() => { const id = getNodeIdentity(); return id?.nodeId ? ( {id.nodeId} ) : null; })()}
{/* Step: Connect to relay */}
{activatingPhase === 'keys' ? ( ) : activatingPhase === 'peers' ? ( ) : ( )} {activatingPhase === 'keys' ? t('node.preparingTransport') : activatingPhase === 'peers' ? t('node.findingPeers') : t('node.peersReady')}
{/* Step: Sync chain */}
{(activatingPhase === 'keys' || activatingPhase === 'peers') ? ( ) : activatingPhase === 'sync' ? ( ) : ( )} {activatingPhase === 'done' ? (syncOutcomeRaw === 'solo' ? `${t('node.soloNodeReady')} — ${nodeStatus?.total_events ?? 0} ${t('node.events')}` : `${t('node.synced')} — ${nodeStatus?.total_events ?? 0} ${t('node.events')}`) : activatingPhase === 'sync' ? `${t('node.syncingChain')}${(nodeStatus?.total_events ?? 0) > 0 ? ` ${nodeStatus?.total_events} ${t('node.events')}` : ''}` : t('node.syncingChain')}
{/* Done banner */} {activatingPhase === 'done' && ( <>
{t('node.nodeOnline')}
{t('node.keepSyncing')}
)}
{activatingTimedOut && activatingPhase !== 'done' && (
{t('node.syncTakingLong')}
)} {nodeToggleError && (
{nodeToggleError}
)} {(activatingTimedOut || activatingPhase === 'done') && ( )} ) : nodeStep === 'prompt' ? ( <>
{t('node.activatePrompt')}
{(bootstrapFailed || nodeStatusError || nodeToggleError) && (
{nodeToggleError || nodeStatusError || nodeStatus?.bootstrap?.last_bootstrap_error || 'Node runtime warning detected.'}
)}
) : ( <>
{t('node.termsTitle')}
  • {t('node.term1')}
  • {t('node.term2')}
  • {t('node.term3')}
  • {t('node.term4')}
  • {t('node.term5')}
{nodeMode} • {syncOutcome}
)}
, document.body, ) : null; const terminalStatusLabel = terminalPrivateReady ? t('terminal.privateLaneReady') : terminalPrivateEnabled ? t('terminal.privateLaneStarting') : t('terminal.privateLaneOffline'); const terminalStatusTone = terminalPrivateReady ? 'text-emerald-300' : terminalPrivateEnabled ? 'text-amber-300' : 'text-cyan-300'; const terminalLauncherModal = portalReady && terminalLauncherOpen ? createPortal(
{terminalPrivateReady ? t('terminal.enterTerminal') : t('terminal.terminalDetail')}
{terminalPrivateReady ? t('terminal.identityReady') : t('terminal.identityNotReady')}
{terminalLaunchError && (
{terminalLaunchError}
)}
{t('terminal.beforeYouEnter')}
  • {t('terminal.termTerminal1')}
  • {t('terminal.termTerminal2')}
  • {t('terminal.termTerminal3')}
{t('terminal.wormholeCleanup')}
{t('terminal.cleanupDetail')}
, document.body, ) : null; return ( <> {terminalLauncherModal} {nodeLauncherModal}
{/* Node runtime / private lane */} {/* Terminal toggle */} {/* ── Update Available → opens confirmation ── */} {updateStatus === 'available' && ( )} {/* ── Confirming → show dialog ── */} {updateStatus === 'confirming' && ( <> {renderConfirmDialog()} )} {/* ── Updating → spinner ── */} {updateStatus === 'updating' && (
{t('update.downloadingUpdate')}
)} {/* ── Restarting → spinner + waiting ── */} {updateStatus === 'restarting' && (
{t('update.restarting')}
)} {/* ── Error → show error dialog ── */} {updateStatus === 'update_error' && ( <> {renderErrorDialog()} )} {/* ── Docker update → show pull instructions ── */} {updateStatus === 'docker_update' && ( <> {renderDockerDialog()} )} {/* ── Default states: idle / checking / uptodate / check-error ── */} {!['available', 'confirming', 'updating', 'restarting', 'update_error', 'docker_update'].includes( updateStatus, ) && ( )}
); }