diff --git a/src/App.tsx b/src/App.tsx index fe3e79804..13c9a62de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -572,6 +572,8 @@ function MainApp() { updaterState, startUpdate, dismissUpdate, + postUpdateNotice, + dismissPostUpdateNotice, handleTestNotificationSound, handleTestSystemNotification, } = useUpdaterController({ @@ -1895,6 +1897,8 @@ function MainApp() { updaterState, onUpdate: startUpdate, onDismissUpdate: dismissUpdate, + postUpdateNotice, + onDismissPostUpdateNotice: dismissPostUpdateNotice, errorToasts, onDismissErrorToast: dismissErrorToast, latestAgentRuns, diff --git a/src/features/app/hooks/useUpdaterController.ts b/src/features/app/hooks/useUpdaterController.ts index 657295190..b3ca64ed5 100644 --- a/src/features/app/hooks/useUpdaterController.ts +++ b/src/features/app/hooks/useUpdaterController.ts @@ -34,7 +34,14 @@ export function useUpdaterController({ successSoundUrl, errorSoundUrl, }: Params) { - const { state: updaterState, startUpdate, checkForUpdates, dismiss } = useUpdater({ + const { + state: updaterState, + startUpdate, + checkForUpdates, + dismiss, + postUpdateNotice, + dismissPostUpdateNotice, + } = useUpdater({ enabled, onDebug, }); @@ -112,6 +119,8 @@ export function useUpdaterController({ startUpdate, checkForUpdates, dismissUpdate: dismiss, + postUpdateNotice, + dismissPostUpdateNotice, handleTestNotificationSound, handleTestSystemNotification, }; diff --git a/src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx b/src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx index a0e35eb15..6400c9446 100644 --- a/src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx +++ b/src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx @@ -216,6 +216,8 @@ export function buildPrimaryNodes(options: LayoutNodesOptions): PrimaryLayoutNod state={options.updaterState} onUpdate={options.onUpdate} onDismiss={options.onDismissUpdate} + postUpdateNotice={options.postUpdateNotice} + onDismissPostUpdateNotice={options.onDismissPostUpdateNotice} /> ); diff --git a/src/features/layout/hooks/layoutNodes/types.ts b/src/features/layout/hooks/layoutNodes/types.ts index 4972bfb3f..43963f0ed 100644 --- a/src/features/layout/hooks/layoutNodes/types.ts +++ b/src/features/layout/hooks/layoutNodes/types.ts @@ -37,7 +37,10 @@ import type { TurnPlan, WorkspaceInfo, } from "../../../../types"; -import type { UpdateState } from "../../../update/hooks/useUpdater"; +import type { + PostUpdateNoticeState, + UpdateState, +} from "../../../update/hooks/useUpdater"; import type { TerminalSessionState } from "../../../terminal/hooks/useTerminalSession"; import type { TerminalTab } from "../../../terminal/hooks/useTerminalTabs"; import type { ErrorToast } from "../../../../services/toasts"; @@ -179,6 +182,8 @@ export type LayoutNodesOptions = { updaterState: UpdateState; onUpdate: () => void; onDismissUpdate: () => void; + postUpdateNotice: PostUpdateNoticeState; + onDismissPostUpdateNotice: () => void; errorToasts: ErrorToast[]; onDismissErrorToast: (id: string) => void; latestAgentRuns: Array<{ diff --git a/src/features/update/components/UpdateToast.test.tsx b/src/features/update/components/UpdateToast.test.tsx index d97310b83..65a7d2605 100644 --- a/src/features/update/components/UpdateToast.test.tsx +++ b/src/features/update/components/UpdateToast.test.tsx @@ -1,10 +1,21 @@ // @vitest-environment jsdom import { fireEvent, render, screen, within } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { UpdateState } from "../hooks/useUpdater"; import { UpdateToast } from "./UpdateToast"; +vi.mock("@tauri-apps/plugin-opener", () => ({ + openUrl: vi.fn(), +})); + +const openUrlMock = vi.mocked(openUrl); + describe("UpdateToast", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders available state and handles actions", () => { const onUpdate = vi.fn(); const onDismiss = vi.fn(); @@ -83,4 +94,88 @@ describe("UpdateToast", () => { fireEvent.click(scoped.getByRole("button", { name: "Dismiss" })); expect(onDismiss).toHaveBeenCalledTimes(1); }); + + it("renders post-update loading notice and dismisses", () => { + const onDismissPostUpdateNotice = vi.fn(); + const state: UpdateState = { stage: "idle" }; + + const { container } = render( + , + ); + const scoped = within(container); + + expect(scoped.getByText("What's New")).toBeTruthy(); + expect(scoped.getByText(/Loading release notes/i)).toBeTruthy(); + fireEvent.click(scoped.getByRole("button", { name: "Dismiss" })); + expect(onDismissPostUpdateNotice).toHaveBeenCalledTimes(1); + }); + + it("renders post-update release notes and opens GitHub link", () => { + const onDismissPostUpdateNotice = vi.fn(); + const htmlUrl = + "https://github.com/Dimillian/CodexMonitor/releases/tag/v1.2.3"; + const state: UpdateState = { stage: "idle" }; + + const { container } = render( + , + ); + const scoped = within(container); + + expect(scoped.getByText("Highlights")).toBeTruthy(); + expect(scoped.getByText("Added release notes toast")).toBeTruthy(); + + fireEvent.click(scoped.getByRole("button", { name: "View on GitHub" })); + expect(openUrlMock).toHaveBeenCalledWith(htmlUrl); + + fireEvent.click(scoped.getByRole("button", { name: "Dismiss" })); + expect(onDismissPostUpdateNotice).toHaveBeenCalledTimes(1); + }); + + it("renders post-update fallback notice", () => { + const htmlUrl = + "https://github.com/Dimillian/CodexMonitor/releases/tag/v1.2.3"; + const state: UpdateState = { stage: "available", version: "9.9.9" }; + + const { container } = render( + , + ); + const scoped = within(container); + + expect( + scoped.getByText("Updated to v1.2.3. Release notes could not be loaded."), + ).toBeTruthy(); + fireEvent.click(scoped.getByRole("button", { name: "View on GitHub" })); + expect(openUrlMock).toHaveBeenCalledWith(htmlUrl); + expect(scoped.queryByText("A new version is available.")).toBeNull(); + }); }); diff --git a/src/features/update/components/UpdateToast.tsx b/src/features/update/components/UpdateToast.tsx index 52d36b99d..686b79818 100644 --- a/src/features/update/components/UpdateToast.tsx +++ b/src/features/update/components/UpdateToast.tsx @@ -1,4 +1,7 @@ -import type { UpdateState } from "../hooks/useUpdater"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import type { PostUpdateNoticeState, UpdateState } from "../hooks/useUpdater"; import { ToastActions, ToastBody, @@ -13,6 +16,8 @@ type UpdateToastProps = { state: UpdateState; onUpdate: () => void; onDismiss: () => void; + postUpdateNotice?: PostUpdateNoticeState; + onDismissPostUpdateNotice?: () => void; }; function formatBytes(value: number) { @@ -29,7 +34,89 @@ function formatBytes(value: number) { return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unitIndex]}`; } -export function UpdateToast({ state, onUpdate, onDismiss }: UpdateToastProps) { +export function UpdateToast({ + state, + onUpdate, + onDismiss, + postUpdateNotice = null, + onDismissPostUpdateNotice, +}: UpdateToastProps) { + if (postUpdateNotice) { + return ( + + + + What's New + v{postUpdateNotice.version} + + {postUpdateNotice.stage === "loading" ? ( + + Updated successfully. Loading release notes... + + ) : null} + {postUpdateNotice.stage === "ready" ? ( + <> + + Updated successfully. Here is what is new: + + + { + if (!href) { + return {children}; + } + return ( + { + event.preventDefault(); + void openUrl(href); + }} + > + {children} + + ); + }, + }} + > + {postUpdateNotice.body} + + + > + ) : null} + {postUpdateNotice.stage === "fallback" ? ( + + Updated to v{postUpdateNotice.version}. Release notes could not be + loaded. + + ) : null} + + {postUpdateNotice.stage !== "loading" ? ( + { + void openUrl(postUpdateNotice.htmlUrl); + }} + > + View on GitHub + + ) : null} + + Dismiss + + + + + ); + } + if (state.stage === "idle") { return null; } diff --git a/src/features/update/hooks/useUpdater.test.ts b/src/features/update/hooks/useUpdater.test.ts index f5de991e8..da8757ed9 100644 --- a/src/features/update/hooks/useUpdater.test.ts +++ b/src/features/update/hooks/useUpdater.test.ts @@ -5,6 +5,7 @@ import { check } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; import type { DebugEntry } from "../../../types"; import { useUpdater } from "./useUpdater"; +import { STORAGE_KEY_PENDING_POST_UPDATE_VERSION } from "../utils/postUpdateRelease"; vi.mock("@tauri-apps/api/core", () => ({ isTauri: vi.fn(() => true), @@ -20,14 +21,19 @@ vi.mock("@tauri-apps/plugin-process", () => ({ const checkMock = vi.mocked(check); const relaunchMock = vi.mocked(relaunch); +const fetchMock = vi.fn(); describe("useUpdater", () => { beforeEach(() => { vi.clearAllMocks(); + window.localStorage.clear(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { vi.useRealTimers(); + vi.unstubAllGlobals(); }); it("sets error state when update check fails", async () => { @@ -113,6 +119,9 @@ describe("useUpdater", () => { expect(result.current.state.progress?.downloadedBytes).toBe(100); expect(downloadAndInstall).toHaveBeenCalledTimes(1); expect(relaunchMock).toHaveBeenCalledTimes(1); + expect( + window.localStorage.getItem(STORAGE_KEY_PENDING_POST_UPDATE_VERSION), + ).toBe("1.2.3"); }); it("resets to idle and closes update on dismiss", async () => { @@ -189,4 +198,129 @@ describe("useUpdater", () => { expect(checkMock).not.toHaveBeenCalled(); expect(result.current.state.stage).toBe("idle"); }); + + it("loads post-update release notes after restart when marker matches current version", async () => { + window.localStorage.setItem( + STORAGE_KEY_PENDING_POST_UPDATE_VERSION, + __APP_VERSION__, + ); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + tag_name: `v${__APP_VERSION__}`, + html_url: `https://github.com/Dimillian/CodexMonitor/releases/tag/v${__APP_VERSION__}`, + body: "## New\n- Added updater notes", + }), + } as Response); + + const { result } = renderHook(() => useUpdater({})); + + await waitFor(() => + expect(result.current.postUpdateNotice?.stage).toBe("ready"), + ); + + expect(result.current.postUpdateNotice).toMatchObject({ + stage: "ready", + version: __APP_VERSION__, + htmlUrl: `https://github.com/Dimillian/CodexMonitor/releases/tag/v${__APP_VERSION__}`, + body: "## New\n- Added updater notes", + }); + + await act(async () => { + result.current.dismissPostUpdateNotice(); + }); + expect(result.current.postUpdateNotice).toBeNull(); + expect( + window.localStorage.getItem(STORAGE_KEY_PENDING_POST_UPDATE_VERSION), + ).toBeNull(); + }); + + it("shows post-update fallback when release notes fetch fails", async () => { + window.localStorage.setItem( + STORAGE_KEY_PENDING_POST_UPDATE_VERSION, + __APP_VERSION__, + ); + fetchMock.mockRejectedValue(new Error("offline")); + const onDebug = vi.fn(); + const { result } = renderHook(() => useUpdater({ onDebug })); + + await waitFor(() => + expect(result.current.postUpdateNotice?.stage).toBe("fallback"), + ); + + expect(result.current.postUpdateNotice).toMatchObject({ + stage: "fallback", + version: __APP_VERSION__, + htmlUrl: `https://github.com/Dimillian/CodexMonitor/releases/tag/v${__APP_VERSION__}`, + }); + expect(onDebug).toHaveBeenCalledWith( + expect.objectContaining({ + label: "updater/release-notes-error", + source: "error", + }), + ); + }); + + it("does not reopen post-update toast after dismissing during loading", async () => { + window.localStorage.setItem( + STORAGE_KEY_PENDING_POST_UPDATE_VERSION, + __APP_VERSION__, + ); + + let resolveFetch: ((value: Response) => void) | null = null; + fetchMock.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve as (value: Response) => void; + }), + ); + + const { result } = renderHook(() => useUpdater({})); + + await waitFor(() => + expect(result.current.postUpdateNotice?.stage).toBe("loading"), + ); + + await act(async () => { + result.current.dismissPostUpdateNotice(); + }); + + expect(result.current.postUpdateNotice).toBeNull(); + expect( + window.localStorage.getItem(STORAGE_KEY_PENDING_POST_UPDATE_VERSION), + ).toBeNull(); + + await act(async () => { + resolveFetch?.({ + ok: true, + status: 200, + json: async () => ({ + tag_name: `v${__APP_VERSION__}`, + html_url: `https://github.com/Dimillian/CodexMonitor/releases/tag/v${__APP_VERSION__}`, + body: "## Notes", + }), + } as Response); + await Promise.resolve(); + }); + + expect(result.current.postUpdateNotice).toBeNull(); + }); + + it("clears stale post-update marker when version does not match current app", async () => { + window.localStorage.setItem( + STORAGE_KEY_PENDING_POST_UPDATE_VERSION, + "0.0.1", + ); + + const { result } = renderHook(() => useUpdater({})); + + await waitFor(() => + expect( + window.localStorage.getItem(STORAGE_KEY_PENDING_POST_UPDATE_VERSION), + ).toBeNull(), + ); + expect(result.current.postUpdateNotice).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/update/hooks/useUpdater.ts b/src/features/update/hooks/useUpdater.ts index ee34beccd..fc607fb63 100644 --- a/src/features/update/hooks/useUpdater.ts +++ b/src/features/update/hooks/useUpdater.ts @@ -4,6 +4,14 @@ import { check } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; import type { DownloadEvent, Update } from "@tauri-apps/plugin-updater"; import type { DebugEntry } from "../../../types"; +import { + buildReleaseTagUrl, + clearPendingPostUpdateVersion, + fetchReleaseNotesForVersion, + loadPendingPostUpdateVersion, + normalizeReleaseVersion, + savePendingPostUpdateVersion, +} from "../utils/postUpdateRelease"; type UpdateStage = | "idle" @@ -27,6 +35,26 @@ export type UpdateState = { error?: string; }; +type PostUpdateNotice = + | { + stage: "loading"; + version: string; + htmlUrl: string; + } + | { + stage: "ready"; + version: string; + body: string; + htmlUrl: string; + } + | { + stage: "fallback"; + version: string; + htmlUrl: string; + }; + +export type PostUpdateNoticeState = PostUpdateNotice | null; + type UseUpdaterOptions = { enabled?: boolean; onDebug?: (entry: DebugEntry) => void; @@ -34,7 +62,11 @@ type UseUpdaterOptions = { export function useUpdater({ enabled = true, onDebug }: UseUpdaterOptions) { const [state, setState] = useState({ stage: "idle" }); + const [postUpdateNotice, setPostUpdateNotice] = useState( + null, + ); const updateRef = useRef(null); + const postUpdateFetchGenerationRef = useRef(0); const latestTimeoutRef = useRef(null); const latestToastDurationMs = 2000; @@ -152,6 +184,7 @@ export function useUpdater({ enabled = true, onDebug }: UseUpdaterOptions) { ...prev, stage: "restarting", })); + savePendingPostUpdateVersion(update.version); await relaunch(); } catch (error) { const message = @@ -178,16 +211,104 @@ export function useUpdater({ enabled = true, onDebug }: UseUpdaterOptions) { void checkForUpdates(); }, [checkForUpdates, enabled]); + useEffect(() => { + if (!enabled || !isTauri()) { + return; + } + const pendingVersion = loadPendingPostUpdateVersion(); + if (!pendingVersion) { + return; + } + + const normalizedPendingVersion = normalizeReleaseVersion(pendingVersion); + const normalizedCurrentVersion = normalizeReleaseVersion(__APP_VERSION__); + if ( + !normalizedPendingVersion || + normalizedPendingVersion !== normalizedCurrentVersion + ) { + clearPendingPostUpdateVersion(); + return; + } + + const fallbackUrl = buildReleaseTagUrl(normalizedPendingVersion); + const generation = postUpdateFetchGenerationRef.current + 1; + postUpdateFetchGenerationRef.current = generation; + let cancelled = false; + setPostUpdateNotice({ + stage: "loading", + version: normalizedPendingVersion, + htmlUrl: fallbackUrl, + }); + + void fetchReleaseNotesForVersion(normalizedPendingVersion) + .then((releaseInfo) => { + if ( + cancelled || + postUpdateFetchGenerationRef.current !== generation + ) { + return; + } + if (releaseInfo.body) { + setPostUpdateNotice({ + stage: "ready", + version: normalizedPendingVersion, + body: releaseInfo.body, + htmlUrl: releaseInfo.htmlUrl, + }); + return; + } + setPostUpdateNotice({ + stage: "fallback", + version: normalizedPendingVersion, + htmlUrl: releaseInfo.htmlUrl, + }); + }) + .catch((error) => { + if ( + cancelled || + postUpdateFetchGenerationRef.current !== generation + ) { + return; + } + const message = + error instanceof Error ? error.message : JSON.stringify(error); + onDebug?.({ + id: `${Date.now()}-client-updater-release-notes-error`, + timestamp: Date.now(), + source: "error", + label: "updater/release-notes-error", + payload: message, + }); + setPostUpdateNotice({ + stage: "fallback", + version: normalizedPendingVersion, + htmlUrl: fallbackUrl, + }); + }); + + return () => { + cancelled = true; + }; + }, [enabled, onDebug]); + useEffect(() => { return () => { clearLatestTimeout(); }; }, [clearLatestTimeout]); + const dismissPostUpdateNotice = useCallback(() => { + postUpdateFetchGenerationRef.current += 1; + clearPendingPostUpdateVersion(); + setPostUpdateNotice(null); + }, []); + return { state, startUpdate, checkForUpdates, dismiss: resetToIdle, + postUpdateNotice, + dismissPostUpdateNotice, }; } diff --git a/src/features/update/utils/postUpdateRelease.ts b/src/features/update/utils/postUpdateRelease.ts new file mode 100644 index 000000000..8a597faa8 --- /dev/null +++ b/src/features/update/utils/postUpdateRelease.ts @@ -0,0 +1,129 @@ +export const STORAGE_KEY_PENDING_POST_UPDATE_VERSION = + "codexmonitor.pendingPostUpdateVersion"; +const GITHUB_RELEASES_API_BASE = + "https://api.github.com/repos/Dimillian/CodexMonitor/releases"; +const GITHUB_RELEASES_WEB_BASE = + "https://github.com/Dimillian/CodexMonitor/releases"; + +type GitHubReleaseResponse = { + tag_name?: string; + html_url?: string; + body?: string | null; +}; + +export type PostUpdateReleaseInfo = { + body: string | null; + htmlUrl: string; + tag: string | null; +}; + +function normalizeStoredVersion(value: string): string { + let normalized = value.trim(); + while (normalized.startsWith("v") || normalized.startsWith("V")) { + normalized = normalized.slice(1); + } + return normalized.trim(); +} + +export function normalizeReleaseVersion(value: string): string { + return normalizeStoredVersion(value); +} + +export function buildReleaseTagUrl(version: string): string { + const normalized = normalizeStoredVersion(version); + const tag = normalized.length > 0 ? `v${normalized}` : "latest"; + return `${GITHUB_RELEASES_WEB_BASE}/tag/${encodeURIComponent(tag)}`; +} + +export function savePendingPostUpdateVersion(version: string): void { + if (typeof window === "undefined") { + return; + } + const normalized = normalizeStoredVersion(version); + if (!normalized) { + return; + } + try { + window.localStorage.setItem( + STORAGE_KEY_PENDING_POST_UPDATE_VERSION, + normalized, + ); + } catch { + // Best-effort persistence. + } +} + +export function loadPendingPostUpdateVersion(): string | null { + if (typeof window === "undefined") { + return null; + } + try { + const raw = window.localStorage.getItem(STORAGE_KEY_PENDING_POST_UPDATE_VERSION); + if (!raw) { + return null; + } + const normalized = normalizeStoredVersion(raw); + return normalized || null; + } catch { + return null; + } +} + +export function clearPendingPostUpdateVersion(): void { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.removeItem(STORAGE_KEY_PENDING_POST_UPDATE_VERSION); + } catch { + // Best-effort persistence. + } +} + +export async function fetchReleaseNotesForVersion( + version: string, +): Promise { + const normalized = normalizeStoredVersion(version); + if (!normalized) { + throw new Error("Invalid release version."); + } + + const candidates = [`v${normalized}`, normalized]; + const seen = new Set(); + for (const candidate of candidates) { + const tag = candidate.trim(); + if (!tag || seen.has(tag)) { + continue; + } + seen.add(tag); + const url = `${GITHUB_RELEASES_API_BASE}/tags/${encodeURIComponent(tag)}`; + const response = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (response.status === 404) { + continue; + } + if (!response.ok) { + throw new Error(`GitHub releases request failed (${response.status}).`); + } + const payload = (await response.json()) as GitHubReleaseResponse; + const body = payload.body?.trim() ? payload.body : null; + const htmlUrl = + payload.html_url && payload.html_url.trim().length > 0 + ? payload.html_url + : buildReleaseTagUrl(normalized); + const resultTag = + payload.tag_name && payload.tag_name.trim().length > 0 + ? payload.tag_name + : null; + return { + body, + htmlUrl, + tag: resultTag, + }; + } + + throw new Error(`Could not find GitHub release for version ${normalized}.`); +} diff --git a/src/styles/update-toasts.css b/src/styles/update-toasts.css index b8f94416e..ae32d0d1f 100644 --- a/src/styles/update-toasts.css +++ b/src/styles/update-toasts.css @@ -71,6 +71,73 @@ margin-bottom: 0; } +.update-toast-notes { + margin-bottom: 10px; + max-height: min(44vh, 280px); + overflow: auto; + border-radius: 8px; + border: 1px solid var(--border-strong); + background: var(--surface-card-muted); + padding: 10px; +} + +.update-toast-notes > *:first-child { + margin-top: 0; +} + +.update-toast-notes > *:last-child { + margin-bottom: 0; +} + +.update-toast-notes h1, +.update-toast-notes h2, +.update-toast-notes h3, +.update-toast-notes h4, +.update-toast-notes h5, +.update-toast-notes h6 { + margin: 0 0 6px; + font-size: 13px; + line-height: 1.35; +} + +.update-toast-notes p { + margin: 0 0 8px; + font-size: 12px; + line-height: 1.45; +} + +.update-toast-notes ul, +.update-toast-notes ol { + margin: 0 0 8px; + padding-left: 18px; + font-size: 12px; + line-height: 1.45; +} + +.update-toast-notes li { + margin: 0 0 4px; +} + +.update-toast-notes a { + color: var(--text-link); +} + +.update-toast-notes code { + font-family: var(--code-font-family); + font-size: 11px; + border-radius: 4px; + background: var(--surface-card-strong); + padding: 0 4px; +} + +.update-toast-notes pre { + margin: 0 0 8px; + padding: 8px; + border-radius: 6px; + background: var(--surface-card-strong); + overflow: auto; +} + @media (max-width: 960px) { .update-toasts { top: auto;