mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
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:
parent
1742620cc1
commit
06e52af81c
5 changed files with 132 additions and 108 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
127
apps/x/apps/renderer/src/components/update-indicator.tsx
Normal file
127
apps/x/apps/renderer/src/components/update-indicator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue