/** * @file UpdateNotifier.tsx * @description Modal surfaced when the dashboard's git checkout is behind its * remote tracking branch. Shows how many commits behind, the exact terminal * command to update with copy-to-clipboard, and — when the checkout is on a * fast-forwardable branch — an "Update now" button that calls * `POST /api/updates/apply` to pull, rebuild, and restart the server itself, * then polls until it's back and reloads the page. * * ## 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 by `remote_sha` in `localStorage` so a new upstream * commit re-opens the prompt. 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. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/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 } 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 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-side check in `server/lib/update-check.js`'s `applyUpdate`. */ function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean { return 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 upstream SHA from `localStorage`, or null. */ 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 the current SHA 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); }, []); const show = Boolean( status?.update_available && status.remote_sha && dismissedSha !== status.remote_sha ); const dismiss = useCallback(() => { if (restarting || !status?.remote_sha) return; try { localStorage.setItem(DISMISS_KEY, status.remote_sha); } catch { /* ignore */ } setDismissedSha(status.remote_sha); }, [restarting, status?.remote_sha]); // 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. const pollUntilBack = useCallback(() => { const attempt = () => { api.updates .status() .then(() => window.location.reload()) .catch(() => 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); pollUntilBack(); return; } setError( 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; return (
{ if (e.target === e.currentTarget) dismiss(); }} >
{/* Header */}

{t("title")}

{t("commitsBehind", { count: behind, ref: refLabel })}

{/* Body */}

{t("lead")}

{status.fetch_error ? (
{t("fetchError")}
) : null} {!status.git_repo ? (
{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 === "tracking_canonical" || status.situation === "fork_or_diverged_tracking" ? (

{t("restartNote")}

) : null} {restarting ? (
{t("restarting")}
) : null} {error ? (

{error}

) : null}
{/* Footer */}
{status.manual_command ? ( ) : null} {isAutoApplicable(status.situation) ? ( ) : null}
); }