From 5987a90bedbffd46e9d228fe405a4ddfb41ae238 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 23 Apr 2026 13:50:43 -0700 Subject: [PATCH] feat(desktop): improve auto-updater UX and add global update indicator - Restyle UpdateChecker settings section to match design system (semantic tokens, Button/Badge components, no hardcoded zinc/blue colors) - Extract updater state machine into useUpdater hook with React context provider (single instance, no split-brain between settings and top bar) - Add UpdateIndicator in top nav bar: pulsing dot when update available, green dot when ready to restart - Fire deduped Sonner toast on update detection (passive, no auto-open) - Gracefully handle dev builds where updater plugin isn't loaded - Wrap relaunch() to surface errors in UI instead of unhandled rejections Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src/app/AppShell.tsx | 364 +++++++++--------- .../src/features/settings/UpdateChecker.tsx | 152 ++------ .../src/features/settings/UpdateIndicator.tsx | 48 +++ .../settings/hooks/UpdaterProvider.tsx | 19 + .../features/settings/hooks/use-updater.ts | 105 +++++ 5 files changed, 395 insertions(+), 293 deletions(-) create mode 100644 desktop/src/features/settings/UpdateIndicator.tsx create mode 100644 desktop/src/features/settings/hooks/UpdaterProvider.tsx create mode 100644 desktop/src/features/settings/hooks/use-updater.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 9e3742e9..a32fe0e4 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -46,6 +46,8 @@ import { SidebarProvider, SidebarTrigger, } from "@/shared/ui/sidebar"; +import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider"; +import { UpdateIndicator } from "@/features/settings/UpdateIndicator"; type AppView = "home" | "channel" | "agents" | "workflows" | "pulse"; @@ -117,6 +119,7 @@ export function AppShell() { const [settingsSection, setSettingsSection] = React.useState( DEFAULT_SETTINGS_SECTION, ); + const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); const [isSearchOpen, setIsSearchOpen] = React.useState(false); @@ -407,188 +410,195 @@ export function AppShell() { }, [handleCloseSettings, handleOpenSettings, settingsOpen]); return ( - - { - setIsChannelManagementOpen(true); - }, - }} - > - -
- -
- - - -
- { - const createdChannel = - await createChannelMutation.mutateAsync({ + + + { + setIsChannelManagementOpen(true); + }, + }} + > + +
+ +
+ + + + handleOpenSettings("updates")} + /> +
+ { + const createdChannel = + await createChannelMutation.mutateAsync({ + name, + description, + channelType: "stream", + visibility, + ttlSeconds, + }); + + await goChannel(createdChannel.id); + }} + onCreateForum={async ({ + description, + name, + visibility, + ttlSeconds, + }) => { + const createdForum = await createForumMutation.mutateAsync({ name, description, - channelType: "stream", + channelType: "forum", visibility, ttlSeconds, }); - await goChannel(createdChannel.id); - }} - onCreateForum={async ({ - description, - name, - visibility, - ttlSeconds, - }) => { - const createdForum = await createForumMutation.mutateAsync({ - name, - description, - channelType: "forum", - visibility, - ttlSeconds, - }); - - await goChannel(createdForum.id); - }} - onHideDm={handleHideDm} - onOpenBrowseChannels={handleOpenBrowseChannels} - onOpenBrowseForums={handleOpenBrowseForums} - onOpenDm={async ({ pubkeys }) => { - const directMessage = await openDmMutation.mutateAsync({ - pubkeys, - }); - await goChannel(directMessage.id); - }} - onOpenSearch={handleOpenSearch} - onSelectAgents={() => { - void goAgents(); - }} - onSelectChannel={(channelId) => { - void goChannel(channelId); - }} - onSelectHome={() => { - void goHome(); - }} - onSelectPulse={() => { - void goPulse(); - }} - onSelectSettings={handleOpenSettings} - onSelectWorkflows={() => { - void goWorkflows(); - }} - onSetPresenceStatus={(status) => - presenceSession.setStatus(status) - } - profile={profileQuery.data} - selectedChannelId={selectedChannelId} - selectedView={selectedView} - unreadChannelIds={unreadChannelIds} - /> - - - - - - { - setIsChannelManagementOpen(false); - void goHome({ replace: true }); - }} - onOpenSearchResult={handleOpenSearchResult} - onSearchOpenChange={setIsSearchOpen} - onSelectChannel={(channelId) => { - void goChannel(channelId); - }} - /> - - {settingsOpen ? ( - - - - ) : null} -
- -
-
-
-
+ await goChannel(createdForum.id); + }} + onHideDm={handleHideDm} + onOpenBrowseChannels={handleOpenBrowseChannels} + onOpenBrowseForums={handleOpenBrowseForums} + onOpenDm={async ({ pubkeys }) => { + const directMessage = await openDmMutation.mutateAsync({ + pubkeys, + }); + await goChannel(directMessage.id); + }} + onOpenSearch={handleOpenSearch} + onSelectAgents={() => { + void goAgents(); + }} + onSelectChannel={(channelId) => { + void goChannel(channelId); + }} + onSelectHome={() => { + void goHome(); + }} + onSelectPulse={() => { + void goPulse(); + }} + onSelectSettings={handleOpenSettings} + onSelectWorkflows={() => { + void goWorkflows(); + }} + onSetPresenceStatus={(status) => + presenceSession.setStatus(status) + } + profile={profileQuery.data} + selectedChannelId={selectedChannelId} + selectedView={selectedView} + unreadChannelIds={unreadChannelIds} + /> + + + + + + { + setIsChannelManagementOpen(false); + void goHome({ replace: true }); + }} + onOpenSearchResult={handleOpenSearchResult} + onSearchOpenChange={setIsSearchOpen} + onSelectChannel={(channelId) => { + void goChannel(channelId); + }} + /> + + {settingsOpen ? ( + + + + ) : null} +
+ +
+
+
+
+ ); } diff --git a/desktop/src/features/settings/UpdateChecker.tsx b/desktop/src/features/settings/UpdateChecker.tsx index a88d26a2..3efb5e78 100644 --- a/desktop/src/features/settings/UpdateChecker.tsx +++ b/desktop/src/features/settings/UpdateChecker.tsx @@ -1,168 +1,88 @@ -import { useState, useRef, useCallback } from "react"; -import { check, type Update } from "@tauri-apps/plugin-updater"; -import { relaunch } from "@tauri-apps/plugin-process"; - -type UpdateStatus = - | { state: "idle" } - | { state: "checking" } - | { state: "up-to-date" } - | { state: "available"; version: string } - | { state: "downloading" } - | { state: "installing" } - | { state: "ready" } - | { state: "error"; message: string }; +import { useUpdaterContext } from "./hooks/UpdaterProvider"; +import { Button } from "@/shared/ui/button"; +import { Badge } from "@/shared/ui/badge"; export function UpdateChecker() { - const [status, setStatus] = useState({ state: "idle" }); - const updateRef = useRef(null); - - const closeUpdate = useCallback(async () => { - if (updateRef.current) { - await updateRef.current.close(); - updateRef.current = null; - } - }, []); - - async function checkForUpdate() { - try { - await closeUpdate(); - setStatus({ state: "checking" }); - const update = await check(); - - if (update) { - updateRef.current = update; - setStatus({ state: "available", version: update.version }); - } else { - setStatus({ state: "up-to-date" }); - } - } catch (err) { - setStatus({ - state: "error", - message: err instanceof Error ? err.message : String(err), - }); - } - } - - async function downloadAndInstall() { - try { - const update = updateRef.current; - if (!update) { - setStatus({ state: "up-to-date" }); - return; - } - - setStatus({ state: "downloading" }); - - await update.downloadAndInstall((event) => { - if (event.event === "Finished") { - setStatus({ state: "installing" }); - } - }); - - setStatus({ state: "ready" }); - } catch (err) { - setStatus({ - state: "error", - message: err instanceof Error ? err.message : String(err), - }); - } - } - - async function handleRelaunch() { - await relaunch(); - } + const { status, checkForUpdate, downloadAndInstall, relaunch } = + useUpdaterContext(); return ( -
-

- Software Updates -

+
+
+

+ Software Updates +

+

+ Keep Sprout up to date with the latest features and fixes. +

+
{status.state === "idle" && (
-

+

Check if a new version is available.

- +
)} {status.state === "checking" && ( -

Checking for updates...

+

Checking for updates...

)} {status.state === "up-to-date" && (
-

You're on the latest version.

- +
)} {status.state === "available" && (
-

- Version {status.version} is - available. +

+ Version {status.version} is available.

- +
)} {status.state === "downloading" && ( -

Downloading update...

+

Downloading update...

)} {status.state === "installing" && ( -

Installing update...

+

Installing update...

)} {status.state === "ready" && (
-

+

Update installed. Restart to apply.

- +
)} {status.state === "error" && (
-

+

Update failed: {status.message}

- +
)} -
+ ); } diff --git a/desktop/src/features/settings/UpdateIndicator.tsx b/desktop/src/features/settings/UpdateIndicator.tsx new file mode 100644 index 00000000..15697729 --- /dev/null +++ b/desktop/src/features/settings/UpdateIndicator.tsx @@ -0,0 +1,48 @@ +import { Download, RefreshCw } from "lucide-react"; + +import { Button } from "@/shared/ui/button"; + +import { useUpdaterContext } from "./hooks/UpdaterProvider"; + +const indicatorButtonClass = + "relative h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"; + +export function UpdateIndicator({ + onOpenUpdates, +}: { + onOpenUpdates: () => void; +}) { + const { status } = useUpdaterContext(); + + if (status.state === "available") { + return ( + + ); + } + + if (status.state === "ready") { + return ( + + ); + } + + return null; +} diff --git a/desktop/src/features/settings/hooks/UpdaterProvider.tsx b/desktop/src/features/settings/hooks/UpdaterProvider.tsx new file mode 100644 index 00000000..186c78d0 --- /dev/null +++ b/desktop/src/features/settings/hooks/UpdaterProvider.tsx @@ -0,0 +1,19 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { useUpdater } from "./use-updater"; + +type UpdaterContextValue = ReturnType; + +const UpdaterContext = createContext(null); + +export function UpdaterProvider({ children }: { children: ReactNode }) { + const updater = useUpdater(); + return {children}; +} + +export function useUpdaterContext(): UpdaterContextValue { + const ctx = useContext(UpdaterContext); + if (!ctx) { + throw new Error("useUpdaterContext must be used within an UpdaterProvider"); + } + return ctx; +} diff --git a/desktop/src/features/settings/hooks/use-updater.ts b/desktop/src/features/settings/hooks/use-updater.ts new file mode 100644 index 00000000..9e728ddc --- /dev/null +++ b/desktop/src/features/settings/hooks/use-updater.ts @@ -0,0 +1,105 @@ +import { useState, useRef, useCallback, useEffect } from "react"; +import { check, type Update } from "@tauri-apps/plugin-updater"; +import { relaunch } from "@tauri-apps/plugin-process"; +import { toast } from "sonner"; + +export type UpdateStatus = + | { state: "idle" } + | { state: "checking" } + | { state: "up-to-date" } + | { state: "available"; version: string } + | { state: "downloading" } + | { state: "installing" } + | { state: "ready" } + | { state: "error"; message: string }; + +export function useUpdater() { + const [status, setStatus] = useState({ state: "idle" }); + const updateRef = useRef(null); + + const closeUpdate = useCallback(async () => { + if (updateRef.current) { + await updateRef.current.close(); + updateRef.current = null; + } + }, []); + + const checkForUpdate = useCallback(async () => { + try { + await closeUpdate(); + setStatus({ state: "checking" }); + const update = await check(); + + if (update) { + updateRef.current = update; + setStatus({ state: "available", version: update.version }); + toast("Update Available", { + id: "update-available", + description: `Version ${update.version} is ready to download.`, + }); + } else { + setStatus({ state: "up-to-date" }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if ( + msg.includes("plugin updater not found") || + msg.includes("not initialized") + ) { + setStatus({ state: "idle" }); + return; + } + setStatus({ state: "error", message: msg }); + } + }, [closeUpdate]); + + const downloadAndInstall = useCallback(async () => { + try { + const update = updateRef.current; + if (!update) { + setStatus({ state: "up-to-date" }); + return; + } + + setStatus({ state: "downloading" }); + + await update.downloadAndInstall((event) => { + if (event.event === "Finished") { + setStatus({ state: "installing" }); + } + }); + + setStatus({ state: "ready" }); + } catch (err) { + setStatus({ + state: "error", + message: err instanceof Error ? err.message : String(err), + }); + } + }, []); + + const handleRelaunch = useCallback(async () => { + try { + await relaunch(); + } catch (err) { + setStatus({ + state: "error", + message: err instanceof Error ? err.message : String(err), + }); + } + }, []); + + useEffect(() => { + checkForUpdate(); + return () => { + closeUpdate(); + }; + }, [checkForUpdate, closeUpdate]); + + return { + status, + checkForUpdate, + downloadAndInstall, + relaunch: handleRelaunch, + }; +}