diff --git a/src/gateway/protocol/schema/snapshot.ts b/src/gateway/protocol/schema/snapshot.ts index 1ac6ebc1a8..98e3182604 100644 --- a/src/gateway/protocol/schema/snapshot.ts +++ b/src/gateway/protocol/schema/snapshot.ts @@ -60,6 +60,13 @@ export const SnapshotSchema = Type.Object( Type.Literal("trusted-proxy"), ]), ), + updateAvailable: Type.Optional( + Type.Object({ + currentVersion: NonEmptyString, + latestVersion: NonEmptyString, + channel: NonEmptyString, + }), + ), }, { additionalProperties: false }, ); diff --git a/src/gateway/server/health-state.ts b/src/gateway/server/health-state.ts index 3e2ef9522d..5d87538814 100644 --- a/src/gateway/server/health-state.ts +++ b/src/gateway/server/health-state.ts @@ -2,6 +2,7 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { getHealthSnapshot, type HealthSummary } from "../../commands/health.js"; import { CONFIG_PATH, STATE_DIR, loadConfig } from "../../config/config.js"; import { resolveMainSessionKey } from "../../config/sessions.js"; +import { getUpdateAvailable } from "../../infra/update-startup.js"; import { listSystemPresence } from "../../infra/system-presence.js"; import { normalizeMainKey } from "../../routing/session-key.js"; import { resolveGatewayAuth } from "../auth.js"; @@ -22,6 +23,7 @@ export function buildGatewaySnapshot(): Snapshot { const presence = listSystemPresence(); const uptimeMs = Math.round(process.uptime() * 1000); const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env }); + const updateAvailable = getUpdateAvailable() ?? undefined; // Health is async; caller should await getHealthSnapshot and replace later if needed. const emptyHealth: unknown = {}; return { @@ -39,6 +41,7 @@ export function buildGatewaySnapshot(): Snapshot { scope, }, authMode: auth.mode, + updateAvailable, }; } diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index 7ef7c5c40f..5739c38cab 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -14,6 +14,18 @@ type UpdateCheckState = { lastNotifiedTag?: string; }; +type UpdateAvailable = { + currentVersion: string; + latestVersion: string; + channel: string; +}; + +let updateAvailableCache: UpdateAvailable | null = null; + +export function getUpdateAvailable(): UpdateAvailable | null { + return updateAvailableCache; +} + const UPDATE_CHECK_FILENAME = "update-check.json"; const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -100,6 +112,11 @@ export async function runGatewayUpdateCheck(params: { const cmp = compareSemverStrings(VERSION, resolved.version); if (cmp != null && cmp < 0) { + updateAvailableCache = { + currentVersion: VERSION, + latestVersion: resolved.version, + channel: tag, + }; const shouldNotify = state.lastNotifiedVersion !== resolved.version || state.lastNotifiedTag !== tag; if (shouldNotify) { diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 0b1d56ef77..77f1212919 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1,5 +1,41 @@ @import "./chat.css"; +/* =========================================== + Update Banner + =========================================== */ + +.update-banner { + position: sticky; + top: 0; + z-index: 10; + margin: 0 calc(-1 * var(--shell-pad)) 0; + border-radius: 0; + border-left: none; + border-right: none; + text-align: center; + font-weight: 500; + padding: 10px 16px; +} + +.update-banner code { + background: rgba(239, 68, 68, 0.15); + padding: 2px 6px; + border-radius: 4px; + font-size: 12px; +} + +.update-banner__btn { + margin-left: 8px; + border-color: var(--danger); + color: var(--danger); + font-size: 12px; + padding: 4px 12px; +} + +.update-banner__btn:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.15); +} + /* =========================================== Cards - Refined with depth =========================================== */ diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index aaa02c4440..8200c79757 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -54,6 +54,7 @@ type GatewayHost = { refreshSessionsAfterChat: Set; execApprovalQueue: ExecApprovalRequest[]; execApprovalError: string | null; + updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null; }; type SessionDefaultsSnapshot = { @@ -278,6 +279,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { presence?: PresenceEntry[]; health?: HealthSnapshot; sessionDefaults?: SessionDefaultsSnapshot; + updateAvailable?: { currentVersion: string; latestVersion: string; channel: string }; } | undefined; if (snapshot?.presence && Array.isArray(snapshot.presence)) { @@ -289,4 +291,5 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { if (snapshot?.sessionDefaults) { applySessionDefaults(host, snapshot.sessionDefaults); } + host.updateAvailable = snapshot?.updateAvailable ?? null; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index ee47ea94a9..c5e7dc435f 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -188,6 +188,17 @@ export function renderApp(state: AppViewState) {
+ ${state.updateAvailable + ? html`` + : nothing}
${state.tab === "usage" ? nothing : html`
${titleForTab(state.tab)}
`} diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index ea41b4b073..ff6b004294 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -221,6 +221,7 @@ export type AppViewState = { logsLimit: number; logsMaxBytes: number; logsAtBottom: boolean; + updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null; client: GatewayBrowserClient | null; refreshSessionsAfterChat: Set; connect: () => void; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 84e39067ba..22ca6d1d4b 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -300,6 +300,12 @@ export class OpenClawApp extends LitElement { @state() cronRuns: CronRunLogEntry[] = []; @state() cronBusy = false; + @state() updateAvailable: { + currentVersion: string; + latestVersion: string; + channel: string; + } | null = null; + @state() skillsLoading = false; @state() skillsReport: SkillStatusReport | null = null; @state() skillsError: string | null = null;