f0500dc592
- 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
529 lines
22 KiB
TypeScript
529 lines
22 KiB
TypeScript
/**
|
||
* @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>
|
||
);
|
||
}
|