From 51fd5d87f7a32ab7ef0969e4d88fceddfcd326b7 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 26 Jan 2026 13:36:36 +0000 Subject: [PATCH] feat(ui): toast when UI updates --- packages/ui/src/lib/i18n/messages/en/app.ts | 3 + packages/ui/src/stores/releases.ts | 85 ++++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/lib/i18n/messages/en/app.ts b/packages/ui/src/lib/i18n/messages/en/app.ts index 1965883b..3a9345d3 100644 --- a/packages/ui/src/lib/i18n/messages/en/app.ts +++ b/packages/ui/src/lib/i18n/messages/en/app.ts @@ -26,4 +26,7 @@ export const appMessages = { "releases.upgradeRequired.message.withVersion": "Update to CodeNomad {version} to use the latest UI.", "releases.upgradeRequired.message.noVersion": "Update CodeNomad to use the latest UI.", "releases.upgradeRequired.action.getUpdate": "Get update", + + "releases.uiUpdated.title": "UI updated", + "releases.uiUpdated.message": "UI is now updated to {version}.", } as const diff --git a/packages/ui/src/stores/releases.ts b/packages/ui/src/stores/releases.ts index f1925f13..d02bd4b3 100644 --- a/packages/ui/src/stores/releases.ts +++ b/packages/ui/src/stores/releases.ts @@ -1,5 +1,5 @@ import { createEffect, createSignal } from "solid-js" -import type { SupportMeta } from "../../../server/src/api-types" +import type { ServerMeta, SupportMeta } from "../../../server/src/api-types" import { getServerMeta } from "../lib/server-meta" import { showToastNotification, ToastHandle } from "../lib/notifications" import { getLogger } from "../lib/logger" @@ -10,10 +10,13 @@ const log = getLogger("actions") const [supportInfo, setSupportInfo] = createSignal(null) +const UI_VERSION_STORAGE_KEY = "codenomad:lastSeenUiVersion" + let initialized = false let visibilityEffectInitialized = false let activeToast: ToastHandle | null = null let activeToastKey: string | null = null +let uiUpdateToasted = false function dismissActiveToast() { if (activeToast) { @@ -76,6 +79,7 @@ async function refreshFromMeta() { try { const meta = await getServerMeta(true) setSupportInfo(meta.support ?? null) + maybeNotifyUiUpdated(meta) } catch (error) { log.warn("Unable to load server metadata for support info", error) } @@ -84,3 +88,82 @@ async function refreshFromMeta() { export function useSupportInfo() { return supportInfo } + +function maybeNotifyUiUpdated(meta: ServerMeta) { + if (uiUpdateToasted) return + uiUpdateToasted = true + + const currentVersion = meta.ui?.version?.trim() + if (!currentVersion) return + + const previousVersion = safeReadLocalStorage(UI_VERSION_STORAGE_KEY) + safeWriteLocalStorage(UI_VERSION_STORAGE_KEY, currentVersion) + + if (!previousVersion) return + if (previousVersion === currentVersion) return + + // Only show the "updated" toast when the server is serving a downloaded UI bundle. + if (meta.ui?.source !== "downloaded") return + if (!isSemverUpgrade(previousVersion, currentVersion)) return + + showToastNotification({ + title: tGlobal("releases.uiUpdated.title"), + message: tGlobal("releases.uiUpdated.message", { version: currentVersion }), + variant: "success", + duration: 8000, + position: "bottom-right", + }) +} + +function safeReadLocalStorage(key: string): string | null { + try { + if (typeof window === "undefined" || !window.localStorage) return null + return window.localStorage.getItem(key) + } catch { + return null + } +} + +function safeWriteLocalStorage(key: string, value: string) { + try { + if (typeof window === "undefined" || !window.localStorage) return + window.localStorage.setItem(key, value) + } catch { + // ignore + } +} + +function isSemverUpgrade(previous: string, current: string): boolean { + const prevParsed = parseSemverCore(previous) + const currParsed = parseSemverCore(current) + if (!prevParsed || !currParsed) { + // If either version isn't semver-like, default to "changed". + return true + } + return compareSemverCore(currParsed, prevParsed) > 0 +} + +function compareSemverCore(a: { major: number; minor: number; patch: number }, b: { major: number; minor: number; patch: number }): number { + if (a.major !== b.major) return a.major > b.major ? 1 : -1 + if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1 + if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1 + return 0 +} + +function parseSemverCore(value: string): { major: number; minor: number; patch: number } | null { + const core = value.trim().replace(/^v/i, "").split("-", 1)[0] + if (!core) return null + const parts = core.split(".") + if (parts.length < 2) return null + + const parsePart = (input: string | undefined) => { + const n = Number.parseInt((input ?? "0").replace(/[^0-9]/g, ""), 10) + return Number.isFinite(n) ? n : 0 + } + + return { + major: parsePart(parts[0]), + minor: parsePart(parts[1]), + patch: parsePart(parts[2]), + } +}