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
-}