701 lines
30 KiB
TypeScript
701 lines
30 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 × 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ĩ <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 { 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<UpdateStatusPayload | null>(null);
|
||
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
|
||
// Closed for THIS page load only (Escape, backdrop, ×) — 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 [fixing, setFixing] = useState(false);
|
||
/** The running plugin chain's step, from the polls while it runs. */
|
||
const [progress, setProgress] = useState<UpdateStatusPayload["plugin_update_progress"]>(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 (
|
||
<button
|
||
type="button"
|
||
data-testid="update-chip"
|
||
onClick={() => setClosedKey(null)}
|
||
title={t("chipHint")}
|
||
className="fixed bottom-24 right-4 z-[60] w-72 rounded-lg border border-accent/30 bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
|
||
>
|
||
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
|
||
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" aria-hidden />
|
||
<span className="truncate">
|
||
{status?.latest_release
|
||
? t("chipTitle", { version: status.latest_release })
|
||
: t("updating")}
|
||
</span>
|
||
</span>
|
||
<span className="mt-1 block truncate text-[11px] text-fg-muted">
|
||
{stepText || (applying ? t("updating") : t("restartingShort"))}
|
||
</span>
|
||
{progress && progress.total > 0 ? (
|
||
<span
|
||
className="mt-1.5 block h-1 overflow-hidden rounded bg-surface-2"
|
||
role="progressbar"
|
||
aria-valuenow={progress.index}
|
||
aria-valuemin={0}
|
||
aria-valuemax={progress.total}
|
||
>
|
||
<span
|
||
className="block h-full bg-accent-solid transition-all"
|
||
style={{ width: `${(Math.max(progress.index - 1, 0) / progress.total) * 100}%` }}
|
||
/>
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
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 max-h-full flex flex-col 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}
|
||
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="min-h-0 overflow-y-auto 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. */}
|
||
<span>
|
||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||
{stepText ? (
|
||
<span className="block mt-0.5 font-medium" data-testid="update-step">
|
||
{stepText}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
</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 whitespace-pre-wrap break-words max-h-48 overflow-auto"
|
||
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"
|
||
>
|
||
<SkipForward className="w-3.5 h-3.5" aria-hidden />
|
||
{t("skipVersion")}
|
||
</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}
|
||
{/* Only after a failed update: an AI agent takes the error from here. */}
|
||
{error ? (
|
||
<button
|
||
type="button"
|
||
onClick={autoFix}
|
||
disabled={busy}
|
||
title={t("autoFixHint")}
|
||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||
>
|
||
{fixing ? (
|
||
<RefreshCw className="w-3.5 h-3.5 animate-spin" aria-hidden />
|
||
) : (
|
||
<Wrench className="w-3.5 h-3.5" aria-hidden />
|
||
)}
|
||
{t("autoFix")}
|
||
</button>
|
||
) : null}
|
||
{isAutoApplicable(status.situation) ? (
|
||
<button
|
||
type="button"
|
||
onClick={applyNow}
|
||
disabled={busy}
|
||
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>
|
||
);
|
||
}
|