From b150518cde79812ceea8b638bfea832e412d5449 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 12:12:36 +0000 Subject: [PATCH] feat(app): Show what's new after an update Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V9MDdxQJAhjXsAKyCpVwwt --- src/components/Dashboard.tsx | 2 + src/hooks/useWhatsNew.test.tsx | 103 +++++++++++++++++++++++++++++++++ src/hooks/useWhatsNew.ts | 66 +++++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 src/hooks/useWhatsNew.test.tsx create mode 100644 src/hooks/useWhatsNew.ts diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index d429fce11..5ef02b01a 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -14,6 +14,7 @@ import { import useSWR from "swr"; import { useLocation } from "wouter"; import { useAutoUpdate } from "../hooks/useAutoUpdate"; +import { useWhatsNew } from "../hooks/useWhatsNew"; import { useKeyboardShortcut } from "../hooks/useKeyboard"; import { useMutation } from "../hooks/useMutation"; import { useWorkspaceHierarchy } from "../hooks/useWorkspaceHierarchy"; @@ -1180,6 +1181,7 @@ export const Dashboard: React.FC = ({ autoCheck: import.meta.env.MODE !== "test", listenMenu: import.meta.env.MODE !== "test", }); + useWhatsNew({ enabled: import.meta.env.MODE !== "test" }); const handleReturnToDashboard = () => { // Navigate to main repo ShowWorkspace > Code setSelectedWorkspace(null); diff --git a/src/hooks/useWhatsNew.test.tsx b/src/hooks/useWhatsNew.test.tsx new file mode 100644 index 000000000..0dc41336c --- /dev/null +++ b/src/hooks/useWhatsNew.test.tsx @@ -0,0 +1,103 @@ +import { getVersion } from "@tauri-apps/api/app"; +import { invoke } from "@tauri-apps/api/core"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SWRConfig } from "swr"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ToastProvider } from "../components/ui/toast"; +import { resetAllStores } from "../stores/resetStores"; +import { useWhatsNew } from "./useWhatsNew"; + +vi.mock("@tauri-apps/api/app", () => ({ getVersion: vi.fn() })); + +/** In-memory stand-in for the app settings table behind get/set_setting. */ +let settings: Map; + +function WhatsNew() { + useWhatsNew(); + return null; +} + +/** Simulates one app launch on `version`, then lets the hook settle. */ +async function launch(version: string) { + vi.mocked(getVersion).mockResolvedValue(version); + // A fresh SWR cache per render, as each app launch starts with one. + const view = render( + new Map() }}> + + + + , + ); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("get_setting", { + key: "last_seen_app_version", + }), + ); + // Flush the rest of the hook's promise chain (write, then toast). + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + return view; +} + +describe("useWhatsNew", () => { + beforeEach(() => { + vi.clearAllMocks(); + settings = new Map(); + vi.mocked(invoke).mockImplementation(async (cmd, args) => { + const { key, value } = (args ?? {}) as { key: string; value?: string }; + if (cmd === "get_setting") return settings.get(key) ?? null; + if (cmd === "set_setting") settings.set(key, value ?? ""); + return null; + }); + }); + + it("records the version silently on a fresh install", async () => { + await launch("0.3.0"); + + expect(settings.get("last_seen_app_version")).toBe("0.3.0"); + expect(screen.queryByText(/Updated to/)).toBeNull(); + }); + + it("shows nothing when the version has not changed", async () => { + settings.set("last_seen_app_version", "0.3.0"); + + await launch("0.3.0"); + + expect(screen.queryByText(/Updated to/)).toBeNull(); + expect(settings.get("last_seen_app_version")).toBe("0.3.0"); + }); + + it("shows the toast once after an update", async () => { + settings.set("last_seen_app_version", "0.2.0"); + + const first = await launch("0.3.0"); + expect(await screen.findByText("Updated to v0.3.0")).toBeInTheDocument(); + expect(settings.get("last_seen_app_version")).toBe("0.3.0"); + first.unmount(); + resetAllStores(); + vi.mocked(invoke).mockClear(); + + await launch("0.3.0"); + expect(screen.queryByText(/Updated to/)).toBeNull(); + }); + + it("shows nothing after a downgrade and keeps the newer version", async () => { + settings.set("last_seen_app_version", "0.10.0"); + + await launch("0.9.0"); + + expect(screen.queryByText(/Updated to/)).toBeNull(); + expect(settings.get("last_seen_app_version")).toBe("0.10.0"); + }); + + it("opens the changelog from the What's new action", async () => { + settings.set("last_seen_app_version", "0.2.0"); + const user = userEvent.setup(); + + await launch("0.3.0"); + await user.click(await screen.findByRole("button", { name: "What's new" })); + + expect(openUrl).toHaveBeenCalledWith("https://treq.dev/changelog"); + }); +}); diff --git a/src/hooks/useWhatsNew.ts b/src/hooks/useWhatsNew.ts new file mode 100644 index 000000000..2efd84df4 --- /dev/null +++ b/src/hooks/useWhatsNew.ts @@ -0,0 +1,66 @@ +import { getVersion } from "@tauri-apps/api/app"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useEffect } from "react"; +import useSWR from "swr"; +import { useToast } from "../components/ui/toast"; +import { getSetting, setSetting } from "../lib/api"; +import { useMutation } from "./useMutation"; + +// Kept in the app settings database rather than localStorage: it is the store +// the other app-wide settings use, and it outlives a cleared WebView cache. +const LAST_SEEN_VERSION_KEY = "last_seen_app_version"; +const CHANGELOG_URL = "https://treq.dev/changelog"; + +type SeenVersions = { version: string; lastSeen: string | null }; + +/** True when dotted version `a` is newer than `b` ("0.10.0" > "0.9.0"). */ +function isNewerVersion(a: string, b: string): boolean { + return a.localeCompare(b, undefined, { numeric: true }) > 0; +} + +/** + * On the first launch after an update, shows "Updated to vX.Y.Z" with a link + * to the changelog. A fresh install records its version and shows nothing. + */ +export function useWhatsNew(options: { enabled?: boolean } = {}) { + const { enabled = true } = options; + const { addToast } = useToast(); + + const { data, mutate } = useSWR( + enabled ? ["whats-new", LAST_SEEN_VERSION_KEY] : null, + async () => { + const [version, lastSeen] = await Promise.all([ + getVersion(), + getSetting(LAST_SEEN_VERSION_KEY), + ]); + return { version, lastSeen }; + }, + { revalidateOnFocus: false, revalidateOnReconnect: false }, + ); + + const recordVersion = useMutation({ + mutationFn: (version: string) => setSetting(LAST_SEEN_VERSION_KEY, version), + // Mark the cached read as seen so a remount cannot repeat the toast. + onSuccess: (_, version) => { + void mutate({ version, lastSeen: version }, { revalidate: false }); + }, + }); + + useEffect(() => { + if (!data) return; + const { version, lastSeen } = data; + if (lastSeen && !isNewerVersion(version, lastSeen)) return; + recordVersion.mutate(version); + if (!lastSeen) return; + addToast({ + type: "info", + title: `Updated to v${version}`, + action: { + label: "What's new", + onClick: () => void openUrl(CHANGELOG_URL), + }, + }); + // React to each read only: recordVersion is a new object every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); +}