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 <noreply@anthropic.com>
This commit is contained in:
Harshvardhan Vatsa 2026-07-14 00:06:30 +05:30
parent 1742620cc1
commit 06e52af81c
5 changed files with 132 additions and 108 deletions

View file

@ -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)

View file

@ -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();

View file

@ -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() {
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
)}
<UpdateIndicator />
<CaffeinateIndicator />
{/* 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 */}
<UpdateReadyNotice busy={inCall || activeIsProcessing} />
{/* Rendered last so its no-drag region paints over the sidebar drag region */}
<FixedSidebarToggle
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}

View file

@ -0,0 +1,127 @@
import { useEffect, useRef, useState } from "react"
import { LoaderIcon, RefreshCw, X } from "lucide-react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { updatePrompted } from "@/lib/analytics"
type UpdaterStatus = {
state: string
newVersion?: string
snoozedUntil?: number
}
/**
* Titlebar update indicator (Zed-style): invisible while idle, a spinner
* while an update downloads, and a "Restart to update" chip once it's staged.
* Clicking the chip restarts into the new version; the small × snoozes the
* chip for 24h (owned by main see updater.ts snoozeUpdateNotice so it
* survives window reloads and reopens).
*
* Both buttons stay mounted and are toggled invisible when inactive a
* freshly-mounted no-drag button inside the drag-region header has its first
* click swallowed by the window drag (see CaffeinateIndicator).
*/
export function UpdateIndicator() {
const [status, setStatus] = useState<UpdaterStatus | null>(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 (
<div className="flex items-center self-center shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={ready ? () => void window.ipc.invoke("updater:quitAndInstall", null) : undefined}
disabled={!ready && !downloading}
aria-hidden={!ready && !downloading}
aria-label={ready ? "Restart to update" : downloading ? "Downloading update" : undefined}
className={cn(
"titlebar-no-drag flex h-8 items-center justify-center rounded-md transition-colors self-center shrink-0",
ready
? "gap-1.5 px-2.5 text-xs font-medium bg-accent/60 text-foreground hover:bg-accent"
: downloading
? "w-8 text-muted-foreground cursor-default"
: "w-0 invisible pointer-events-none",
)}
>
{downloading ? (
<LoaderIcon className="size-4 animate-spin" />
) : ready ? (
<>
<RefreshCw className="size-3.5" />
<span>Restart to update</span>
</>
) : null}
</button>
</TooltipTrigger>
{downloading && <TooltipContent side="bottom">Downloading update</TooltipContent>}
{ready && (
<TooltipContent side="bottom" className="max-w-64">
{description} Restart to finish updating you'll come right back to where you are.
</TooltipContent>
)}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => void window.ipc.invoke("updater:snooze", null)}
disabled={!ready}
aria-hidden={!ready}
aria-label="Remind me later"
className={cn(
"titlebar-no-drag flex h-8 items-center justify-center rounded-md text-muted-foreground transition-colors self-center shrink-0",
ready ? "w-5 hover:text-foreground" : "w-0 invisible pointer-events-none",
)}
>
<X className={cn("size-3.5", !ready && "hidden")} />
</button>
</TooltipTrigger>
{ready && <TooltipContent side="bottom">Remind me later</TooltipContent>}
</Tooltip>
</div>
)
}

View file

@ -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. "}
<a
href={notesUrl}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
{"See what's new"}
</a>
</>
),
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
}