feat(desktop): improve auto-updater UX and add global update indicator (#394)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-23 14:13:31 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent c1a33e3ff1
commit da0506f4ea
5 changed files with 392 additions and 290 deletions
+184 -174
View File
@@ -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<SettingsSection>(
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 (
<ChannelNavigationProvider channels={channels}>
<AppShellProvider
value={{
markChannelRead,
openChannelManagement: () => {
setIsChannelManagementOpen(true);
},
}}
>
<HuddleProvider>
<div className="flex h-dvh flex-col overflow-hidden overscroll-none">
<SidebarProvider className="min-h-0 flex-1 overflow-hidden">
<div className="fixed left-[80px] top-[8px] z-50 flex items-center gap-1.5">
<SidebarTrigger className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<Button
aria-label="Go back"
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-back"
disabled={!canGoBack}
onClick={goBack}
size="icon"
variant="ghost"
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
aria-label="Go forward"
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-forward"
disabled={!canGoForward}
onClick={goForward}
size="icon"
variant="ghost"
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
<AppSidebar
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
isNewDmOpen={isNewDmOpen}
isPresencePending={presenceSession.isPending}
onNewDmOpenChange={setIsNewDmOpen}
selfPresenceStatus={presenceSession.currentStatus}
onCreateChannel={async ({
description,
name,
visibility,
ttlSeconds,
}) => {
const createdChannel =
await createChannelMutation.mutateAsync({
<UpdaterProvider>
<ChannelNavigationProvider channels={channels}>
<AppShellProvider
value={{
markChannelRead,
openChannelManagement: () => {
setIsChannelManagementOpen(true);
},
}}
>
<HuddleProvider>
<div className="flex h-dvh flex-col overflow-hidden overscroll-none">
<SidebarProvider className="min-h-0 flex-1 overflow-hidden">
<div className="fixed left-[80px] top-[8px] z-50 flex items-center gap-1.5">
<SidebarTrigger className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<Button
aria-label="Go back"
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-back"
disabled={!canGoBack}
onClick={goBack}
size="icon"
variant="ghost"
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
aria-label="Go forward"
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-forward"
disabled={!canGoForward}
onClick={goForward}
size="icon"
variant="ghost"
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
<UpdateIndicator
onOpenUpdates={() => handleOpenSettings("updates")}
/>
</div>
<AppSidebar
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
isNewDmOpen={isNewDmOpen}
isPresencePending={presenceSession.isPending}
onNewDmOpenChange={setIsNewDmOpen}
selfPresenceStatus={presenceSession.currentStatus}
onCreateChannel={async ({
description,
name,
visibility,
ttlSeconds,
}) => {
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}
/>
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}
/>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<Outlet />
</SidebarInset>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<Outlet />
</SidebarInset>
<AppShellOverlays
activeChannel={activeChannel}
browseDialogType={browseDialogType}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
isSearchOpen={isSearchOpen}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={setIsChannelManagementOpen}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
void goHome({ replace: true });
}}
onOpenSearchResult={handleOpenSearchResult}
onSearchOpenChange={setIsSearchOpen}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
/>
<AppShellOverlays
activeChannel={activeChannel}
browseDialogType={browseDialogType}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
isSearchOpen={isSearchOpen}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={setIsChannelManagementOpen}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
void goHome({ replace: true });
}}
onOpenSearchResult={handleOpenSearchResult}
onSearchOpenChange={setIsSearchOpen}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
/>
{settingsOpen ? (
<React.Suspense fallback={null}>
<LazySettingsScreen
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={notificationSettings.errorMessage}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={
notificationSettings.setHomeBadgeEnabled
}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
section={settingsSection}
/>
</React.Suspense>
) : null}
</SidebarProvider>
<HuddleBar />
</div>
</HuddleProvider>
</AppShellProvider>
</ChannelNavigationProvider>
{settingsOpen ? (
<React.Suspense fallback={null}>
<LazySettingsScreen
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={
notificationSettings.errorMessage
}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={
notificationSettings.setHomeBadgeEnabled
}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
section={settingsSection}
/>
</React.Suspense>
) : null}
</SidebarProvider>
<HuddleBar />
</div>
</HuddleProvider>
</AppShellProvider>
</ChannelNavigationProvider>
</UpdaterProvider>
);
}
+36 -116
View File
@@ -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<UpdateStatus>({ state: "idle" });
const updateRef = useRef<Update | null>(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 (
<div className="rounded-lg bg-zinc-900 p-4">
<h3 className="mb-3 text-sm font-medium text-zinc-200">
Software Updates
</h3>
<section className="min-w-0">
<div className="mb-3 min-w-0">
<h2 className="text-sm font-semibold tracking-tight">
Software Updates
</h2>
<p className="text-sm text-muted-foreground">
Keep Sprout up to date with the latest features and fixes.
</p>
</div>
{status.state === "idle" && (
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-400">
<p className="text-sm text-muted-foreground">
Check if a new version is available.
</p>
<button
type="button"
onClick={checkForUpdate}
className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
<Button size="sm" onClick={checkForUpdate}>
Check for Updates
</button>
</Button>
</div>
)}
{status.state === "checking" && (
<p className="text-sm text-zinc-400">Checking for updates...</p>
<p className="text-sm text-muted-foreground">Checking for updates...</p>
)}
{status.state === "up-to-date" && (
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-300">You're on the latest version.</p>
<button
type="button"
onClick={checkForUpdate}
className="rounded-lg bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
>
<p className="text-sm text-foreground">
You're on the latest version.
</p>
<Button variant="outline" size="sm" onClick={checkForUpdate}>
Check Again
</button>
</Button>
</div>
)}
{status.state === "available" && (
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-300">
Version <span className="font-semibold">{status.version}</span> is
available.
<p className="text-sm text-foreground">
Version <Badge variant="info">{status.version}</Badge> is available.
</p>
<button
type="button"
onClick={downloadAndInstall}
className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
<Button size="sm" onClick={downloadAndInstall}>
Download &amp; Install
</button>
</Button>
</div>
)}
{status.state === "downloading" && (
<p className="text-sm text-zinc-400">Downloading update...</p>
<p className="text-sm text-muted-foreground">Downloading update...</p>
)}
{status.state === "installing" && (
<p className="text-sm text-zinc-400">Installing update...</p>
<p className="text-sm text-muted-foreground">Installing update...</p>
)}
{status.state === "ready" && (
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-300">
<p className="text-sm text-foreground">
Update installed. Restart to apply.
</p>
<button
type="button"
onClick={handleRelaunch}
className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
<Button size="sm" onClick={relaunch}>
Restart Now
</button>
</Button>
</div>
)}
{status.state === "error" && (
<div className="flex items-center justify-between">
<p className="text-sm text-red-400">
<p className="text-sm text-destructive">
Update failed: {status.message}
</p>
<button
type="button"
onClick={checkForUpdate}
className="rounded-lg bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
>
<Button variant="outline" size="sm" onClick={checkForUpdate}>
Retry
</button>
</Button>
</div>
)}
</div>
</section>
);
}
@@ -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 (
<Button
aria-label="Update available"
className={indicatorButtonClass}
onClick={onOpenUpdates}
size="icon"
variant="ghost"
>
<Download className="h-3.5 w-3.5" />
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-primary animate-pulse" />
</Button>
);
}
if (status.state === "ready") {
return (
<Button
aria-label="Restart to update"
className={indicatorButtonClass}
onClick={onOpenUpdates}
size="icon"
variant="ghost"
>
<RefreshCw className="h-3.5 w-3.5" />
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-emerald-500" />
</Button>
);
}
return null;
}
@@ -0,0 +1,19 @@
import { createContext, useContext, type ReactNode } from "react";
import { useUpdater } from "./use-updater";
type UpdaterContextValue = ReturnType<typeof useUpdater>;
const UpdaterContext = createContext<UpdaterContextValue | null>(null);
export function UpdaterProvider({ children }: { children: ReactNode }) {
const updater = useUpdater();
return <UpdaterContext value={updater}>{children}</UpdaterContext>;
}
export function useUpdaterContext(): UpdaterContextValue {
const ctx = useContext(UpdaterContext);
if (!ctx) {
throw new Error("useUpdaterContext must be used within an UpdaterProvider");
}
return ctx;
}
@@ -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<UpdateStatus>({ state: "idle" });
const updateRef = useRef<Update | null>(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,
};
}