/** * @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. * * ## State sources * - Initial fetch via `api.updates.status()` on mount. * - Background re-check via `api.updates.check()` every hour * ({@link AUTO_CHECK_INTERVAL_MS}), plus the manual "Check now" button. * - 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. Settings can reset dismissal via the * `dashboard:reset-update-dismissal` window event. * * @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, useState } from "react"; import { useTranslation } from "react-i18next"; import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import type { UpdateStatusPayload, WSMessage } from "../lib/types"; /** `localStorage` key storing the dismissed release tag (or upstream SHA). */ const DISMISS_KEY = "agent-monitor-update-dismissed-sha"; /** How often to silently re-check for updates in the background. */ const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000; /** 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" ); } /** 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 { t } = useTranslation("updates"); const [status, setStatus] = useState(null); const [dismissedSha, setDismissedSha] = useState(loadDismissedSha); 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 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]); // Background re-check every hour, on top of the initial mount fetch and the // manual "Check now" button — so a long-lived tab notices an update without // the user having to click anything. useEffect(() => { const id = setInterval(() => { api.updates .check() .then(syncFromPayload) .catch(() => {}); }, AUTO_CHECK_INTERVAL_MS); return () => clearInterval(id); }, [syncFromPayload]); useEffect(() => { const handler = () => setDismissedSha(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). const dismissKey = status?.latest_release || status?.remote_sha || null; const show = Boolean(status?.update_available && dismissKey && dismissedSha !== dismissKey); const dismiss = useCallback(() => { if (restarting || !dismissKey) return; try { localStorage.setItem(DISMISS_KEY, dismissKey); } catch { /* ignore */ } setDismissedSha(dismissKey); }, [restarting, dismissKey]); // Escape to dismiss - standard modal affordance. useEffect(() => { if (!show) return; const handler = (e: KeyboardEvent) => { if (e.key === "Escape") dismiss(); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [show, dismiss]); const copyCmd = async () => { if (!status?.manual_command) return; try { await navigator.clipboard.writeText(status.manual_command); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { /* ignore */ } }; 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) => { let sawDown = !requireDownFirst; const attempt = () => { api.updates .status() .then(() => { if (sawDown) window.location.reload(); else setTimeout(attempt, 1500); }) .catch(() => { sawDown = true; setTimeout(attempt, 1500); }); }; setTimeout(attempt, 1500); }, []); const applyNow = async () => { if (applying || restarting) return; setError(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"); 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); } }; 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) dismiss(); }} > {/* max-w-2xl, not lg: the footer carries up to four buttons and the longest label is a whole sentence ("restarting the dashboard…"). */}
{/* Header */}

{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 && !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")}
) : 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} {isAutoApplicable(status.situation) ? ( ) : null}
); }