From 06e52af81c7389390f893fbf3d535e09f5556d82 Mon Sep 17 00:00:00 2001 From: Harshvardhan Vatsa Date: Tue, 14 Jul 2026 00:06:30 +0530 Subject: [PATCH] feat(x): replace update toast with Zed-style titlebar chip The restart-to-update prompt moves from a sonner toast to a persistent, non-interrupting titlebar indicator: a spinner while an update downloads, then a 'Restart to update' chip once staged. Clicking restarts into the new version; the x snoozes the chip for 24h via the existing persisted snooze. Busy-deferral is dropped - the chip never interrupts. Co-Authored-By: Claude Fable 5 --- apps/x/ANALYTICS.md | 2 +- apps/x/apps/main/src/updater.ts | 4 +- apps/x/apps/renderer/src/App.tsx | 5 +- .../src/components/update-indicator.tsx | 127 ++++++++++++++++++ .../renderer/src/components/update-notice.tsx | 102 -------------- 5 files changed, 132 insertions(+), 108 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/update-indicator.tsx delete mode 100644 apps/x/apps/renderer/src/components/update-notice.tsx diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index c819a5a4..200a7bf7 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -101,7 +101,7 @@ All in `apps/renderer/src/lib/analytics.ts`: The desktop client's own updates — distinct from the in-app apps feature, which owns `app_updated`: -- `update_prompted` — renderer (`apps/renderer/src/lib/analytics.ts`): the restart-to-update card was shown for a staged update +- `update_prompted` — renderer (`apps/renderer/src/lib/analytics.ts`): the restart-to-update titlebar chip was shown for a staged update - `update_restarted` — main (`apps/main/src/updater.ts`), `{ from, to? }`: the user clicked restart-to-update (`to` is Windows-only; Squirrel.Mac doesn't report the release name) - `update_failed` — main (`apps/main/src/updater.ts`), `{ message }`: the auto-updater errored. Network/offline errors are excluded — they go to the soft `offline` state and are not captured (a user offline for hours would otherwise emit one per periodic check) - `client_updated` — main (`apps/main/src/ipc.ts`), `{ from, to }`: first launch on a newer version (fires once per update, whatever the restart path; downgrades restamp silently and don't fire) diff --git a/apps/x/apps/main/src/updater.ts b/apps/x/apps/main/src/updater.ts index 736448dd..063845ac 100644 --- a/apps/x/apps/main/src/updater.ts +++ b/apps/x/apps/main/src/updater.ts @@ -81,8 +81,8 @@ function showReadyBadge(): void { /** * Initialize auto-update. Replaces update-electron-app's `notifyUser` native * dialog with our own state machine: events are forwarded to the renderer - * (updater:status), which shows the non-modal "restart to update" card at a - * moment the user isn't busy. + * (updater:status), which shows a non-modal "Restart to update" chip in the + * titlebar once the update is staged. */ export function initUpdater(): void { const version = app.getVersion(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 899873ed..f94b3830 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -78,7 +78,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Toaster } from "@/components/ui/sonner" -import { UpdateReadyNotice } from "@/components/update-notice" +import { UpdateIndicator } from "@/components/update-indicator" import { BillingErrorDialog } from "@/components/billing-error-dialog" import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error" import { dispatchCreditExhausted, dispatchCreditReplenished } from "@/lib/credit-status" @@ -6293,6 +6293,7 @@ function App() { New chat )} + {/* Trailing layout control. Always mounted (just toggled invisible when inactive) so its -webkit-app-region:no-drag rect is stable — @@ -7044,8 +7045,6 @@ function App() { getLevel={tts.getLevel} /> )} - {/* Restart-to-update card; deferred while a call or turn is active */} - {/* Rendered last so its no-drag region paints over the sidebar drag region */} (null) + // Bumped when a snooze expires so the chip re-appears without new IPC. + const [now, setNow] = useState(() => Date.now()) + const promptedRef = useRef(false) + + useEffect(() => { + let cancelled = false + void window.ipc + .invoke("updater:getStatus", null) + .then((s) => { + if (!cancelled) setStatus(s) + }) + .catch(() => {}) + const unsubscribe = window.ipc.on("updater:status", (s) => { + setStatus(s) + }) + return () => { + cancelled = true + unsubscribe() + } + }, []) + + const snoozed = !!status?.snoozedUntil && status.snoozedUntil > now + const downloading = status?.state === "downloading" + const ready = status?.state === "ready" && !snoozed + + // Wake up when the snooze lapses so the chip re-offers itself. + useEffect(() => { + if (status?.state !== "ready" || !status.snoozedUntil) return + const delay = status.snoozedUntil - Date.now() + if (delay <= 0) return + const timer = setTimeout(() => setNow(Date.now()), delay + 1000) + return () => clearTimeout(timer) + }, [status]) + + useEffect(() => { + if (ready && !promptedRef.current) { + updatePrompted() + promptedRef.current = true + } + }, [ready]) + + const description = status?.newVersion + ? `Rowboat ${status.newVersion} has been downloaded.` + : "A new version of Rowboat has been downloaded." + + return ( +
+ + + + + {downloading && Downloading update…} + {ready && ( + + {description} Restart to finish updating — you'll come right back to where you are. + + )} + + + + + + {ready && Remind me later} + +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/update-notice.tsx b/apps/x/apps/renderer/src/components/update-notice.tsx deleted file mode 100644 index 03a24ed7..00000000 --- a/apps/x/apps/renderer/src/components/update-notice.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useEffect, useRef, useState } from "react" -import { toast } from "sonner" -import { updatePrompted } from "@/lib/analytics" - -// How often to re-evaluate the snooze while an update stays pending. The -// snooze itself (24h) is owned by main — see updater.ts snoozeUpdateNotice — -// so "Later" survives window reloads and reopens. -const RECHECK_MS = 60 * 60 * 1000 - -const TOAST_ID = "update-ready" - -/** - * Non-modal "restart to update" card (gap: the native update dialog used to - * interrupt mid-call). Renders nothing; drives a persistent sonner toast. - * - * `busy` defers the card while the user is in a call or a turn is running — - * it appears at the first idle moment after the update is staged, and is - * retracted if a call/turn starts while it's on screen. - */ -export function UpdateReadyNotice({ busy }: { busy: boolean }) { - const [ready, setReady] = useState<{ newVersion?: string; snoozedUntil?: number } | null>(null) - const promptedRef = useRef(false) - // Distinguishes our own toast.dismiss (retraction) from the user dismissing - // the card — sonner fires onDismiss for both, and only the latter snoozes. - const retractedRef = useRef(false) - - useEffect(() => { - void window.ipc.invoke("updater:getStatus", null).then((s) => { - if (s.state === "ready") setReady({ newVersion: s.newVersion, snoozedUntil: s.snoozedUntil }) - }) - return window.ipc.on("updater:status", (s) => { - setReady(s.state === "ready" ? { newVersion: s.newVersion, snoozedUntil: s.snoozedUntil } : null) - }) - }, []) - - useEffect(() => { - if (busy) { - // Retract the card if a call/turn starts while it's up. Only on busy — - // `ready` briefly clears during the periodic re-check cycle - // (ready → checking → ready), and dismissing there would blink the card. - retractedRef.current = true - toast.dismiss(TOAST_ID) - return - } - if (!ready) return - - // Main persists the snooze and pushes the refreshed status, which updates - // `ready.snoozedUntil` here (and in any other open window). - const snooze = () => { - if (retractedRef.current) return - void window.ipc.invoke("updater:snooze", null) - } - const show = () => { - if (ready.snoozedUntil && Date.now() < ready.snoozedUntil) return - if (!promptedRef.current) { - updatePrompted() - promptedRef.current = true - } - retractedRef.current = false - const notesUrl = ready.newVersion - ? `https://github.com/rowboatlabs/rowboat/releases/tag/v${ready.newVersion}` - : "https://github.com/rowboatlabs/rowboat/releases/latest" - toast("Update ready", { - id: TOAST_ID, // stable id: re-shows update in place, never stacks - description: ( - <> - {ready.newVersion - ? `Rowboat ${ready.newVersion} has been downloaded. ` - : "A new version of Rowboat has been downloaded. "} - {"Restart to finish updating — you'll come right back to where you are. "} - - {"See what's new"} - - - ), - duration: Infinity, - action: { - label: "Restart now", - onClick: () => void window.ipc.invoke("updater:quitAndInstall", null), - }, - cancel: { - label: "Later", - onClick: snooze, - }, - onDismiss: snooze, - }) - } - - show() - // The app can stay open for weeks (macOS especially) — quietly re-offer - // once per day while the update is still pending. - const interval = setInterval(show, RECHECK_MS) - return () => clearInterval(interval) - }, [ready, busy]) - - return null -}