Files
AgentHub/client/src/components/UpdateNotifier.tsx
T
nntrivi2001 f0500dc592 feat: AI commit messages, change tree, router behavesAs, sidebar times (v2.2.0)
- Changes panel: Files and Changes merged into one panel; the overview's
  groups drawn as a folder tree (compacted chains, file counts, fold,
  stage/unstage a whole folder)
- AI-suggested commit message for what is staged
  (POST /api/lanes/:id/git/commit-message, claude -p --safe-mode, haiku)
- AI Router: optional "Behaves as" Claude model id, written as
  modelPicker options into the router's --settings file (issue #56)
- Sidebar: session rows show last activity, live rows their start time;
  a closed Terminal row can be removed (DELETE /api/run/history/:id)
- Every pane's CLI starts only once a client is attached; claude panes
  no longer forced inline; tmux scrollbar one column wide
- Update notifier: "Check for updates" reopens a closed notice
2026-10-06 12:36:06 +07:00

529 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 "Not now" close it
* for this page load only (never while applying); "Skip this version" is the
* one persistent, per-version dismiss.
*
* ## 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. 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ĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* 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 { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { copyText } from "../lib/clipboard";
import { useDialog } from "../hooks/useDialog";
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<UpdateStatusPayload | null>(null);
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
// Closed for THIS page load only (Escape, backdrop, ×, "Not now") — the
// persistent per-version dismiss is the explicit "Skip this version".
const [closedKey, setClosedKey] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(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(() => {
// 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);
const show = Boolean(
status?.update_available &&
dismissKey &&
dismissedSha !== dismissKey &&
closedKey !== dismissKey
);
const busy = applying || restarting;
const closeForNow = useCallback(() => {
if (busy || !dismissKey) return;
setClosedKey(dismissKey);
}, [busy, dismissKey]);
const dismiss = useCallback(() => {
if (busy || !dismissKey) return;
try {
localStorage.setItem(DISMISS_KEY, dismissKey);
} catch {
/* ignore */
}
setDismissedSha(dismissKey);
}, [busy, dismissKey]);
// Escape closes for now — never while an update is applying.
useDialog(show, panelRef, busy ? undefined : 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) => {
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 (
<div
className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
role="dialog"
aria-modal="true"
aria-labelledby="update-notifier-title"
onClick={(e) => {
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…"). */}
<div
ref={panelRef}
className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden"
>
{/* Header */}
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-lg bg-accent-muted border border-accent/30 flex items-center justify-center flex-shrink-0">
<Download className="w-4 h-4 text-accent" aria-hidden />
</div>
<div className="min-w-0">
<h2
id="update-notifier-title"
className="text-sm font-semibold text-fg-primary truncate"
>
{installed
? t("installedTitle", { version: installed.version ?? "?" })
: release
? t("releaseTitle", { version: release })
: t("title")}
</h2>
<p className="text-[11px] text-fg-muted mt-0.5 font-mono truncate">
{/* 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 })}
</p>
</div>
</div>
<button
type="button"
onClick={closeForNow}
disabled={busy}
aria-label={t("later")}
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Body */}
<div className="px-5 py-4 space-y-3">
<p className="text-sm text-fg-secondary leading-relaxed">
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
</p>
{status.latest_release_url ? (
<a
href={status.latest_release_url}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 text-xs text-accent hover:underline"
>
<ExternalLink className="w-3.5 h-3.5" aria-hidden />
{t("viewRelease")}
{status.latest_release_name ? ` — ${status.latest_release_name}` : null}
</a>
) : 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" ? (
<div className="text-xs text-fg-secondary bg-surface-2 border border-border rounded-lg px-3 py-2">
{t("releaseUnavailable", { reason: status.release_check })}
</div>
) : null}
{status.fetch_error ? (
<div className="text-xs text-status-warning/90 bg-status-warning/5 border border-status-warning/20 rounded-lg px-3 py-2">
{t("fetchError")}
</div>
) : null}
{!status.git_repo && status.install_kind !== "plugin" ? (
<div className="text-xs text-fg-secondary bg-surface-2 border border-border rounded-lg px-3 py-2">
{t("notGit")}
</div>
) : null}
{status.situation_note ? (
<div className="text-xs text-status-warning/90 bg-status-warning/5 border border-status-warning/20 rounded-lg px-3 py-2 leading-relaxed">
{status.situation_note}
</div>
) : null}
{status.manual_command ? (
<pre
className="bg-surface-1 border border-border rounded-lg px-3 py-2.5 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-all leading-relaxed"
aria-label={t("commandLabel")}
>
{status.manual_command}
</pre>
) : 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" ? (
<p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p>
) : null}
{restarting ? (
<div className="text-xs text-accent bg-accent-muted border border-accent/30 rounded-lg px-3 py-2 flex items-center gap-2">
<RefreshCw className="w-3.5 h-3.5 animate-spin flex-shrink-0" aria-hidden />
{/* 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")}
</div>
) : 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 ? (
<div className="text-xs text-status-danger bg-status-danger/5 border border-status-danger/20 rounded-lg px-3 py-2 leading-relaxed">
{t("lastPluginUpdateFailed", {
step: status.last_plugin_update.step,
error: status.last_plugin_update.error || "",
})}
</div>
) : null}
{error ? (
<p className="text-xs text-status-danger" role="alert">
{error}
</p>
) : null}
</div>
{/* 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. */}
<div className="flex flex-wrap items-center justify-end gap-2 whitespace-nowrap px-5 py-3 border-t border-border bg-surface-2/40">
<button
type="button"
onClick={checkNow}
disabled={checking || applying || restarting}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
{checking ? t("checking") : t("checkNow")}
</button>
<button
type="button"
onClick={dismiss}
disabled={busy}
title={t("skipVersionHint")}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{t("skipVersion")}
</button>
<button
type="button"
onClick={closeForNow}
disabled={busy}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{t("later")}
</button>
{status.manual_command ? (
<button
type="button"
onClick={copyCmd}
disabled={copied}
className="btn-ghost disabled:opacity-70"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? t("copied") : t("copy")}
</button>
) : null}
{isAutoApplicable(status.situation) ? (
<button
type="button"
onClick={applyNow}
disabled={applying || restarting}
className="btn-primary disabled:opacity-70"
>
{applying || restarting ? (
<RefreshCw className="w-4 h-4 animate-spin" aria-hidden />
) : (
<Zap className="w-4 h-4" aria-hidden />
)}
{/* `restartingShort`, not `restarting`: the full sentence is
already in the banner above, and a whole sentence on a button
is what forced this footer onto two lines. */}
{applying ? t("updating") : restarting ? t("restartingShort") : t("updateNow")}
</button>
) : null}
</div>
</div>
</div>
);
}