diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index e0a2f2581..90850124f 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -2,7 +2,6 @@ import { ErrorBoundary } from "@components/ErrorBoundary"; import { LoginTransition } from "@components/LoginTransition"; import { MainLayout } from "@components/MainLayout"; import { ScopeReauthPrompt } from "@components/ScopeReauthPrompt"; -import { UpdatePrompt } from "@components/UpdatePrompt"; import { AuthScreen } from "@features/auth/components/AuthScreen"; import { InviteCodeScreen } from "@features/auth/components/InviteCodeScreen"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; @@ -13,6 +12,7 @@ import { Flex, Spinner, Text } from "@radix-ui/themes"; import { initializeConnectivityStore } from "@renderer/stores/connectivityStore"; import { useFocusStore } from "@renderer/stores/focusStore"; import { useThemeStore } from "@renderer/stores/themeStore"; +import { initializeUpdateStore } from "@renderer/stores/updateStore"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { useQueryClient } from "@tanstack/react-query"; @@ -49,6 +49,11 @@ function App() { return initializeConnectivityStore(); }, []); + // Initialize update store + useEffect(() => { + return initializeUpdateStore(); + }, []); + // Dev-only inbox demo command for local QA from the renderer console. useEffect(() => { if (import.meta.env.PROD) { @@ -218,7 +223,6 @@ function App() { onComplete={handleTransitionComplete} /> - ); diff --git a/apps/code/src/renderer/components/UpdatePrompt.tsx b/apps/code/src/renderer/components/UpdatePrompt.tsx deleted file mode 100644 index 3aaae9607..000000000 --- a/apps/code/src/renderer/components/UpdatePrompt.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { DownloadIcon } from "@phosphor-icons/react"; -import { Button, Card, Flex, Spinner, Text } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { useSubscription } from "@trpc/tanstack-react-query"; -import { logger } from "@utils/logger"; -import { useCallback, useRef, useState } from "react"; -import { toast as sonnerToast } from "sonner"; - -const log = logger.scope("updates"); -const UPDATE_TOAST_ID = "update-available"; -const CHECK_TOAST_ID = "update-check-status"; - -export function UpdatePrompt() { - const trpcReact = useTRPC(); - const { data: isEnabledData } = useQuery( - trpcReact.updates.isEnabled.queryOptions(), - ); - const isEnabled = isEnabledData?.enabled ?? false; - - const [isInstalling, setIsInstalling] = useState(false); - const toastShownRef = useRef(false); - - const checkMutation = useMutation(trpcReact.updates.check.mutationOptions()); - const installMutation = useMutation( - trpcReact.updates.install.mutationOptions(), - ); - - const handleRestart = useCallback(async () => { - if (isInstalling) { - return; - } - - setIsInstalling(true); - - try { - const result = await installMutation.mutateAsync(); - if (!result.installed) { - sonnerToast.dismiss(UPDATE_TOAST_ID); - sonnerToast.custom( - () => ( - - - - Update failed - - - Couldn't restart automatically. Please quit and relaunch - manually. - - - - ), - { duration: 5000 }, - ); - setIsInstalling(false); - } - } catch (error) { - log.error("Failed to install update", error); - sonnerToast.dismiss(UPDATE_TOAST_ID); - sonnerToast.custom( - () => ( - - - - Update failed - - - Update failed to install. Try quitting manually. - - - - ), - { duration: 5000 }, - ); - setIsInstalling(false); - } - }, [isInstalling, installMutation]); - - const handleLater = useCallback(() => { - sonnerToast.dismiss(UPDATE_TOAST_ID); - toastShownRef.current = false; - }, []); - - useSubscription( - trpcReact.updates.onReady.subscriptionOptions(undefined, { - enabled: isEnabled, - onData: (data) => { - // Dismiss any check status toast - sonnerToast.dismiss(CHECK_TOAST_ID); - - // Show persistent toast with action buttons - if (!toastShownRef.current) { - toastShownRef.current = true; - sonnerToast.custom( - () => ( - - - - - - - - - Update ready - - - {data.version - ? `Version ${data.version} has been downloaded and is ready to install.` - : "A new version of PostHog Code has been downloaded and is ready to install."} - - - - - - Later - - - {isInstalling ? "Restarting…" : "Restart now"} - - - - - ), - { - id: UPDATE_TOAST_ID, - duration: Number.POSITIVE_INFINITY, - }, - ); - } - }, - }), - ); - - useSubscription( - trpcReact.updates.onStatus.subscriptionOptions(undefined, { - enabled: isEnabled, - onData: (status) => { - if (status.checking === false && status.error) { - // Show error toast - sonnerToast.custom( - () => ( - - - - Update check failed - - - {status.error} - - - - ), - { id: CHECK_TOAST_ID, duration: 4000 }, - ); - } else if (status.checking === false && status.upToDate) { - // Show up-to-date toast - const versionSuffix = status.version ? ` (v${status.version})` : ""; - sonnerToast.custom( - () => ( - - - - PostHog Code is up to date{versionSuffix} - - - - ), - { id: CHECK_TOAST_ID, duration: 3000 }, - ); - } else if (status.checking === true) { - // Show checking/downloading toast - sonnerToast.custom( - () => ( - - - - - {status.downloading - ? "Downloading update..." - : "Checking for updates..."} - - - - ), - { id: CHECK_TOAST_ID, duration: Number.POSITIVE_INFINITY }, - ); - } - }, - }), - ); - - useSubscription( - trpcReact.updates.onCheckFromMenu.subscriptionOptions(undefined, { - enabled: isEnabled, - onData: async () => { - // Show checking toast immediately - sonnerToast.custom( - () => ( - - - - - Checking for updates... - - - - ), - { id: CHECK_TOAST_ID, duration: Number.POSITIVE_INFINITY }, - ); - - try { - const result = await checkMutation.mutateAsync(); - - if (!result.success && result.errorCode !== "already_checking") { - sonnerToast.custom( - () => ( - - - - Update check failed - - - {result.errorMessage || "Failed to check for updates"} - - - - ), - { id: CHECK_TOAST_ID, duration: 4000 }, - ); - } - } catch (error) { - log.error("Failed to check for updates:", error); - sonnerToast.custom( - () => ( - - - - Update check failed - - - An unexpected error occurred - - - - ), - { id: CHECK_TOAST_ID, duration: 4000 }, - ); - } - }, - }), - ); - - if (!isEnabled) { - return null; - } - - return null; -} diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx index 272e802de..dbf4218ca 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx @@ -5,6 +5,7 @@ import { useNavigationStore } from "@stores/navigationStore"; import type React from "react"; import { ProjectSwitcher } from "./ProjectSwitcher"; import { SidebarMenu } from "./SidebarMenu"; +import { UpdateBanner } from "./UpdateBanner"; export const SidebarContent: React.FC = () => { const archivedTaskIds = useArchivedTaskIds(); @@ -17,6 +18,7 @@ export const SidebarContent: React.FC = () => { + {archivedTaskIds.size > 0 && ( s.status); + const version = useUpdateStore((s) => s.version); + const isEnabled = useUpdateStore((s) => s.isEnabled); + const installUpdate = useUpdateStore((s) => s.installUpdate); + + const isVisible = + isEnabled && + (status === "downloading" || status === "ready" || status === "installing"); + + return ( + + {isVisible && ( + + + {status === "downloading" && ( + + + Downloading update... + + )} + + {status === "ready" && ( + + + + + + + + + {version ? `Updated to ${version}` : "Update available"} + + + Restart to apply + + + void installUpdate()} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = + "var(--green-a5)"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = + "var(--green-a4)"; + }} + > + Restart + + + + + )} + + {status === "installing" && ( + + + + + Restarting... + + + + )} + + + )} + + ); +} + +function BannerContent({ + children, + ...props +}: { children: React.ReactNode } & React.ComponentProps) { + return ( + + + {children} + + + ); +} diff --git a/apps/code/src/renderer/stores/updateStore.ts b/apps/code/src/renderer/stores/updateStore.ts new file mode 100644 index 000000000..f19fa654e --- /dev/null +++ b/apps/code/src/renderer/stores/updateStore.ts @@ -0,0 +1,106 @@ +import { trpcClient } from "@renderer/trpc/client"; +import { logger } from "@utils/logger"; +import { create } from "zustand"; + +const log = logger.scope("update-store"); + +type UpdateStatus = + | "idle" + | "checking" + | "downloading" + | "ready" + | "installing"; + +interface UpdateState { + status: UpdateStatus; + version: string | null; + isEnabled: boolean; + + installUpdate: () => Promise; + checkForUpdates: () => void; +} + +export const useUpdateStore = create()((set, get) => ({ + status: "idle", + version: null, + isEnabled: false, + + installUpdate: async () => { + if (get().status === "installing") return; + + set({ status: "installing" }); + + try { + const result = await trpcClient.updates.install.mutate(); + if (!result.installed) { + log.error("Update install returned not installed"); + set({ status: "ready" }); + } + } catch (error) { + log.error("Failed to install update", { error }); + set({ status: "ready" }); + } + }, + + checkForUpdates: () => { + trpcClient.updates.check.mutate().catch((error: unknown) => { + log.error("Failed to check for updates", { error }); + }); + }, +})); + +export function initializeUpdateStore() { + trpcClient.updates.isEnabled + .query() + .then((result) => { + useUpdateStore.setState({ isEnabled: result.enabled }); + }) + .catch((error: unknown) => { + log.error("Failed to get update enabled status", { error }); + }); + + const statusSub = trpcClient.updates.onStatus.subscribe(undefined, { + onData: (status) => { + if (status.checking && status.downloading) { + useUpdateStore.setState({ status: "downloading" }); + } else if (status.checking) { + useUpdateStore.setState({ status: "checking" }); + } else if (status.upToDate) { + const current = useUpdateStore.getState().status; + if (current === "checking" || current === "downloading") { + useUpdateStore.setState({ status: "idle" }); + } + } + }, + onError: (error) => { + log.error("Update status subscription error", { error }); + }, + }); + + const readySub = trpcClient.updates.onReady.subscribe(undefined, { + onData: (data) => { + useUpdateStore.setState({ + status: "ready", + version: data.version, + }); + }, + onError: (error) => { + log.error("Update ready subscription error", { error }); + }, + }); + + const menuCheckSub = trpcClient.updates.onCheckFromMenu.subscribe(undefined, { + onData: () => { + useUpdateStore.getState().checkForUpdates(); + }, + onError: (error) => { + log.error("Update menu check subscription error", { error }); + }, + }); + + return () => { + statusSub.unsubscribe(); + readySub.unsubscribe(); + menuCheckSub.unsubscribe(); + }; +}