diff --git a/src/components/layout/changelog-markdown.tsx b/src/components/layout/changelog-markdown.tsx
new file mode 100644
index 0000000..9848e64
--- /dev/null
+++ b/src/components/layout/changelog-markdown.tsx
@@ -0,0 +1,39 @@
+import Markdown from "react-markdown";
+
+const components = {
+ a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
+
+ {children}
+
+ ),
+ h2: ({ children }: { children?: React.ReactNode }) => (
+
+ {children}
+
+ ),
+ ul: ({ children }: { children?: React.ReactNode }) => (
+
+ ),
+ p: ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ ),
+};
+
+/** Renders a release-notes markdown body, or an "improvements" fallback when empty. */
+export function ChangelogMarkdown({ body }: { body: string }) {
+ if (!body) {
+ return (
+
+ Bug fixes and improvements.
+
+ );
+ }
+ return {body} ;
+}
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
index a83f8c6..d6cef92 100644
--- a/src/components/layout/sidebar.tsx
+++ b/src/components/layout/sidebar.tsx
@@ -11,6 +11,7 @@ import { NavLink } from "react-router-dom";
import { isDesktop } from "@/lib/transport";
import { ScopeSwitcher } from "./scope-switcher";
import { UpdateCard } from "./update-card";
+import { WebUpdateCard } from "./web-update-card";
const mainNavItems = [
{ to: "/", icon: LayoutDashboard, label: "Overview" },
@@ -88,7 +89,7 @@ export function Sidebar() {
{/* Settings separator */}
- {isDesktop() && }
+ {isDesktop() ? : }
diff --git a/src/components/layout/update-card.tsx b/src/components/layout/update-card.tsx
index c720517..ec60ac1 100644
--- a/src/components/layout/update-card.tsx
+++ b/src/components/layout/update-card.tsx
@@ -3,10 +3,11 @@ import { useUpdateStore } from "@/stores/update-store";
export function UpdateCard() {
const available = useUpdateStore((s) => s.available);
+ const dismissed = useUpdateStore((s) => s.dismissed);
const installing = useUpdateStore((s) => s.installing);
const promptUpdate = useUpdateStore((s) => s.promptUpdate);
- if (!available) return null;
+ if (!available || dismissed) return null;
return (
diff --git a/src/components/layout/update-dialog.tsx b/src/components/layout/update-dialog.tsx
index b63123b..525b813 100644
--- a/src/components/layout/update-dialog.tsx
+++ b/src/components/layout/update-dialog.tsx
@@ -1,12 +1,13 @@
import { Download, Loader2, X } from "lucide-react";
-import Markdown from "react-markdown";
import { useUpdateStore } from "@/stores/update-store";
+import { ChangelogMarkdown } from "./changelog-markdown";
export function UpdateDialog() {
const available = useUpdateStore((s) => s.available);
const showChangelog = useUpdateStore((s) => s.showChangelog);
const installing = useUpdateStore((s) => s.installing);
- const dismissChangelog = useUpdateStore((s) => s.dismissChangelog);
+ const dismissDialog = useUpdateStore((s) => s.dismissDialog);
+ const dismissUpdate = useUpdateStore((s) => s.dismissUpdate);
const confirmUpdate = useUpdateStore((s) => s.confirmUpdate);
if (!showChangelog || !available) return null;
@@ -16,7 +17,7 @@ export function UpdateDialog() {
{/* Backdrop */}
{/* Dialog */}
@@ -27,7 +28,7 @@ export function UpdateDialog() {
Update to v{available.version}
@@ -36,47 +37,13 @@ export function UpdateDialog() {
{/* Changelog */}
- {available.body ? (
-
(
-
- {children}
-
- ),
- h2: ({ children }) => (
-
- {children}
-
- ),
- ul: ({ children }) => (
-
- ),
- p: ({ children }) => (
- {children}
- ),
- }}
- >
- {available.body}
-
- ) : (
-
- Bug fixes and improvements.
-
- )}
+
{/* Footer */}
Later
diff --git a/src/components/layout/web-update-card.tsx b/src/components/layout/web-update-card.tsx
new file mode 100644
index 0000000..e66f0d9
--- /dev/null
+++ b/src/components/layout/web-update-card.tsx
@@ -0,0 +1,25 @@
+import { Download } from "lucide-react";
+import { useWebUpdateStore } from "@/stores/web-update-store";
+
+export function WebUpdateCard() {
+ const available = useWebUpdateStore((s) => s.available);
+ const dismissed = useWebUpdateStore((s) => s.dismissed);
+ const promptUpdate = useWebUpdateStore((s) => s.promptUpdate);
+
+ if (!available || dismissed) return null;
+
+ return (
+
+
+ v{available.version} available
+
+
+
+ How to Update
+
+
+ );
+}
diff --git a/src/components/layout/web-update-dialog.tsx b/src/components/layout/web-update-dialog.tsx
new file mode 100644
index 0000000..47ee406
--- /dev/null
+++ b/src/components/layout/web-update-dialog.tsx
@@ -0,0 +1,59 @@
+import { ExternalLink, X } from "lucide-react";
+import { useWebUpdateStore } from "@/stores/web-update-store";
+import { ChangelogMarkdown } from "./changelog-markdown";
+
+const INSTRUCTIONS_URL = "https://github.com/RealZST/HarnessKit#updating";
+
+export function WebUpdateDialog() {
+ const available = useWebUpdateStore((s) => s.available);
+ const showDialog = useWebUpdateStore((s) => s.showDialog);
+ const dismissDialog = useWebUpdateStore((s) => s.dismissDialog);
+ const dismissUpdate = useWebUpdateStore((s) => s.dismissUpdate);
+
+ if (!showDialog || !available) return null;
+
+ return (
+
+
+
+
+
+
+ Update to v{available.version}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx
index feca85f..5dc7826 100644
--- a/src/pages/settings.tsx
+++ b/src/pages/settings.tsx
@@ -24,6 +24,7 @@ import { toast } from "@/stores/toast-store";
import type { AppIcon, ThemeName } from "@/stores/ui-store";
import { useUIStore } from "@/stores/ui-store";
import { useUpdateStore } from "@/stores/update-store";
+import { useWebUpdateStore } from "@/stores/web-update-store";
const THEME_OPTIONS: {
value: ThemeName;
@@ -104,6 +105,48 @@ function UpdateSection() {
);
}
+function WebUpdateSection() {
+ const available = useWebUpdateStore((s) => s.available);
+ const checking = useWebUpdateStore((s) => s.checking);
+ const checkForUpdate = useWebUpdateStore((s) => s.checkForUpdate);
+ const promptUpdate = useWebUpdateStore((s) => s.promptUpdate);
+
+ const handleCheck = async () => {
+ await checkForUpdate(true);
+ if (!useWebUpdateStore.getState().available) {
+ toast.success("You're up to date");
+ }
+ };
+
+ return (
+
+ v{__APP_VERSION__}
+ {available ? (
+
+
+ Update to v{available.version}
+
+ ) : (
+
+ {checking ? (
+
+ ) : (
+
+ )}
+ {checking ? "Checking..." : "Check for Updates"}
+
+ )}
+
+ );
+}
+
export default function SettingsPage() {
const {
themeName,
@@ -236,7 +279,7 @@ export default function SettingsPage() {
Settings
- {isDesktop() && }
+ {isDesktop() ? : }
diff --git a/src/stores/__tests__/web-update-store.test.ts b/src/stores/__tests__/web-update-store.test.ts
new file mode 100644
index 0000000..cd1a6bb
--- /dev/null
+++ b/src/stores/__tests__/web-update-store.test.ts
@@ -0,0 +1,250 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const RELEASES_URL =
+ "https://api.github.com/repos/RealZST/HarnessKit/releases/latest";
+const CACHE_KEY = "hk-web-update-cache";
+const DISMISS_KEY_PREFIX = "hk-update-dismissed-v";
+
+function mockReleasesResponse(tag: string, body = "") {
+ return {
+ ok: true,
+ json: async () => ({ tag_name: tag, body }),
+ } as Response;
+}
+
+describe("web-update-store", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ sessionStorage.clear();
+ vi.resetModules();
+ vi.unstubAllGlobals();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("starts with no update available", async () => {
+ const { useWebUpdateStore } = await import("../web-update-store");
+ const state = useWebUpdateStore.getState();
+ expect(state.available).toBeNull();
+ expect(state.checking).toBe(false);
+ expect(state.showDialog).toBe(false);
+ expect(state.dismissed).toBe(false);
+ });
+
+ it("does not flag update when current version matches latest", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(mockReleasesResponse("v1.2.1")),
+ );
+
+ const { useWebUpdateStore } = await import("../web-update-store");
+ await useWebUpdateStore.getState().checkForUpdate();
+
+ expect(useWebUpdateStore.getState().available).toBeNull();
+ });
+
+ it("flags update when latest is newer than current", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(mockReleasesResponse("v1.3.0", "## Changes")),
+ );
+
+ const { useWebUpdateStore } = await import("../web-update-store");
+ await useWebUpdateStore.getState().checkForUpdate();
+
+ const { available } = useWebUpdateStore.getState();
+ expect(available?.version).toBe("1.3.0");
+ expect(available?.body).toContain("## Changes");
+ });
+
+ it("does not flag update when latest is older (downgrade)", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(mockReleasesResponse("v1.0.0")),
+ );
+
+ const { useWebUpdateStore } = await import("../web-update-store");
+ await useWebUpdateStore.getState().checkForUpdate();
+
+ expect(useWebUpdateStore.getState().available).toBeNull();
+ });
+
+ it("strips 'New Contributors' and 'Full Changelog' sections from body", async () => {
+ const raw = [
+ "## What's Changed",
+ "- feat: thing",
+ "## New Contributors",
+ "@someone made their first contribution",
+ "**Full Changelog**: https://...",
+ ].join("\n");
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(mockReleasesResponse("v1.3.0", raw)),
+ );
+
+ const { useWebUpdateStore } = await import("../web-update-store");
+ await useWebUpdateStore.getState().checkForUpdate();
+
+ const body = useWebUpdateStore.getState().available?.body ?? "";
+ expect(body).toContain("## What's Changed");
+ expect(body).toContain("- feat: thing");
+ expect(body).not.toContain("New Contributors");
+ expect(body).not.toContain("Full Changelog");
+ });
+
+ it("promptUpdate opens dialog only when an update is available", async () => {
+ const { useWebUpdateStore } = await import("../web-update-store");
+
+ useWebUpdateStore.getState().promptUpdate();
+ expect(useWebUpdateStore.getState().showDialog).toBe(false);
+
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(mockReleasesResponse("v1.3.0")),
+ );
+ await useWebUpdateStore.getState().checkForUpdate();
+ useWebUpdateStore.getState().promptUpdate();
+ expect(useWebUpdateStore.getState().showDialog).toBe(true);
+ });
+
+ it("dismissDialog closes the dialog without persisting dismissal", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(mockReleasesResponse("v1.3.0")),
+ );
+ const { useWebUpdateStore } = await import("../web-update-store");
+ await useWebUpdateStore.getState().checkForUpdate();
+ useWebUpdateStore.getState().promptUpdate();
+ expect(useWebUpdateStore.getState().showDialog).toBe(true);
+
+ useWebUpdateStore.getState().dismissDialog();
+ expect(useWebUpdateStore.getState().showDialog).toBe(false);
+ expect(useWebUpdateStore.getState().dismissed).toBe(false);
+ expect(localStorage.getItem(`${DISMISS_KEY_PREFIX}1.3.0`)).toBeNull();
+ });
+
+ it("dismissUpdate closes the dialog AND persists dismissal for the version", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(mockReleasesResponse("v1.3.0")),
+ );
+ const { useWebUpdateStore } = await import("../web-update-store");
+ await useWebUpdateStore.getState().checkForUpdate();
+ useWebUpdateStore.getState().promptUpdate();
+
+ useWebUpdateStore.getState().dismissUpdate();
+ expect(useWebUpdateStore.getState().showDialog).toBe(false);
+ expect(useWebUpdateStore.getState().dismissed).toBe(true);
+ expect(localStorage.getItem(`${DISMISS_KEY_PREFIX}1.3.0`)).toBe("1");
+ });
+
+ it("dismissUpdate is a no-op when no update is available", async () => {
+ const { useWebUpdateStore } = await import("../web-update-store");
+ useWebUpdateStore.getState().dismissUpdate();
+ expect(useWebUpdateStore.getState().dismissed).toBe(false);
+ expect(localStorage.length).toBe(0);
+ });
+
+ it("checkForUpdate seeds dismissed=true when localStorage has flag for the detected version", async () => {
+ localStorage.setItem(`${DISMISS_KEY_PREFIX}1.3.0`, "1");
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(mockReleasesResponse("v1.3.0")),
+ );
+ const { useWebUpdateStore } = await import("../web-update-store");
+ await useWebUpdateStore.getState().checkForUpdate();
+
+ expect(useWebUpdateStore.getState().available?.version).toBe("1.3.0");
+ expect(useWebUpdateStore.getState().dismissed).toBe(true);
+ });
+
+ it("checkForUpdate keeps dismissed=false when a newer version appears", async () => {
+ localStorage.setItem(`${DISMISS_KEY_PREFIX}1.3.0`, "1");
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(mockReleasesResponse("v1.4.0")),
+ );
+ const { useWebUpdateStore } = await import("../web-update-store");
+ await useWebUpdateStore.getState().checkForUpdate();
+
+ expect(useWebUpdateStore.getState().available?.version).toBe("1.4.0");
+ expect(useWebUpdateStore.getState().dismissed).toBe(false);
+ });
+
+ it("uses sessionStorage cache instead of refetching within TTL", async () => {
+ sessionStorage.setItem(
+ CACHE_KEY,
+ JSON.stringify({ tag: "v1.3.0", body: "cached body", at: Date.now() }),
+ );
+ const fetchSpy = vi.fn();
+ vi.stubGlobal("fetch", fetchSpy);
+
+ const { useWebUpdateStore } = await import("../web-update-store");
+ await useWebUpdateStore.getState().checkForUpdate();
+
+ expect(fetchSpy).not.toHaveBeenCalled();
+ expect(useWebUpdateStore.getState().available?.version).toBe("1.3.0");
+ expect(useWebUpdateStore.getState().available?.body).toContain(
+ "cached body",
+ );
+ });
+
+ it("ignores stale sessionStorage cache and refetches", async () => {
+ sessionStorage.setItem(
+ CACHE_KEY,
+ JSON.stringify({
+ tag: "v1.3.0",
+ body: "old",
+ at: Date.now() - 2 * 60 * 60 * 1000,
+ }),
+ );
+ const fetchSpy = vi
+ .fn()
+ .mockResolvedValue(mockReleasesResponse("v1.4.0", "fresh"));
+ vi.stubGlobal("fetch", fetchSpy);
+
+ const { useWebUpdateStore } = await import("../web-update-store");
+ await useWebUpdateStore.getState().checkForUpdate();
+
+ expect(fetchSpy).toHaveBeenCalledWith(RELEASES_URL);
+ expect(useWebUpdateStore.getState().available?.version).toBe("1.4.0");
+ expect(useWebUpdateStore.getState().available?.body).toContain("fresh");
+ });
+
+ it("silently absorbs fetch failures", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network")));
+
+ const { useWebUpdateStore } = await import("../web-update-store");
+ await expect(
+ useWebUpdateStore.getState().checkForUpdate(),
+ ).resolves.toBeUndefined();
+ expect(useWebUpdateStore.getState().available).toBeNull();
+ });
+
+ it("silently absorbs non-OK HTTP responses", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({ ok: false, status: 500 } as Response),
+ );
+
+ const { useWebUpdateStore } = await import("../web-update-store");
+ await useWebUpdateStore.getState().checkForUpdate();
+ expect(useWebUpdateStore.getState().available).toBeNull();
+ });
+
+ it("ignores malformed tag_name", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ tag_name: "garbage" }),
+ } as Response),
+ );
+
+ const { useWebUpdateStore } = await import("../web-update-store");
+ await useWebUpdateStore.getState().checkForUpdate();
+ expect(useWebUpdateStore.getState().available).toBeNull();
+ });
+});
diff --git a/src/stores/update-store.ts b/src/stores/update-store.ts
index b3a774f..039df87 100644
--- a/src/stores/update-store.ts
+++ b/src/stores/update-store.ts
@@ -5,7 +5,7 @@ import { create } from "zustand";
/** Clean up GitHub auto-generated release notes for in-app display.
* - Removes "New Contributors" and "Full Changelog" sections
* - Converts bare PR URLs to clickable markdown links (e.g. [#3](url)) */
-function cleanChangelog(body: string): string {
+export function cleanChangelog(body: string): string {
const lines: string[] = [];
let skip = false;
for (const line of body.split("\n")) {
@@ -32,6 +32,10 @@ function cleanChangelog(body: string): string {
return lines.join("\n").trim();
}
+/** localStorage key prefix for "user has dismissed the update reminder for this version".
+ * Shared between desktop and web update flows so a dismissal in either mode silences both. */
+export const DISMISS_KEY_PREFIX = "hk-update-dismissed-v";
+
interface UpdateState {
/** Available update version, null if none or not checked yet */
available: { version: string; body: string } | null;
@@ -39,12 +43,16 @@ interface UpdateState {
installing: boolean;
/** Whether the changelog confirmation dialog is visible */
showChangelog: boolean;
+ /** User dismissed the reminder for `available.version` (sidebar card hidden). */
+ dismissed: boolean;
checkForUpdate: () => Promise;
/** Open the changelog dialog (called when user clicks Update) */
promptUpdate: () => void;
- /** Close the changelog dialog without updating */
- dismissChangelog: () => void;
+ /** Close the changelog dialog without updating (X / backdrop). Does not hide card. */
+ dismissDialog: () => void;
+ /** Close the dialog AND persist a "don't remind for this version" flag (Later button). */
+ dismissUpdate: () => void;
/** Confirm and install the update */
confirmUpdate: () => Promise;
}
@@ -54,6 +62,7 @@ export const useUpdateStore = create((set, get) => ({
checking: false,
installing: false,
showChangelog: false,
+ dismissed: false,
async checkForUpdate() {
if (get().checking) return;
@@ -61,11 +70,15 @@ export const useUpdateStore = create((set, get) => ({
try {
const update = await check();
if (update) {
+ const dismissed =
+ localStorage.getItem(`${DISMISS_KEY_PREFIX}${update.version}`) ===
+ "1";
set({
available: {
version: update.version,
body: cleanChangelog(update.body ?? ""),
},
+ dismissed,
});
}
} catch {
@@ -81,10 +94,21 @@ export const useUpdateStore = create((set, get) => ({
}
},
- dismissChangelog() {
+ dismissDialog() {
set({ showChangelog: false });
},
+ dismissUpdate() {
+ const { available } = get();
+ if (!available) return;
+ try {
+ localStorage.setItem(`${DISMISS_KEY_PREFIX}${available.version}`, "1");
+ } catch {
+ // localStorage unavailable — keep the in-memory dismissal anyway
+ }
+ set({ showChangelog: false, dismissed: true });
+ },
+
async confirmUpdate() {
if (get().installing) return;
set({ installing: true, showChangelog: false });
diff --git a/src/stores/web-update-store.ts b/src/stores/web-update-store.ts
new file mode 100644
index 0000000..e99a766
--- /dev/null
+++ b/src/stores/web-update-store.ts
@@ -0,0 +1,140 @@
+import { create } from "zustand";
+import { cleanChangelog, DISMISS_KEY_PREFIX } from "./update-store";
+
+const RELEASES_URL =
+ "https://api.github.com/repos/RealZST/HarnessKit/releases/latest";
+const CACHE_KEY = "hk-web-update-cache";
+const CACHE_TTL_MS = 60 * 60 * 1000;
+
+interface CachedRelease {
+ tag: string;
+ body: string;
+ at: number;
+}
+
+function parseVersion(raw: string): [number, number, number] | null {
+ const match = raw.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
+ if (!match) return null;
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
+}
+
+function isNewer(current: string, latest: string): boolean {
+ const a = parseVersion(current);
+ const b = parseVersion(latest);
+ if (!a || !b) return false;
+ for (let i = 0; i < 3; i++) {
+ if (b[i] > a[i]) return true;
+ if (b[i] < a[i]) return false;
+ }
+ return false;
+}
+
+function readCache(): CachedRelease | null {
+ try {
+ const raw = sessionStorage.getItem(CACHE_KEY);
+ if (!raw) return null;
+ const cached = JSON.parse(raw) as CachedRelease;
+ if (Date.now() - cached.at > CACHE_TTL_MS) return null;
+ return cached;
+ } catch {
+ return null;
+ }
+}
+
+function writeCache(tag: string, body: string): void {
+ try {
+ const entry: CachedRelease = { tag, body, at: Date.now() };
+ sessionStorage.setItem(CACHE_KEY, JSON.stringify(entry));
+ } catch {
+ // sessionStorage unavailable — ignore
+ }
+}
+
+async function fetchLatestRelease(
+ force = false,
+): Promise<{ tag: string; body: string } | null> {
+ if (!force) {
+ const cached = readCache();
+ if (cached) return { tag: cached.tag, body: cached.body };
+ }
+ try {
+ const response = await fetch(RELEASES_URL);
+ if (!response.ok) return null;
+ const data = (await response.json()) as {
+ tag_name?: string;
+ body?: string;
+ };
+ if (!data.tag_name) return null;
+ const body = data.body ?? "";
+ writeCache(data.tag_name, body);
+ return { tag: data.tag_name, body };
+ } catch {
+ return null;
+ }
+}
+
+interface WebUpdateState {
+ /** Available update payload, null if none or not yet checked. */
+ available: { version: string; body: string } | null;
+ checking: boolean;
+ /** Whether the changelog/instructions dialog is visible. */
+ showDialog: boolean;
+ /** User dismissed the reminder for `available.version` (sidebar card hidden). */
+ dismissed: boolean;
+
+ /** When `force` is true, skip the sessionStorage cache and re-fetch from GitHub. */
+ checkForUpdate: (force?: boolean) => Promise;
+ /** Open the dialog (only when an update is available). */
+ promptUpdate: () => void;
+ /** Close the dialog without persisting dismissal (X / backdrop). */
+ dismissDialog: () => void;
+ /** Close the dialog AND persist a "don't remind for this version" flag (Close button). */
+ dismissUpdate: () => void;
+}
+
+export const useWebUpdateStore = create((set, get) => ({
+ available: null,
+ checking: false,
+ showDialog: false,
+ dismissed: false,
+
+ async checkForUpdate(force = false) {
+ if (get().checking) return;
+ set({ checking: true });
+ try {
+ const release = await fetchLatestRelease(force);
+ if (!release) return;
+ if (!isNewer(__APP_VERSION__, release.tag)) return;
+ const version = release.tag.replace(/^v/, "");
+ const dismissed =
+ localStorage.getItem(`${DISMISS_KEY_PREFIX}${version}`) === "1";
+ set({
+ available: { version, body: cleanChangelog(release.body) },
+ dismissed,
+ });
+ } finally {
+ set({ checking: false });
+ }
+ },
+
+ promptUpdate() {
+ if (get().available) {
+ set({ showDialog: true });
+ }
+ },
+
+ dismissDialog() {
+ set({ showDialog: false });
+ },
+
+ dismissUpdate() {
+ const { available } = get();
+ if (!available) return;
+ try {
+ localStorage.setItem(`${DISMISS_KEY_PREFIX}${available.version}`, "1");
+ } catch {
+ // localStorage unavailable — keep the in-memory dismissal anyway
+ }
+ set({ showDialog: false, dismissed: true });
+ },
+}));
diff --git a/vitest.config.ts b/vitest.config.ts
index e408a36..fde9fd2 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -6,6 +6,9 @@ export default defineConfig({
environment: "jsdom",
globals: true,
},
+ define: {
+ __APP_VERSION__: JSON.stringify("1.2.1"),
+ },
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),