feat(core,server,web,cli): show version in the web UI with update check and admin self-update (#74)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Admin self-update dialog (opened from the sidebar user menu's update reminder):
|
||||
* explains that the new release is downloaded into the install directory and that the
|
||||
* service must be restarted afterwards, then runs POST /api/version/update and shows the
|
||||
* outcome — the CLI's own output tail in a scrollable <pre> for the failed/unsupported
|
||||
* states, and a restart hint on success. Closing is blocked while the update runs (the
|
||||
* request can take minutes; navigating away would just hide the result).
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import type { UpdateRunResponse } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { S } from "../../lib/strings";
|
||||
import { apiErrorText } from "../../lib/api-error";
|
||||
import { Button } from "../ui/button";
|
||||
import { Modal } from "../ui/modal";
|
||||
|
||||
type Phase = "confirm" | "running" | "done";
|
||||
|
||||
export function UpdateDialog({
|
||||
open,
|
||||
onClose,
|
||||
latestVersion,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
latestVersion: string | null;
|
||||
}) {
|
||||
const [phase, setPhase] = useState<Phase>("confirm");
|
||||
const [result, setResult] = useState<UpdateRunResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPhase("confirm");
|
||||
setResult(null);
|
||||
}, [open]);
|
||||
|
||||
const run = async () => {
|
||||
setPhase("running");
|
||||
try {
|
||||
setResult(await api.runUpdate());
|
||||
} catch (e) {
|
||||
// The request itself failed (e.g. the connection dropped mid-run): surface it the
|
||||
// same way as a failed run, with the error text where the output tail would be.
|
||||
setResult({ status: "failed", output: apiErrorText(e), needsRestart: false });
|
||||
}
|
||||
setPhase("done");
|
||||
};
|
||||
|
||||
const statusLine =
|
||||
result === null
|
||||
? null
|
||||
: result.status === "updated"
|
||||
? { text: S.update.updated, className: "text-green-600 dark:text-green-400" }
|
||||
: result.status === "unsupported"
|
||||
? { text: S.update.unsupported, className: "text-amber-600 dark:text-amber-400" }
|
||||
: { text: S.update.failed, className: "text-red-600 dark:text-red-400" };
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={S.update.updateNow}
|
||||
onClose={phase === "running" ? () => undefined : onClose}
|
||||
footer={
|
||||
phase === "done" ? (
|
||||
<Button onClick={onClose}>{S.common.close}</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={onClose} disabled={phase === "running"}>
|
||||
{S.common.cancel}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={phase === "running"} onClick={() => void run()}>
|
||||
{phase === "running" ? S.update.updating : S.update.updateNow}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{phase === "done" && result !== null && statusLine !== null ? (
|
||||
<div className="space-y-3">
|
||||
<p className={`text-sm font-medium ${statusLine.className}`}>{statusLine.text}</p>
|
||||
{result.status === "updated" && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{S.update.restartHint}</p>
|
||||
)}
|
||||
{result.output !== "" && (
|
||||
<pre className="max-h-56 overflow-auto rounded-md bg-gray-100 p-3 text-xs leading-relaxed whitespace-pre-wrap text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
{result.output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{latestVersion !== null && (
|
||||
<p className="text-sm font-medium">{S.update.newVersion(latestVersion)}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{S.update.confirmBody}</p>
|
||||
{phase === "running" && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{S.update.updating}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { S } from "../../lib/strings";
|
||||
import { formatMonthDay } from "../../lib/format";
|
||||
import { apiErrorText } from "../../lib/api-error";
|
||||
import { useAuth } from "../../state/auth";
|
||||
import { useLocale } from "../../state/locale";
|
||||
@@ -48,7 +49,7 @@ import { Dropdown } from "../ui/dropdown";
|
||||
import { AgentAvatar } from "../ui/agent-avatar";
|
||||
import { Chevron } from "../ui/chevron";
|
||||
import { ChevronDown } from "../ui/icons";
|
||||
import { toastError } from "../ui/toast";
|
||||
import { toastError, toastInfo, toastSuccess } from "../ui/toast";
|
||||
import { Truncated } from "../ui/truncated";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Modal } from "../ui/modal";
|
||||
@@ -61,6 +62,8 @@ import { DRAFT_SESSION_ID } from "../../features/chat/chat-page";
|
||||
import { clearDraft, sessionDraftKey } from "../../features/chat/draft-cache";
|
||||
import { CreateProjectDialog, ProjectSettingsDialog } from "./project-dialogs";
|
||||
import { ChangePasswordDialog } from "../account/change-password-dialog";
|
||||
import { UpdateDialog } from "../account/update-dialog";
|
||||
import { forceUpdateCheck, useVersionInfo } from "../../lib/use-version-info";
|
||||
|
||||
function Icon({ d, size = 16 }: { d: string; size?: number }) {
|
||||
return (
|
||||
@@ -114,6 +117,14 @@ const PIN_ICON =
|
||||
const menuItemClass =
|
||||
"block w-full px-3.5 py-2 text-left text-sm transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800";
|
||||
|
||||
/**
|
||||
* Superscript "new version" pill on the version line (accent-colored, raised via
|
||||
* align-super). Kept literally identical to the draft page's copy in
|
||||
* features/chat/draft-view.tsx — the two surfaces must not drift apart.
|
||||
*/
|
||||
const versionBadgeClass =
|
||||
"ml-1.5 inline-block rounded-full bg-[var(--accent-bg)] px-1.5 align-super text-[10px] font-medium leading-4 text-[var(--accent-fg)] transition-opacity duration-150 hover:opacity-80";
|
||||
|
||||
/** Grouping mode of the Session list (persisted; Workspace is the default). */
|
||||
type GroupMode = "workspace" | "agent";
|
||||
const GROUP_MODE_KEY = "penguin.sidebarGroupMode";
|
||||
@@ -190,7 +201,7 @@ export function Sidebar({
|
||||
const { user, logout } = useAuth();
|
||||
const { mode, setMode, fontScale, setFontScale, accent, setAccent, currency, setCurrency } =
|
||||
useTheme();
|
||||
const { lang, setLang } = useLocale();
|
||||
const { lang, locale, setLang } = useLocale();
|
||||
const {
|
||||
projects,
|
||||
currentProject,
|
||||
@@ -220,6 +231,40 @@ export function Sidebar({
|
||||
const [createProjectOpen, setCreateProjectOpen] = useState(false);
|
||||
const [projectSettingsOpen, setProjectSettingsOpen] = useState(false);
|
||||
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
|
||||
const [updateDialogOpen, setUpdateDialogOpen] = useState(false);
|
||||
// Version row + update reminder: nothing is fetched until the dropdown first opens.
|
||||
const { version, update } = useVersionInfo(userOpen);
|
||||
const updateAvailable = update?.updateAvailable === true;
|
||||
// The running version's release date, stamped into core's BUILD_DATE at build time by
|
||||
// the release workflow — displayed as-is, no network involved. Dev builds and releases
|
||||
// that predate the stamping (v0.1.2 and earlier) carry null. Shown as the localized
|
||||
// "last updated" tooltip on the check-for-updates row (the row itself stays uncluttered).
|
||||
const versionDate = version?.buildDate ?? null;
|
||||
/** Manual "check for updates" in flight (row disabled, busy label). */
|
||||
const [updateChecking, setUpdateChecking] = useState(false);
|
||||
/**
|
||||
* Manual update check (owner request): forces a lookup past the server's TTL cache and
|
||||
* pushes the result into the shared version-info store, so the reminder rows, badge,
|
||||
* and dot appear immediately when a newer release is found — that visible change is
|
||||
* the notification then. A toast fires only when nothing changes visibly (#54, one
|
||||
* notification per action): up to date, checks disabled, or a failed lookup (the
|
||||
* check is fail-soft — failure arrives as the `error` field, not an exception; the
|
||||
* catch handles our own server being unreachable).
|
||||
*/
|
||||
const runUpdateCheck = async () => {
|
||||
if (updateChecking) return;
|
||||
setUpdateChecking(true);
|
||||
try {
|
||||
const res = await forceUpdateCheck();
|
||||
if (res.disabled === true) toastInfo(S.update.checkDisabled);
|
||||
else if (res.error !== undefined) toastError(S.update.checkFailed);
|
||||
else if (!res.updateAvailable) toastSuccess(S.update.upToDate);
|
||||
} catch (e) {
|
||||
toastError(apiErrorText(e));
|
||||
} finally {
|
||||
setUpdateChecking(false);
|
||||
}
|
||||
};
|
||||
const currentProjectId = currentProject?.projectId ?? null;
|
||||
const collapseStoreKey = currentProjectId === null ? null : collapsedGroupsKey(currentProjectId);
|
||||
const pinStoreKey = currentProjectId === null ? null : pinnedGroupsKey(currentProjectId);
|
||||
@@ -958,8 +1003,17 @@ export function Sidebar({
|
||||
onClick={() => setUserOpen(!userOpen)}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-gray-200/70 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900">
|
||||
<span className="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900">
|
||||
{(user?.userId ?? "?").slice(0, 1).toUpperCase()}
|
||||
{/* Update reminder dot: only once the lazy check has actually run and found a
|
||||
newer release. The border (sidebar background color) separates it from the
|
||||
avatar for every accent — the neutral accent matches the avatar fill. */}
|
||||
{updateAvailable && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full border-2 border-gray-50 bg-[var(--accent-bg)] dark:border-gray-900"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{user?.userId}</span>
|
||||
{user?.isAdmin && (
|
||||
@@ -990,6 +1044,41 @@ export function Sidebar({
|
||||
<Segmented options={langOptions} value={lang} onChange={setLang} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
{/* Update reminder: release-notes link, plus the self-update action for admins.
|
||||
Only rendered after the lazy check found a newer release. */}
|
||||
{update !== null && update.updateAvailable && update.latestVersion !== null && (
|
||||
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
|
||||
{update.releaseUrl !== null ? (
|
||||
<a
|
||||
href={update.releaseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={S.update.releaseNotes}
|
||||
className={`${menuItemClass} flex items-center gap-2 font-medium`}
|
||||
>
|
||||
<span aria-hidden className="h-2 w-2 rounded-full bg-[var(--accent-bg)]" />
|
||||
{S.update.newVersion(update.latestVersion)}
|
||||
</a>
|
||||
) : (
|
||||
<span className={`${menuItemClass} flex items-center gap-2 font-medium`}>
|
||||
<span aria-hidden className="h-2 w-2 rounded-full bg-[var(--accent-bg)]" />
|
||||
{S.update.newVersion(update.latestVersion)}
|
||||
</span>
|
||||
)}
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
className={menuItemClass}
|
||||
onClick={() => {
|
||||
setUserOpen(false);
|
||||
setUpdateDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{S.update.updateNow}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1001,6 +1090,40 @@ export function Sidebar({
|
||||
>
|
||||
{S.account.changePassword}
|
||||
</button>
|
||||
{/* Manual update check, directly below Change password (owner layout). The
|
||||
running version sits muted on the right of the same row — no product-name
|
||||
prefix — and the superscript new-version badge rides along there as a
|
||||
passive indicator: a nested button/link inside this button row would be
|
||||
invalid HTML, and whenever the badge shows, the clickable affordances
|
||||
(release link / Update now) are already present in the reminder rows
|
||||
above. The "last updated" date lives in the row tooltip, keeping the row
|
||||
itself uncluttered. While checking, the label swaps to the busy text and
|
||||
the right-side version stays put. Nothing is fetched until the menu first
|
||||
opens; the version span appears once /api/version resolves. */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateChecking}
|
||||
onClick={() => void runUpdateCheck()}
|
||||
{...(versionDate !== null
|
||||
? { title: S.update.lastUpdated(formatMonthDay(versionDate, locale)) }
|
||||
: {})}
|
||||
className={`${menuItemClass} flex items-center justify-between gap-2 disabled:cursor-default disabled:opacity-60`}
|
||||
>
|
||||
<span>{updateChecking ? S.update.checking : S.update.checkNow}</span>
|
||||
{version !== null && (
|
||||
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
|
||||
{`v${version.version}`}
|
||||
{update !== null && update.updateAvailable && update.latestVersion !== null && (
|
||||
<span
|
||||
className={versionBadgeClass}
|
||||
title={S.update.newVersion(update.latestVersion)}
|
||||
>
|
||||
{S.update.newVersionBadge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{/* User management is visible only to admins (the page route also has its own guard as a fallback). */}
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
@@ -1032,6 +1155,11 @@ export function Sidebar({
|
||||
open={changePasswordOpen}
|
||||
onClose={() => setChangePasswordOpen(false)}
|
||||
/>
|
||||
<UpdateDialog
|
||||
open={updateDialogOpen}
|
||||
onClose={() => setUpdateDialogOpen(false)}
|
||||
latestVersion={update?.latestVersion ?? null}
|
||||
/>
|
||||
|
||||
<CreateProjectDialog
|
||||
open={createProjectOpen}
|
||||
|
||||
Reference in New Issue
Block a user