/** * @file UpdateNotifier.tsx * @description Modal surfaced when a newer RELEASE of the dashboard has been * published (or, when no release can be read, when the checkout is behind its * remote tracking branch). Shows the release version against the running one * with a link to its notes, the exact terminal command with copy-to-clipboard, * and — when the install is fast-forwardable — an "Update now" button that * calls `POST /api/updates/apply` to move onto the release tag, rebuild, and * restart the server itself, then polls until it's back and reloads the page. * * A `plugin_managed` install (running out of `~/.claude/plugins/cache/`) gets * the same button, wired to a different mechanism: the server hands it to a * detached chain (`claude plugin update` then `plugin-bootstrap.js --force`) * which pulls the new cache directory, reinstalls the runtime and restarts the * dashboard. It takes minutes rather than seconds, so the progress text differs * — and the poll waits for the old server to actually go DOWN first, because it * keeps answering while the chain installs. New skills/slash commands still * need `/reload-plugins` in an already-open Claude Code session; nothing a * background process can do for it. Escape, the backdrop and × close it for * this page load only — also while an update applies: it keeps running in the * background (the page still reloads itself once the new server is up), and a * failure re-opens the modal with its error. While hidden — or once the status * stops offering the update mid-run — a small chip in the corner keeps showing * it, with the plugin chain's current step from `plugin_update_progress`, and a * click on it shows the modal again. A chain the server reports as running * (started before a reload or from another tab) is adopted the same way; * "Skip this version" is the one * persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the * error to an AI agent: it opens (or adopts) a workspace on the install folder * and starts a Claude Code session there with the error as its prompt — the * agent fixes whatever blocked the update (a dirty lockfile, a failed build…) * and runs the update command; the dashboard itself still decides nothing. * * ## State sources * - Initial fetch via `api.updates.status()` on mount. * - No client-side background re-check: the server checks on its own * schedule and broadcasts `update_status`; "Check now" stays manual. * - Live refresh from WebSocket `update_status` events on {@link eventBus}. * * ## Dismissal persistence * Dismissals are keyed in `localStorage` by the release tag when there is one, * else the upstream `remote_sha`, so a newer release re-opens the prompt while * a dismissed one stays dismissed. The connection modal's "Check for updates" * fires `dashboard:reset-update-dismissal`, which clears both the skip and the * page-load close; `z-[110]` keeps this modal above that `z-[100]` one. * * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../lib/api` * - `../lib/eventBus` * - `../lib/types` * * ## Public surface * - `UpdateNotifier` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **UpdateNotifier** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink, SkipForward, Wrench, } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { copyText } from "../lib/clipboard"; import { toast } from "../lib/toast"; import { useDialog } from "../hooks/useDialog"; import type { UpdateStatusPayload, WSMessage } from "../lib/types"; import { usePermissions } from "./AuthGate"; import { startInFreeTab } from "./lanes/startSession"; /** `localStorage` key storing the dismissed release tag (or upstream SHA). */ const DISMISS_KEY = "agent-monitor-update-dismissed-sha"; /** Situations `POST /api/updates/apply` will actually act on. Mirrors the * server: `release_available` / `tracking_canonical` / `fork_or_diverged_tracking` * go through a git fast-forward, and `plugin_managed` goes through the detached * `plugin-update.js` chain (`claude plugin update` then `plugin-bootstrap --force`), * which is why it belongs here even though `applyUpdate` itself refuses it. */ function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean { return ( situation === "plugin_managed" || situation === "release_available" || situation === "tracking_canonical" || situation === "fork_or_diverged_tracking" ); } /** The prompt the Auto fix session starts with: what failed, where, and the * limits that keep the agent from taking down the dashboard it runs under. */ function autoFixPrompt(status: UpdateStatusPayload, error: string): string { const target = status.latest_release || status.remote_ref || "the newest version"; const plugin = status.situation === "plugin_managed"; return [ `The AgentHub dashboard's self-update to ${target} failed${status.local_version ? ` (running ${status.local_version})` : ""}.`, plugin ? "AgentHub is installed as a Claude Code plugin (the `agenthub` plugin from the `agenthub` marketplace)." : `The install is the git checkout at ${status.repo_root}.`, "", "Error:", "```", error, "```", ...(status.manual_command ? ["", "Update command:", "```", status.manual_command, "```"] : []), "", "Find the cause, fix it, then finish the update" + (plugin ? " (`claude plugin marketplace update agenthub` then `claude plugin update agenthub@agenthub`)." : " by running the update command."), "Rules: never lose the user's work — before discarding any local change, save it (e.g. `git diff > ~/agenthub-update-backup.patch`) and say where; do not stop or restart the AgentHub server, and never kill tmux sessions or the tmux server (this session runs inside one).", "When done, tell me what you changed; I will restart the dashboard from its update prompt.", ].join("\n"); } /** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */ function isUpdatePayload(x: unknown): x is UpdateStatusPayload { return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x; } /** Read the last dismissed release tag / upstream SHA from `localStorage`. */ function loadDismissedSha(): string | null { try { return localStorage.getItem(DISMISS_KEY); } catch { return null; } } /** * Git update availability modal — mounted once in {@link Layout}. * @returns `null` when no update is available or this release was dismissed. */ export function UpdateNotifier() { const { isAdmin } = usePermissions(); const { t } = useTranslation("updates"); const [status, setStatus] = useState(null); const [dismissedSha, setDismissedSha] = useState(loadDismissedSha); // Closed for THIS page load only (Escape, backdrop, ×) — the // persistent per-version dismiss is the explicit "Skip this version". const [closedKey, setClosedKey] = useState(null); const panelRef = useRef(null); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); const [checking, setChecking] = useState(false); const [applying, setApplying] = useState(false); const [restarting, setRestarting] = useState(false); const [fixing, setFixing] = useState(false); /** The running plugin chain's step, from the polls while it runs. */ const [progress, setProgress] = useState(null); const navigate = useNavigate(); const syncFromPayload = useCallback((s: UpdateStatusPayload) => { setStatus(s); if (!s.fetch_error) setError(null); }, []); useEffect(() => { let cancelled = false; api.updates .status() .then((s) => { if (cancelled) return; syncFromPayload(s); eventBus.publish({ type: "update_status", data: s, timestamp: new Date().toISOString(), }); }) .catch(() => {}); return () => { cancelled = true; }; }, [syncFromPayload]); useEffect(() => { return eventBus.subscribe((msg: WSMessage) => { if (msg.type !== "update_status") return; if (isUpdatePayload(msg.data)) syncFromPayload(msg.data); }); }, [syncFromPayload]); useEffect(() => { // An explicit "Check for updates" undoes both kinds of close. const handler = () => { setDismissedSha(null); setClosedKey(null); }; window.addEventListener("dashboard:reset-update-dismissal", handler); return () => window.removeEventListener("dashboard:reset-update-dismissal", handler); }, []); // Keyed by the release tag when there is one, so dismissing v1.5.0 doesn't // also swallow v1.6.0 — and so the prompt survives the tag pointing at a // commit the user already has. Falls back to the upstream SHA for the // commit-count path (no release readable). // A build installed from a terminal while this server kept running has no // tag or sha when the forge is unreachable — key it by its directory. const installed = status?.installed_not_running ?? null; const dismissKey = status?.latest_release || status?.remote_sha || (installed ? `installed:${installed.root}` : null); // Updating replaces the host's install: only an admin is offered it. A // running update stays shown (unless closed) even once the status stops // offering it — the new version may already be on disk before it restarts. const show = Boolean( isAdmin && dismissKey && closedKey !== dismissKey && (applying || restarting || (status?.update_available && dismissedSha !== dismissKey)) ); const busy = applying || restarting || fixing; // Closing while an update applies only hides it: the apply request and the // poll that reloads the page keep running, and a failure re-opens it. const closeForNow = useCallback(() => { if (!dismissKey) return; setClosedKey(dismissKey); }, [dismissKey]); const dismiss = useCallback(() => { if (busy || !dismissKey) return; try { localStorage.setItem(DISMISS_KEY, dismissKey); } catch { /* ignore */ } setDismissedSha(dismissKey); }, [busy, dismissKey]); // Escape closes for now — an applying update carries on in the background. useDialog(show, panelRef, closeForNow); const copyCmd = async () => { if (!status?.manual_command) return; if (await copyText(status.manual_command, true)) { setCopied(true); setTimeout(() => setCopied(false), 2000); } }; const checkNow = async () => { if (checking) return; setError(null); setChecking(true); try { const fresh = await api.updates.check(); syncFromPayload(fresh); } catch (e) { setError(e instanceof Error ? e.message : t("checkError")); } finally { setChecking(false); } }; // Polls `status()` until the restarted server answers again, then reloads // so the tab picks up the new client bundle too — the server can't push // this over its own WebSocket since it's mid-restart. /** * @param requireDownFirst wait for the server to actually disappear before * treating a success as "it's back". The git path rebuilds and restarts * within seconds, but the plugin chain pulls a new cache directory and * reinstalls dependencies first — the OLD server stays up for that whole * stretch, so polling naively would get a 200 from the process we are * waiting to replace and reload onto the same old bundle. */ const pollUntilBack = useCallback( (requireDownFirst = false, fromVersion: string | null = null) => { let sawDown = !requireDownFirst; // Its own verdicts only: an older plugin-update.json is still on disk // until this chain writes one (5 s slack for a clock a little behind). const since = Date.now() - 5000; const attempt = () => { api.updates .status() .then((s) => { setProgress(s.plugin_update_progress ?? null); const v = s.last_plugin_update; const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since; // A failed step, or nothing to pull, leaves the OLD server up: it // never goes down, so waiting for that spun "Restarting…" forever. if (requireDownFirst && v && mine && (v.ok === false || v.step === "already_current")) { syncFromPayload(s); setRestarting(false); if (v.ok) toast.info(t("pluginAlreadyCurrent")); else setClosedKey(null); // closed while it ran: show why it failed return; } // A new version answering is back too, even if a poll never // landed in the short gap between the old server and the new one. const moved = !!fromVersion && !!s.local_version && s.local_version !== fromVersion; if (sawDown || moved) window.location.reload(); else setTimeout(attempt, 1500); }) .catch(() => { sawDown = true; setTimeout(attempt, 1500); }); }; setTimeout(attempt, 1500); }, [syncFromPayload, t] ); // A chain already running on the server (started before a reload, in another // tab, or from the old bundle) is adopted: this tab shows its steps, then // reloads onto the new server like the tab that started it. const running = status?.plugin_update_progress ?? null; const adoptedRef = useRef(false); useEffect(() => { if (!running || !isAdmin || applying || restarting || adoptedRef.current) return; adoptedRef.current = true; setProgress(running); setRestarting(true); pollUntilBack(true, status?.local_version ?? null); }, [running, isAdmin, applying, restarting, pollUntilBack, status?.local_version]); const applyNow = async () => { if (applying || restarting) return; setError(null); setProgress(null); setApplying(true); try { const result = await api.updates.apply(); if (result.applied) { setApplying(false); setRestarting(true); // mode "plugin": the old server lingers while the chain installs, so // only treat a reachable server as "back" after it has gone away once. pollUntilBack(result.mode === "plugin", status?.local_version ?? null); return; } setError( result.reason === "plugin_managed" ? t("reasonPluginManaged") : result.reason === "not_fast_forwardable" ? t("reasonNotFastForwardable") : t("applyError") ); } catch (e) { setError(e instanceof Error ? e.message : t("applyError")); } finally { setApplying(false); } // Not applied: re-open a modal closed while it ran, so the error is seen. setClosedKey(null); }; // Hand the failure to an AI agent: a workspace on the install folder (the // home for a plugin install — never a lane inside the plugin cache) and a // Claude Code session there with the error as its first prompt. const autoFix = async () => { if (busy || !status || !error) return; setFixing(true); try { const plugin = status.situation === "plugin_managed"; const { lane } = await api.lanes.ensure({ ...(plugin || !status.repo_root ? {} : { cwd: status.repo_root, exact: true }), title: t("autoFixWorkspace"), tool: "claude", }); const runId = await startInFreeTab(lane.id, { prompt: autoFixPrompt(status, error) }); if (dismissKey) setClosedKey(dismissKey); navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`); } catch (e) { toast.error(e instanceof Error ? e.message : t("autoFixError")); } finally { setFixing(false); } }; const stepText = progress?.step ? t(`pluginStep.${progress.step}`, { index: progress.index, total: progress.total, defaultValue: progress.step, }) : null; // Hidden while it runs — closed, or the status no longer offers the update // (the new version is already on disk): a chip keeps it in sight. if (!show && isAdmin && (applying || restarting)) { return ( ); } if (!show || !status) return null; const refLabel = status.remote_ref || "origin"; const behind = status.commits_behind ?? 0; const release = status.latest_release || null; const pluginManaged = status.situation === "plugin_managed"; return (
{ if (e.target === e.currentTarget) closeForNow(); }} > {/* max-w-2xl, not lg: the footer carries up to four buttons and the longest label is a whole sentence ("restarting the dashboard…"). */}
{/* Header */}

{installed ? t("installedTitle", { version: installed.version ?? "?" }) : release ? t("releaseTitle", { version: release }) : t("title")}

{/* Never print a commit count on a shallow clone — the number is a graft artefact, not a distance. */} {(release || installed) && !status.shallow_clone && status.local_version ? t("releaseSubtitle", { current: status.local_version }) : t("commitsBehind", { count: behind, ref: refLabel })}

{/* Body */}

{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}

{status.latest_release_url ? ( {t("viewRelease")} {status.latest_release_name ? ` — ${status.latest_release_name}` : null} ) : null} {/* Release lookup failed and detection fell back to commit counting: say so, so "up to date" is never read as "release checked". */} {status.release_check && status.release_check !== "ok" ? (
{t("releaseUnavailable", { reason: status.release_check })}
) : null} {status.fetch_error ? (
{t("fetchError")}
) : null} {!status.git_repo && status.install_kind !== "plugin" ? (
{t("notGit")}
) : null} {status.situation_note ? (
{status.situation_note}
) : null} {status.manual_command ? (
              {status.manual_command}
            
) : null} {/* The restart hint only applies when the printed command actually * rewrites the working tree. Feature-branch / detached-HEAD commands * are fetch-only - restarting the dashboard would change nothing. */} {status.situation === "release_available" || status.situation === "tracking_canonical" || status.situation === "fork_or_diverged_tracking" ? (

{t("restartNote")}

) : null} {restarting ? (
{/* The plugin chain pulls a new cache directory and reinstalls dependencies before it restarts, so it takes minutes, not seconds — saying "restarting" there would look hung. */} {pluginManaged ? t("pluginUpdating") : t("restarting")} {stepText ? ( {stepText} ) : null}
) : null} {/* A previous chain that failed: the only place the user would ever learn why, since the server it would have reported from is the one that never came back. */} {status.last_plugin_update && status.last_plugin_update.ok === false ? (
{t("lastPluginUpdateFailed", { step: status.last_plugin_update.step, error: status.last_plugin_update.error || "", })}
) : null} {error ? (

{error}

) : null}
{/* Footer */} {/* `whitespace-nowrap` inherits, so one class keeps every label on its own single line; `flex-wrap` then breaks the ROW instead of the words if a translation still outgrows the modal. */}
{status.manual_command ? ( ) : null} {/* Only after a failed update: an AI agent takes the error from here. */} {error ? ( ) : null} {isAutoApplicable(status.situation) ? ( ) : null}
); }