mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
463 lines
14 KiB
TypeScript
463 lines
14 KiB
TypeScript
import { useStore } from '@nanostores/react'
|
|
import { useEffect, useState } from 'react'
|
|
|
|
import { BrandMark } from '@/components/brand-mark'
|
|
import { Button } from '@/components/ui/button'
|
|
import { writeClipboardText } from '@/components/ui/copy-button'
|
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
|
|
import { ErrorIcon, ErrorState } from '@/components/ui/error-state'
|
|
import { Loader } from '@/components/ui/loader'
|
|
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
|
|
import { useI18n } from '@/i18n'
|
|
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
|
|
import { AlertCircle, Check, CheckCircle2, Copy, Terminal } from '@/lib/icons'
|
|
import { resolveUpdateCopy, type UpdateTarget } from '@/lib/update-copy'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
$backendUpdateApply,
|
|
$backendUpdateChecking,
|
|
$backendUpdateStatus,
|
|
$updateApply,
|
|
$updateChecking,
|
|
$updateOverlayOpen,
|
|
$updateOverlayTarget,
|
|
$updateStatus,
|
|
applyBackendUpdate,
|
|
applyUpdates,
|
|
checkBackendUpdates,
|
|
checkUpdates,
|
|
resetUpdateApplyState,
|
|
setUpdateOverlayOpen,
|
|
type UpdateApplyState
|
|
} from '@/store/updates'
|
|
|
|
function totalItems(groups: readonly CommitGroup[]) {
|
|
return groups.reduce((sum, g) => sum + g.items.length, 0)
|
|
}
|
|
|
|
export function UpdatesOverlay() {
|
|
const open = useStore($updateOverlayOpen)
|
|
const target = useStore($updateOverlayTarget)
|
|
|
|
const clientStatus = useStore($updateStatus)
|
|
const clientChecking = useStore($updateChecking)
|
|
const clientApply = useStore($updateApply)
|
|
const backendStatus = useStore($backendUpdateStatus)
|
|
const backendChecking = useStore($backendUpdateChecking)
|
|
const backendApply = useStore($backendUpdateApply)
|
|
|
|
const isBackend = target === 'backend'
|
|
const status = isBackend ? backendStatus : clientStatus
|
|
const checking = isBackend ? backendChecking : clientChecking
|
|
const apply = isBackend ? backendApply : clientApply
|
|
const check = isBackend ? checkBackendUpdates : checkUpdates
|
|
const install = isBackend ? applyBackendUpdate : applyUpdates
|
|
|
|
useEffect(() => {
|
|
if (open && !status && !checking) {
|
|
void check()
|
|
}
|
|
}, [check, checking, open, status])
|
|
|
|
const behind = status?.behind ?? 0
|
|
const updateAvailable = status?.updateAvailable || behind > 0
|
|
|
|
const phase: 'idle' | 'applying' | 'manual' | 'guiSkew' | 'error' =
|
|
apply.stage === 'manual'
|
|
? 'manual'
|
|
: apply.stage === 'guiSkew'
|
|
? 'guiSkew'
|
|
: apply.applying || apply.stage === 'restart'
|
|
? 'applying'
|
|
: apply.stage === 'error'
|
|
? 'error'
|
|
: 'idle'
|
|
|
|
const handleClose = (next: boolean) => {
|
|
if (phase === 'applying') {
|
|
return
|
|
}
|
|
|
|
setUpdateOverlayOpen(next)
|
|
|
|
if (
|
|
!next &&
|
|
(apply.stage === 'error' || apply.stage === 'restart' || apply.stage === 'manual' || apply.stage === 'guiSkew')
|
|
) {
|
|
resetUpdateApplyState()
|
|
}
|
|
}
|
|
|
|
const handleInstall = () => {
|
|
void install()
|
|
}
|
|
|
|
return (
|
|
<Dialog onOpenChange={handleClose} open={open}>
|
|
<DialogContent
|
|
className="max-w-sm overflow-hidden border-border/70 p-0 gap-0"
|
|
showCloseButton={phase !== 'applying'}
|
|
>
|
|
{phase === 'applying' && <ApplyingView apply={apply} isBackend={isBackend} />}
|
|
|
|
{phase === 'manual' && (
|
|
<ManualView command={apply.command ?? null} message={apply.message} onDone={() => handleClose(false)} />
|
|
)}
|
|
|
|
{phase === 'guiSkew' && <GuiSkewView message={apply.message} onDone={() => handleClose(false)} />}
|
|
|
|
{phase === 'error' && (
|
|
<ErrorView message={apply.message} onDismiss={() => handleClose(false)} onRetry={handleInstall} />
|
|
)}
|
|
|
|
{phase === 'idle' && (
|
|
<IdleView
|
|
behind={behind}
|
|
checking={checking}
|
|
commits={status?.commits ?? []}
|
|
onInstall={handleInstall}
|
|
onLater={() => handleClose(false)}
|
|
onRetryCheck={() => void check()}
|
|
status={status}
|
|
target={target}
|
|
updateAvailable={updateAvailable}
|
|
/>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
function IdleView({
|
|
behind,
|
|
checking,
|
|
commits,
|
|
onInstall,
|
|
onLater,
|
|
onRetryCheck,
|
|
status,
|
|
target,
|
|
updateAvailable
|
|
}: {
|
|
behind: number
|
|
checking: boolean
|
|
commits: readonly DesktopUpdateCommit[]
|
|
onInstall: () => void
|
|
onLater: () => void
|
|
onRetryCheck: () => void
|
|
status: DesktopUpdateStatus | null
|
|
target: UpdateTarget
|
|
updateAvailable: boolean
|
|
}) {
|
|
const { t } = useI18n()
|
|
const u = t.updates
|
|
|
|
if (!status && checking) {
|
|
return (
|
|
<CenteredStatus
|
|
icon={<Loader className="size-12" label={u.checking} type="lemniscate-bloom" />}
|
|
title={u.checking}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (!status) {
|
|
return (
|
|
<CenteredStatus
|
|
action={
|
|
<Button onClick={onRetryCheck} size="sm">
|
|
{u.tryAgain}
|
|
</Button>
|
|
}
|
|
icon={<ErrorIcon />}
|
|
title={u.checkFailedTitle}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (!status.supported) {
|
|
return (
|
|
<CenteredStatus
|
|
body={status.message ?? u.unsupportedMessage}
|
|
icon={<AlertCircle className="size-6 text-muted-foreground" />}
|
|
title={u.notAvailableTitle}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (status.error) {
|
|
return (
|
|
<CenteredStatus
|
|
action={
|
|
<Button disabled={checking} onClick={onRetryCheck} size="sm">
|
|
{u.tryAgain}
|
|
</Button>
|
|
}
|
|
body={u.connectionRetry}
|
|
icon={<ErrorIcon />}
|
|
title={u.checkFailedTitle}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (!updateAvailable) {
|
|
return (
|
|
<CenteredStatus
|
|
body={target === 'backend' ? u.latestBodyBackend : u.latestBody}
|
|
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
|
|
title={u.allSetTitle}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const groups = buildCommitChangelog(commits)
|
|
const shownItems = totalItems(groups)
|
|
const remaining = Math.max(0, behind - shownItems)
|
|
|
|
// Name what's being updated. In remote mode the overlay acts on the connected
|
|
// backend, not the local client — say so. When there are no commit rows to
|
|
// show (e.g. pip/non-git backend), degrade to honest "no release notes" copy
|
|
// instead of generic filler.
|
|
const { title, body } = resolveUpdateCopy({ target, shownItems, copy: u })
|
|
|
|
return (
|
|
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
|
|
<div className="flex flex-col items-center gap-3 text-center">
|
|
<BrandMark className="size-16" />
|
|
|
|
<DialogTitle className="text-center text-xl">{title}</DialogTitle>
|
|
<DialogDescription className="text-center text-sm">{body}</DialogDescription>
|
|
</div>
|
|
|
|
<div className="grid gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
|
|
{groups.map(group => (
|
|
<div key={group.id}>
|
|
<p className="text-[0.625rem] font-semibold uppercase tracking-wide text-muted-foreground">{group.label}</p>
|
|
<ul className="mt-1.5 grid gap-1.5 text-xs text-foreground">
|
|
{group.items.map(item => (
|
|
<li className="flex items-start gap-2" key={item}>
|
|
<span aria-hidden className="mt-1.5 inline-block size-1 shrink-0 rounded-full bg-primary" />
|
|
<span className="leading-snug">{item}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Button className="font-semibold" onClick={onInstall} size="lg">
|
|
{u.updateNow}
|
|
</Button>
|
|
<Button className="font-medium" onClick={onLater} type="button" variant="text">
|
|
{u.maybeLater}
|
|
</Button>
|
|
</div>
|
|
|
|
{remaining > 0 && <p className="text-center text-xs text-muted-foreground">{u.moreChanges(remaining)}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ManualView({ command, message, onDone }: { command: string | null; message?: string; onDone: () => void }) {
|
|
const { t } = useI18n()
|
|
const u = t.updates
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
const handleCopy = () => {
|
|
if (!command) {
|
|
return
|
|
}
|
|
|
|
void writeClipboardText(command).then(() => {
|
|
setCopied(true)
|
|
window.setTimeout(() => setCopied(false), 1800)
|
|
})
|
|
}
|
|
|
|
// No command (e.g. the Linux sandbox-blocked relaunch): render the explanatory
|
|
// message + a Done button, not a copy-a-command box.
|
|
if (!command) {
|
|
return (
|
|
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
|
|
<div className="flex flex-col items-center gap-3 text-center">
|
|
<Terminal className="size-8 text-primary" />
|
|
|
|
<DialogTitle className="text-center text-xl">{u.manualTitle}</DialogTitle>
|
|
<DialogDescription className="text-center text-sm">{message || u.manualPickedUp}</DialogDescription>
|
|
</div>
|
|
|
|
<Button className="font-semibold" onClick={onDone} size="lg" variant="secondary">
|
|
{u.done}
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
|
|
<div className="flex flex-col items-center gap-3 text-center">
|
|
<Terminal className="size-8 text-primary" />
|
|
|
|
<DialogTitle className="text-center text-xl">{u.manualTitle}</DialogTitle>
|
|
<DialogDescription className="text-center text-sm">{u.manualBody}</DialogDescription>
|
|
</div>
|
|
|
|
<button
|
|
className="group flex w-full items-center justify-between gap-3 rounded-xl border border-border/70 bg-muted/30 px-4 py-3 text-left transition-colors hover:border-border hover:bg-muted/50"
|
|
onClick={handleCopy}
|
|
type="button"
|
|
>
|
|
<code className="select-all font-mono text-sm text-foreground">
|
|
<span className="text-muted-foreground">$ </span>
|
|
{command}
|
|
</code>
|
|
<span className="flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground transition-colors group-hover:text-foreground">
|
|
{copied ? (
|
|
<>
|
|
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" />
|
|
{u.copied}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Copy className="size-3.5" />
|
|
{u.copy}
|
|
</>
|
|
)}
|
|
</span>
|
|
</button>
|
|
|
|
<p className="text-center text-xs text-muted-foreground">{u.manualPickedUp}</p>
|
|
|
|
<Button className="font-semibold" onClick={onDone} size="lg" variant="secondary">
|
|
{u.done}
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Linux GUI/backend skew (#45205): backend updated, but the running desktop app
|
|
// package (AppImage/.deb/.rpm) was NOT changed. Closeable terminal state that
|
|
// tells the user to update/reinstall the desktop app — never claims the GUI was
|
|
// updated.
|
|
function GuiSkewView({ message, onDone }: { message?: string; onDone: () => void }) {
|
|
const { t } = useI18n()
|
|
const u = t.updates
|
|
|
|
return (
|
|
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
|
|
<div className="flex flex-col items-center gap-3 text-center">
|
|
<AlertCircle className="size-8 text-amber-500" />
|
|
|
|
<DialogTitle className="text-center text-xl">{u.guiSkewTitle}</DialogTitle>
|
|
<DialogDescription className="max-w-prose text-center text-sm leading-5 text-muted-foreground">
|
|
{message || u.guiSkewBody}
|
|
</DialogDescription>
|
|
</div>
|
|
|
|
<Button className="font-semibold" onClick={onDone} size="lg" variant="secondary">
|
|
{u.done}
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend: boolean }) {
|
|
const { t } = useI18n()
|
|
const u = t.updates
|
|
const label = u.stages[apply.stage as DesktopUpdateStage] ?? u.stages.idle
|
|
const body = isBackend ? u.applyingBodyBackend : u.applyingBody
|
|
const currentMessage = apply.message.trim()
|
|
const recentLog = apply.log.slice(-4)
|
|
|
|
const percent =
|
|
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
|
|
? Math.max(2, Math.min(100, Math.round(apply.percent)))
|
|
: null
|
|
|
|
return (
|
|
<div className="grid gap-5 px-6 pb-6 pt-7">
|
|
<div className="flex flex-col items-center gap-3 text-center">
|
|
<Loader className="size-16" label={label} type="lemniscate-bloom" />
|
|
|
|
<DialogTitle className="text-center text-xl">{label}</DialogTitle>
|
|
<DialogDescription className="text-center text-sm">{body}</DialogDescription>
|
|
|
|
{currentMessage ? (
|
|
<p className="max-w-lg break-words text-center text-xs leading-5 text-muted-foreground">{currentMessage}</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className={cn(
|
|
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
|
|
percent === null && 'w-1/3 animate-pulse'
|
|
)}
|
|
style={percent !== null ? { width: `${percent}%` } : undefined}
|
|
/>
|
|
</div>
|
|
|
|
{recentLog.length > 1 ? (
|
|
<div className="max-h-24 overflow-hidden rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-left font-mono text-[11px] leading-4 text-muted-foreground">
|
|
{recentLog.map((entry, index) => (
|
|
<div className="truncate" key={`${entry.at}-${index}`}>
|
|
{entry.message}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
<p className="text-center text-xs text-muted-foreground">{u.applyingClose}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss: () => void; onRetry: () => void }) {
|
|
const { t } = useI18n()
|
|
const u = t.updates
|
|
|
|
return (
|
|
<ErrorState
|
|
className="px-6 pb-6 pt-7 pr-8"
|
|
description={
|
|
<DialogDescription className="max-w-prose text-center text-sm leading-5 text-muted-foreground">
|
|
{message || u.errorBody}
|
|
</DialogDescription>
|
|
}
|
|
title={<DialogTitle className="text-center text-xl font-semibold tracking-tight">{u.errorTitle}</DialogTitle>}
|
|
>
|
|
<Button className="font-semibold" onClick={onRetry} size="lg">
|
|
{u.tryAgain}
|
|
</Button>
|
|
<Button onClick={onDismiss} variant="text">
|
|
{u.notNow}
|
|
</Button>
|
|
</ErrorState>
|
|
)
|
|
}
|
|
|
|
function CenteredStatus({
|
|
action,
|
|
body,
|
|
icon,
|
|
title
|
|
}: {
|
|
action?: React.ReactNode
|
|
body?: string
|
|
icon: React.ReactNode
|
|
title: string
|
|
}) {
|
|
return (
|
|
<div className="grid gap-4 px-6 pb-6 pt-8 pr-8">
|
|
<div className="flex flex-col items-center gap-3 text-center">
|
|
{icon}
|
|
|
|
<DialogTitle className="text-center text-lg">{title}</DialogTitle>
|
|
{body && <DialogDescription className="text-center text-sm">{body}</DialogDescription>}
|
|
</div>
|
|
|
|
{action && <div className="flex justify-center">{action}</div>}
|
|
</div>
|
|
)
|
|
}
|