feat: improve desktop app update UX (#401)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-26 16:51:28 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 3c0295331d
commit a05565e687
3 changed files with 98 additions and 68 deletions
+2
View File
@@ -447,6 +447,8 @@ export function AppShell() {
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
<div className="fixed right-[16px] top-[8px] z-50">
<UpdateIndicator
onOpenUpdates={() => handleOpenSettings("updates")}
/>
@@ -3,9 +3,35 @@ import { Download, RefreshCw } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { useUpdaterContext } from "./hooks/UpdaterProvider";
import type { UpdateStatus } from "./hooks/use-updater";
const indicatorButtonClass =
"relative h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground";
"relative text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground";
const iconClass = "h-3.5 w-3.5";
const variants: Record<
"available" | "ready",
{ Icon: typeof Download; label: string; badgeColor: string }
> = {
available: {
Icon: Download,
label: "Update available",
badgeColor: "bg-primary",
},
ready: {
Icon: RefreshCw,
label: "Restart to update",
badgeColor: "bg-emerald-500",
},
};
function getVariant(state: UpdateStatus["state"]) {
if (state === "available" || state === "ready") {
return variants[state];
}
return null;
}
export function UpdateIndicator({
onOpenUpdates,
@@ -13,36 +39,27 @@ export function UpdateIndicator({
onOpenUpdates: () => void;
}) {
const { status } = useUpdaterContext();
const variant = getVariant(status.state);
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 (!variant) {
return null;
}
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>
);
}
const { Icon, label, badgeColor } = variant;
return null;
return (
<Button
aria-label={label}
className={indicatorButtonClass}
onClick={onOpenUpdates}
size="sm"
variant="ghost"
>
<Icon className={iconClass} />
{label}
<span
className={`absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full ${badgeColor} animate-pulse`}
/>
</Button>
);
}
@@ -13,14 +13,51 @@ export type UpdateStatus =
| { state: "ready" }
| { state: "error"; message: string };
const TOAST_ID = "update-available";
function toErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function isUpdaterUnavailable(message: string): boolean {
return (
message.includes("plugin updater not found") ||
message.includes("not initialized")
);
}
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();
const current = updateRef.current;
if (current) {
updateRef.current = null;
await current.close();
}
}, []);
const downloadAndInstall = useCallback(async () => {
try {
const update = updateRef.current;
if (!update) {
return;
}
toast.dismiss(TOAST_ID);
setStatus({ state: "downloading" });
await update.downloadAndInstall((event) => {
if (event.event === "Finished") {
setStatus({ state: "installing" });
}
});
updateRef.current = null;
setStatus({ state: "ready" });
} catch (err) {
setStatus({ state: "error", message: toErrorMessage(err) });
}
}, []);
@@ -34,58 +71,32 @@ export function useUpdater() {
updateRef.current = update;
setStatus({ state: "available", version: update.version });
toast("Update Available", {
id: "update-available",
id: TOAST_ID,
description: `Version ${update.version} is ready to download.`,
duration: Infinity,
action: {
label: "Download & install",
onClick: () => downloadAndInstall(),
},
});
} 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")
) {
const message = toErrorMessage(err);
if (isUpdaterUnavailable(message)) {
setStatus({ state: "idle" });
return;
}
setStatus({ state: "error", message: msg });
setStatus({ state: "error", message });
}
}, [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),
});
}
}, []);
}, [closeUpdate, downloadAndInstall]);
const handleRelaunch = useCallback(async () => {
try {
await relaunch();
} catch (err) {
setStatus({
state: "error",
message: err instanceof Error ? err.message : String(err),
});
setStatus({ state: "error", message: toErrorMessage(err) });
}
}, []);