From 468934c1f79b80f92e54ab2de06db65b49e3c43f Mon Sep 17 00:00:00 2001 From: Ignacio Cervino Date: Mon, 19 Jan 2026 23:10:02 -0300 Subject: [PATCH] Add menu item to check for updates and show up-to-date toast --- src-tauri/src/lib.rs | 9 ++++- src/App.tsx | 39 +++++++++++++++++-- .../update/components/UpdateToast.test.tsx | 16 +++++++- .../update/components/UpdateToast.tsx | 10 +++++ src/features/update/hooks/useUpdater.test.ts | 18 +++++++++ src/features/update/hooks/useUpdater.ts | 34 ++++++++++++++-- src/services/events.ts | 8 ++++ src/styles/update-toasts.css | 11 ++++++ 8 files changed, 135 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc3b23dda..0400e15e4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,5 @@ use tauri::menu::{Menu, MenuItemBuilder, PredefinedMenuItem, Submenu}; -use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; +use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; mod backend; mod codex; @@ -42,12 +42,16 @@ pub fn run() { let app_name = handle.package_info().name.clone(); let about_item = MenuItemBuilder::with_id("about", format!("About {app_name}")) .build(handle)?; + let check_updates_item = + MenuItemBuilder::with_id("check_for_updates", "Check for Updates...") + .build(handle)?; let app_menu = Submenu::with_items( handle, app_name.clone(), true, &[ &about_item, + &check_updates_item, &PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::services(handle, None)?, &PredefinedMenuItem::separator(handle)?, @@ -186,6 +190,9 @@ pub fn run() { .center() .build(); } + "check_for_updates" => { + let _ = app.emit("updater-check", ()); + } "file_close_window" | "window_close" => { if let Some(window) = app.get_webview_window("main") { let _ = window.close(); diff --git a/src/App.tsx b/src/App.tsx index e3e8c7421..b0d4be409 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -93,6 +93,7 @@ import { playNotificationSound } from "./utils/notificationSounds"; import { pickWorkspacePath, } from "./services/tauri"; +import { subscribeUpdaterCheck } from "./services/events"; import type { AccessMode, GitHubPullRequest, @@ -266,10 +267,40 @@ function MainApp() { const composerInputRef = useRef(null); - const updater = useUpdater({ onDebug: addDebugEntry }); + const { + state: updaterState, + startUpdate, + checkForUpdates, + dismiss: dismissUpdate, + } = useUpdater({ onDebug: addDebugEntry }); const isWindowFocused = useWindowFocusState(); const nextTestSoundIsError = useRef(false); + useEffect(() => { + let unlisten: (() => void) | null = null; + subscribeUpdaterCheck(() => { + void checkForUpdates({ announceNoUpdate: true }); + }) + .then((handler) => { + unlisten = handler; + }) + .catch((error) => { + addDebugEntry({ + id: `${Date.now()}-client-updater-menu-error`, + timestamp: Date.now(), + source: "error", + label: "updater/menu-error", + payload: error instanceof Error ? error.message : String(error), + }); + }); + + return () => { + if (unlisten) { + unlisten(); + } + }; + }, [addDebugEntry, checkForUpdates]); + useAgentSoundNotifications({ enabled: appSettings.notificationSoundsEnabled, isWindowFocused, @@ -1331,9 +1362,9 @@ function MainApp() { } void listThreadsForWorkspace(workspace); }, - updaterState: updater.state, - onUpdate: updater.startUpdate, - onDismissUpdate: updater.dismiss, + updaterState, + onUpdate: startUpdate, + onDismissUpdate: dismissUpdate, latestAgentRuns, isLoadingLatestAgents, localUsageSnapshot, diff --git a/src/features/update/components/UpdateToast.test.tsx b/src/features/update/components/UpdateToast.test.tsx index f54aadf03..708a4142e 100644 --- a/src/features/update/components/UpdateToast.test.tsx +++ b/src/features/update/components/UpdateToast.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { UpdateState } from "../hooks/useUpdater"; import { UpdateToast } from "./UpdateToast"; @@ -66,4 +66,18 @@ describe("UpdateToast", () => { expect(onDismiss).toHaveBeenCalledTimes(1); expect(onUpdate).toHaveBeenCalledTimes(1); }); + + it("renders latest state and allows dismiss", () => { + const onDismiss = vi.fn(); + const state: UpdateState = { stage: "latest" }; + + const { container } = render( + , + ); + const scoped = within(container); + + expect(scoped.getByText("You’re up to date.")).toBeTruthy(); + fireEvent.click(scoped.getByRole("button", { name: "Dismiss" })); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/features/update/components/UpdateToast.tsx b/src/features/update/components/UpdateToast.tsx index efb8f5d8a..9b7a03c4c 100644 --- a/src/features/update/components/UpdateToast.tsx +++ b/src/features/update/components/UpdateToast.tsx @@ -59,6 +59,16 @@ export function UpdateToast({ state, onUpdate, onDismiss }: UpdateToastProps) { )} + {state.stage === "latest" && ( +
+
+ You’re up to date. +
+ +
+ )} {state.stage === "downloading" && ( <>
diff --git a/src/features/update/hooks/useUpdater.test.ts b/src/features/update/hooks/useUpdater.test.ts index 6096cac35..3f828b3a8 100644 --- a/src/features/update/hooks/useUpdater.test.ts +++ b/src/features/update/hooks/useUpdater.test.ts @@ -63,6 +63,24 @@ describe("useUpdater", () => { expect(result.current.state.stage).toBe("idle"); }); + it("announces when no update is available for manual checks", async () => { + vi.useFakeTimers(); + checkMock.mockResolvedValue(null); + const { result } = renderHook(() => useUpdater({})); + + await act(async () => { + await result.current.checkForUpdates({ announceNoUpdate: true }); + }); + + expect(result.current.state.stage).toBe("latest"); + + await act(async () => { + vi.advanceTimersByTime(2000); + }); + + expect(result.current.state.stage).toBe("idle"); + }); + it("downloads and restarts when update is available", async () => { const close = vi.fn(); const downloadAndInstall = vi.fn(async (onEvent) => { diff --git a/src/features/update/hooks/useUpdater.ts b/src/features/update/hooks/useUpdater.ts index 08209aee6..00cd4bbb0 100644 --- a/src/features/update/hooks/useUpdater.ts +++ b/src/features/update/hooks/useUpdater.ts @@ -12,6 +12,7 @@ type UpdateStage = | "downloading" | "installing" | "restarting" + | "latest" | "error"; type UpdateProgress = { @@ -34,21 +35,40 @@ type UseUpdaterOptions = { export function useUpdater({ enabled = true, onDebug }: UseUpdaterOptions) { const [state, setState] = useState({ stage: "idle" }); const updateRef = useRef(null); + const latestTimeoutRef = useRef(null); + const latestToastDurationMs = 2000; + + const clearLatestTimeout = useCallback(() => { + if (latestTimeoutRef.current !== null) { + window.clearTimeout(latestTimeoutRef.current); + latestTimeoutRef.current = null; + } + }, []); const resetToIdle = useCallback(async () => { + clearLatestTimeout(); const update = updateRef.current; updateRef.current = null; setState({ stage: "idle" }); await update?.close(); - }, []); + }, [clearLatestTimeout]); - const checkForUpdates = useCallback(async () => { + const checkForUpdates = useCallback(async (options?: { announceNoUpdate?: boolean }) => { let update: Awaited> | null = null; try { + clearLatestTimeout(); setState({ stage: "checking" }); update = await check(); if (!update) { - setState({ stage: "idle" }); + if (options?.announceNoUpdate) { + setState({ stage: "latest" }); + latestTimeoutRef.current = window.setTimeout(() => { + latestTimeoutRef.current = null; + setState({ stage: "idle" }); + }, latestToastDurationMs); + } else { + setState({ stage: "idle" }); + } return; } @@ -73,7 +93,7 @@ export function useUpdater({ enabled = true, onDebug }: UseUpdaterOptions) { await update?.close(); } } - }, [onDebug]); + }, [clearLatestTimeout, onDebug]); const startUpdate = useCallback(async () => { const update = updateRef.current; @@ -152,10 +172,16 @@ export function useUpdater({ enabled = true, onDebug }: UseUpdaterOptions) { void checkForUpdates(); }, [checkForUpdates, enabled]); + useEffect(() => { + return () => { + clearLatestTimeout(); + }; + }, [clearLatestTimeout]); return { state, startUpdate, + checkForUpdates, dismiss: resetToIdle, }; } diff --git a/src/services/events.ts b/src/services/events.ts index d00a2b839..98a593bfa 100644 --- a/src/services/events.ts +++ b/src/services/events.ts @@ -40,3 +40,11 @@ export async function subscribeTerminalOutput( onEvent(event.payload); }); } + +export async function subscribeUpdaterCheck( + onEvent: () => void, +): Promise { + return listen("updater-check", () => { + onEvent(); + }); +} diff --git a/src/styles/update-toasts.css b/src/styles/update-toasts.css index e79ec0e77..c37c798c2 100644 --- a/src/styles/update-toasts.css +++ b/src/styles/update-toasts.css @@ -92,6 +92,17 @@ justify-content: flex-end; } +.update-toast-inline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.update-toast-body-inline { + margin-bottom: 0; +} + @keyframes update-toast-in { from { opacity: 0;