mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-18 14:52:04 +00:00
fix(desktop): split client and backend into two distinct update buttons
The status bar merged both versions into one pill with a single click target, so there was no way to tell which artifact an update acted on — and the apply path was overloaded by connection mode. Separate them: - store: independent client (checkUpdates/applyUpdates) and backend (checkBackendUpdates/applyBackendUpdate) flows with their own status/apply atoms; openUpdateOverlayFor(target) drives the overlay. - status bar: two buttons — client vX (always) and backend vY (+N) (remote only), each with its own behind-count, opening the overlay for its target. - overlay: reads the active target's atoms; install/check route per target. Removes the version-bar merge helper (no longer merging the two versions).
This commit is contained in:
parent
9c264555b0
commit
56be1a63a3
6 changed files with 139 additions and 283 deletions
|
|
@ -41,8 +41,14 @@ import {
|
|||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
|
||||
import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } from '@/store/updates'
|
||||
import { resolveVersionBar } from '@/lib/version-bar'
|
||||
import {
|
||||
$backendUpdateApply,
|
||||
$backendUpdateStatus,
|
||||
$desktopVersion,
|
||||
$updateApply,
|
||||
$updateStatus,
|
||||
openUpdateOverlayFor
|
||||
} from '@/store/updates'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
import { CRON_ROUTE } from '../../routes'
|
||||
|
|
@ -99,6 +105,8 @@ export function useStatusbarItems({
|
|||
const subagentsBySession = useStore($subagentsBySession)
|
||||
const updateStatus = useStore($updateStatus)
|
||||
const updateApply = useStore($updateApply)
|
||||
const backendUpdateStatus = useStore($backendUpdateStatus)
|
||||
const backendUpdateApply = useStore($backendUpdateApply)
|
||||
const desktopVersion = useStore($desktopVersion)
|
||||
const connection = useStore($connection)
|
||||
|
||||
|
|
@ -197,36 +205,25 @@ export function useStatusbarItems({
|
|||
? 'text-amber-600 hover:text-amber-600'
|
||||
: 'text-destructive hover:text-destructive'
|
||||
|
||||
const versionItem = useMemo<StatusbarItem>(() => {
|
||||
const clientVersionItem = useMemo<StatusbarItem>(() => {
|
||||
const appVersion = desktopVersion?.appVersion
|
||||
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
|
||||
const behind = updateStatus?.behind ?? 0
|
||||
const applying = updateApply.applying || updateApply.stage === 'restart'
|
||||
const remote = connection?.mode === 'remote'
|
||||
|
||||
// In remote thin-client mode the client and backend are separate installs;
|
||||
// show both versions and flag skew. Local mode is unchanged.
|
||||
const versionBar = resolveVersionBar({
|
||||
appVersion,
|
||||
sha,
|
||||
backendVersion: statusSnapshot?.version,
|
||||
mode: connection?.mode,
|
||||
copy: { clientLabel: copy.clientLabel, backendLabel: copy.backendLabel, unknown: copy.unknown }
|
||||
})
|
||||
|
||||
const base = versionBar.label
|
||||
const version = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
|
||||
const base = remote ? copy.clientLabel(appVersion ?? sha ?? copy.unknown) : version
|
||||
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
|
||||
|
||||
const label = applying
|
||||
? updateApply.stage === 'restart'
|
||||
? `${base} · ${copy.restart}`
|
||||
: `${base} · ${copy.update}`
|
||||
? `${base} · ${updateApply.stage === 'restart' ? copy.restart : copy.update}`
|
||||
: `${base}${behindHint}`
|
||||
|
||||
const tooltip = [
|
||||
applying ? updateApply.message || copy.updateInProgress : null,
|
||||
!applying && behind > 0 && copy.commitsBehind(behind, updateStatus?.branch ?? '...'),
|
||||
appVersion && copy.desktopVersion(appVersion),
|
||||
versionBar.showsBackend && versionBar.backendVersion && copy.backendVersion(versionBar.backendVersion),
|
||||
sha && copy.commit(sha),
|
||||
updateStatus?.branch && copy.branch(updateStatus.branch)
|
||||
]
|
||||
|
|
@ -234,21 +231,19 @@ export function useStatusbarItems({
|
|||
.join(' · ')
|
||||
|
||||
return {
|
||||
className:
|
||||
versionBar.skew || (!applying && behind > 0) ? 'text-primary hover:text-primary' : undefined,
|
||||
detail: appVersion && sha && !applying && !versionBar.showsBackend ? sha : undefined,
|
||||
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
|
||||
detail: appVersion && sha && !applying && !remote ? sha : undefined,
|
||||
hidden: !appVersion && !sha,
|
||||
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
|
||||
id: 'version',
|
||||
id: 'version-client',
|
||||
label,
|
||||
onSelect: () => setUpdateOverlayOpen(true),
|
||||
onSelect: () => openUpdateOverlayFor('client'),
|
||||
title: tooltip || undefined,
|
||||
variant: 'action'
|
||||
}
|
||||
}, [
|
||||
desktopVersion?.appVersion,
|
||||
connection?.mode,
|
||||
statusSnapshot?.version,
|
||||
copy,
|
||||
updateApply.applying,
|
||||
updateApply.message,
|
||||
|
|
@ -258,6 +253,50 @@ export function useStatusbarItems({
|
|||
updateStatus?.currentSha
|
||||
])
|
||||
|
||||
const backendVersionItem = useMemo<StatusbarItem | null>(() => {
|
||||
if (connection?.mode !== 'remote') {
|
||||
return null
|
||||
}
|
||||
|
||||
const backendVersion = statusSnapshot?.version
|
||||
const behind = backendUpdateStatus?.behind ?? 0
|
||||
const applying = backendUpdateApply.applying || backendUpdateApply.stage === 'restart'
|
||||
|
||||
const base = copy.backendLabel(backendVersion ?? copy.unknown)
|
||||
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
|
||||
|
||||
const label = applying
|
||||
? `${base} · ${backendUpdateApply.stage === 'restart' ? copy.restart : copy.update}`
|
||||
: `${base}${behindHint}`
|
||||
|
||||
const tooltip = [
|
||||
applying ? backendUpdateApply.message || copy.updateInProgress : null,
|
||||
!applying && behind > 0 && copy.commitsBehind(behind, 'main'),
|
||||
backendVersion && copy.backendVersion(backendVersion)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return {
|
||||
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
|
||||
hidden: !backendVersion,
|
||||
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
|
||||
id: 'version-backend',
|
||||
label,
|
||||
onSelect: () => openUpdateOverlayFor('backend'),
|
||||
title: tooltip || undefined,
|
||||
variant: 'action'
|
||||
}
|
||||
}, [
|
||||
connection?.mode,
|
||||
statusSnapshot?.version,
|
||||
backendUpdateStatus?.behind,
|
||||
backendUpdateApply.applying,
|
||||
backendUpdateApply.message,
|
||||
backendUpdateApply.stage,
|
||||
copy
|
||||
])
|
||||
|
||||
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
|
||||
() => [
|
||||
{
|
||||
|
|
@ -403,7 +442,8 @@ export function useStatusbarItems({
|
|||
variant: 'action' as const
|
||||
})
|
||||
},
|
||||
versionItem
|
||||
...(backendVersionItem ? [backendVersionItem] : []),
|
||||
clientVersionItem
|
||||
],
|
||||
[
|
||||
busy,
|
||||
|
|
@ -419,7 +459,8 @@ export function useStatusbarItems({
|
|||
showYoloToggle,
|
||||
toggleYolo,
|
||||
turnStartedAt,
|
||||
versionItem,
|
||||
clientVersionItem,
|
||||
backendVersionItem,
|
||||
yoloActive
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,40 +13,51 @@ import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
|
|||
import { AlertCircle, Check, CheckCircle2, Copy, Terminal } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { resolveUpdateCopy, type UpdateTarget } from '@/lib/update-copy'
|
||||
import { $connection } from '@/store/session'
|
||||
import {
|
||||
$backendUpdateApply,
|
||||
$backendUpdateChecking,
|
||||
$backendUpdateStatus,
|
||||
$updateApply,
|
||||
$updateChecking,
|
||||
$updateOverlayOpen,
|
||||
$updateOverlayTarget,
|
||||
$updateStatus,
|
||||
applyBackendUpdate,
|
||||
applyUpdates,
|
||||
checkBackendUpdates,
|
||||
checkUpdates,
|
||||
resetUpdateApplyState,
|
||||
setUpdateOverlayOpen,
|
||||
type UpdateApplyState
|
||||
} from '@/store/updates'
|
||||
|
||||
/** What the overlay is acting on. In remote thin-client mode the overlay
|
||||
* targets the connected BACKEND; otherwise the local desktop client.
|
||||
* (Type re-exported from the copy helper.) */
|
||||
|
||||
function totalItems(groups: readonly CommitGroup[]) {
|
||||
return groups.reduce((sum, g) => sum + g.items.length, 0)
|
||||
}
|
||||
|
||||
export function UpdatesOverlay() {
|
||||
const open = useStore($updateOverlayOpen)
|
||||
const status = useStore($updateStatus)
|
||||
const checking = useStore($updateChecking)
|
||||
const apply = useStore($updateApply)
|
||||
const connection = useStore($connection)
|
||||
const target: UpdateTarget = connection?.mode === 'remote' ? 'backend' : 'client'
|
||||
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 checkUpdates()
|
||||
void check()
|
||||
}
|
||||
}, [checking, open, status])
|
||||
}, [check, checking, open, status])
|
||||
|
||||
const behind = status?.behind ?? 0
|
||||
|
||||
|
|
@ -72,7 +83,7 @@ export function UpdatesOverlay() {
|
|||
}
|
||||
|
||||
const handleInstall = () => {
|
||||
void applyUpdates()
|
||||
void install()
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -98,7 +109,7 @@ export function UpdatesOverlay() {
|
|||
commits={status?.commits ?? []}
|
||||
onInstall={handleInstall}
|
||||
onLater={() => handleClose(false)}
|
||||
onRetryCheck={() => void checkUpdates()}
|
||||
onRetryCheck={() => void check()}
|
||||
status={status}
|
||||
target={target}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,93 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveVersionBar } from './version-bar'
|
||||
|
||||
const copy = {
|
||||
clientLabel: (v: string) => `client v${v}`,
|
||||
backendLabel: (v: string) => `backend v${v}`,
|
||||
unknown: 'unknown'
|
||||
}
|
||||
|
||||
describe('resolveVersionBar', () => {
|
||||
it('local mode: shows only the client version (unchanged behaviour)', () => {
|
||||
const result = resolveVersionBar({
|
||||
appVersion: '0.16.0',
|
||||
backendVersion: '0.16.0',
|
||||
mode: 'local',
|
||||
copy
|
||||
})
|
||||
|
||||
expect(result.label).toBe('v0.16.0')
|
||||
expect(result.showsBackend).toBe(false)
|
||||
})
|
||||
|
||||
it('remote mode: shows BOTH client and backend versions', () => {
|
||||
const result = resolveVersionBar({
|
||||
appVersion: '0.15.1',
|
||||
backendVersion: '0.16.0',
|
||||
mode: 'remote',
|
||||
copy
|
||||
})
|
||||
|
||||
expect(result.label).toContain('client v0.15.1')
|
||||
expect(result.label).toContain('backend v0.16.0')
|
||||
expect(result.showsBackend).toBe(true)
|
||||
expect(result.backendVersion).toBe('0.16.0')
|
||||
})
|
||||
|
||||
it('remote mode without a backend version yet: falls back to client-only, no crash', () => {
|
||||
const result = resolveVersionBar({
|
||||
appVersion: '0.15.1',
|
||||
backendVersion: undefined,
|
||||
mode: 'remote',
|
||||
copy
|
||||
})
|
||||
|
||||
expect(result.label).toBe('v0.15.1')
|
||||
expect(result.showsBackend).toBe(false)
|
||||
})
|
||||
|
||||
it('remote mode where both versions match: still shows both (no implicit hiding)', () => {
|
||||
const result = resolveVersionBar({
|
||||
appVersion: '0.16.0',
|
||||
backendVersion: '0.16.0',
|
||||
mode: 'remote',
|
||||
copy
|
||||
})
|
||||
|
||||
expect(result.label).toContain('client v0.16.0')
|
||||
expect(result.label).toContain('backend v0.16.0')
|
||||
expect(result.showsBackend).toBe(true)
|
||||
})
|
||||
|
||||
it('no client version at all: uses sha fallback for the base, local mode', () => {
|
||||
const result = resolveVersionBar({
|
||||
appVersion: undefined,
|
||||
sha: 'abc1234',
|
||||
backendVersion: undefined,
|
||||
mode: 'local',
|
||||
copy
|
||||
})
|
||||
|
||||
expect(result.label).toBe('abc1234')
|
||||
expect(result.showsBackend).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes a skew flag when remote versions differ', () => {
|
||||
const skewed = resolveVersionBar({
|
||||
appVersion: '0.15.1',
|
||||
backendVersion: '0.16.0',
|
||||
mode: 'remote',
|
||||
copy
|
||||
})
|
||||
const aligned = resolveVersionBar({
|
||||
appVersion: '0.16.0',
|
||||
backendVersion: '0.16.0',
|
||||
mode: 'remote',
|
||||
copy
|
||||
})
|
||||
|
||||
expect(skewed.skew).toBe(true)
|
||||
expect(aligned.skew).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
/**
|
||||
* Pure logic for the status-bar version item.
|
||||
*
|
||||
* In local mode the desktop app and its backend are the same install, so a
|
||||
* single version is correct and we keep today's behaviour byte-for-byte. In
|
||||
* remote (thin-client) mode the Electron client and the backend it connects to
|
||||
* are separate installs that drift independently — so we surface BOTH the
|
||||
* client version and the connected backend version, and flag skew.
|
||||
*
|
||||
* Kept as a pure helper (no React, no stores) so it's unit-testable in
|
||||
* isolation; the status-bar hook composes the label/click from this.
|
||||
*/
|
||||
|
||||
export interface VersionBarCopy {
|
||||
clientLabel: (version: string) => string
|
||||
backendLabel: (version: string) => string
|
||||
unknown: string
|
||||
}
|
||||
|
||||
export interface ResolveVersionBarInput {
|
||||
/** Desktop client version (from the Electron getVersion IPC). */
|
||||
appVersion?: string
|
||||
/** Short git sha fallback when no semantic version is available. */
|
||||
sha?: string | null
|
||||
/** Backend version reported by the connected gateway (StatusResponse.version). */
|
||||
backendVersion?: string
|
||||
/** Connection topology. Only 'remote' shows two versions. */
|
||||
mode?: 'local' | 'remote'
|
||||
copy: VersionBarCopy
|
||||
}
|
||||
|
||||
export interface VersionBarResult {
|
||||
/** Composed status-bar label. */
|
||||
label: string
|
||||
/** True when the backend version is shown alongside the client version. */
|
||||
showsBackend: boolean
|
||||
/** The backend version actually shown (when showsBackend). */
|
||||
backendVersion?: string
|
||||
/** True when remote client and backend versions differ. */
|
||||
skew: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the version-bar label and skew state.
|
||||
*
|
||||
* Local mode (or remote with no backend version yet) → single client version,
|
||||
* identical to the pre-existing behaviour. Remote mode with a backend version →
|
||||
* "client vX · backend vY".
|
||||
*/
|
||||
export function resolveVersionBar(input: ResolveVersionBarInput): VersionBarResult {
|
||||
const { appVersion, sha, backendVersion, mode, copy } = input
|
||||
|
||||
const clientBase = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
|
||||
|
||||
const isRemote = mode === 'remote'
|
||||
const showsBackend = isRemote && Boolean(backendVersion) && Boolean(appVersion)
|
||||
|
||||
if (!showsBackend) {
|
||||
return {
|
||||
label: clientBase,
|
||||
showsBackend: false,
|
||||
skew: false
|
||||
}
|
||||
}
|
||||
|
||||
const skew = backendVersion !== appVersion
|
||||
|
||||
return {
|
||||
label: `${copy.clientLabel(appVersion as string)} · ${copy.backendLabel(backendVersion as string)}`,
|
||||
showsBackend: true,
|
||||
backendVersion,
|
||||
skew
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ vi.mock('@/hermes', () => ({
|
|||
getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args)
|
||||
}))
|
||||
|
||||
const { maybeNotifyUpdateAvailable, checkUpdates, $updateStatus } = await import('./updates')
|
||||
const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus } = await import('./updates')
|
||||
const { setConnection } = await import('./session')
|
||||
|
||||
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
|
||||
|
|
@ -87,12 +87,12 @@ describe('maybeNotifyUpdateAvailable', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('checkUpdates in remote mode', () => {
|
||||
describe('checkBackendUpdates', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear()
|
||||
notifySpy.mockClear()
|
||||
checkHermesUpdateSpy.mockReset()
|
||||
$updateStatus.set(null)
|
||||
$backendUpdateStatus.set(null)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
|
|
@ -108,7 +108,7 @@ describe('checkUpdates in remote mode', () => {
|
|||
windowButtonPosition: null
|
||||
})
|
||||
|
||||
it('sources the overlay from the backend /update/check and maps commits', async () => {
|
||||
it('maps the backend /update/check onto the backend status, including commits', async () => {
|
||||
setRemote(true)
|
||||
checkHermesUpdateSpy.mockResolvedValue({
|
||||
install_method: 'git',
|
||||
|
|
@ -121,13 +121,13 @@ describe('checkUpdates in remote mode', () => {
|
|||
commits: [{ sha: 'abc1234', summary: 'feat: x', author: 'a', at: 1 }]
|
||||
})
|
||||
|
||||
const result = await checkUpdates()
|
||||
const result = await checkBackendUpdates()
|
||||
|
||||
expect(checkHermesUpdateSpy).toHaveBeenCalled()
|
||||
expect(result?.behind).toBe(2)
|
||||
expect(result?.commits?.[0]?.sha).toBe('abc1234')
|
||||
expect(result?.supported).toBe(true)
|
||||
expect($updateStatus.get()?.commits?.[0]?.summary).toBe('feat: x')
|
||||
expect($backendUpdateStatus.get()?.commits?.[0]?.summary).toBe('feat: x')
|
||||
})
|
||||
|
||||
it('honours can_apply=false (docker/nix): not supported, carries message', async () => {
|
||||
|
|
@ -142,21 +142,16 @@ describe('checkUpdates in remote mode', () => {
|
|||
message: 'Docker images are immutable.'
|
||||
})
|
||||
|
||||
const result = await checkUpdates()
|
||||
const result = await checkBackendUpdates()
|
||||
|
||||
expect(result?.supported).toBe(false)
|
||||
expect(result?.message).toBe('Docker images are immutable.')
|
||||
})
|
||||
|
||||
it('does NOT call the backend check in local mode', async () => {
|
||||
it('is a no-op in local mode (backend check only runs when remote)', async () => {
|
||||
setRemote(false)
|
||||
// No hermesDesktop bridge → local path early-returns without hitting the
|
||||
// backend. Stub a bare window so the local branch can read the (absent)
|
||||
// bridge without throwing in the node test env.
|
||||
vi.stubGlobal('window', {})
|
||||
await checkUpdates()
|
||||
await checkBackendUpdates()
|
||||
expect(checkHermesUpdateSpy).not.toHaveBeenCalled()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -48,8 +48,24 @@ export const $updateChecking = atom<boolean>(false)
|
|||
export const $updateOverlayOpen = atom<boolean>(false)
|
||||
export const $updateStatus = atom<DesktopUpdateStatus | null>(null)
|
||||
|
||||
// Client and backend are independently updatable; each keeps its own state.
|
||||
export const $backendUpdateStatus = atom<DesktopUpdateStatus | null>(null)
|
||||
export const $backendUpdateApply = atom<UpdateApplyState>(IDLE)
|
||||
export const $backendUpdateChecking = atom<boolean>(false)
|
||||
|
||||
export type UpdateTarget = 'client' | 'backend'
|
||||
export const $updateOverlayTarget = atom<UpdateTarget>('client')
|
||||
|
||||
export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(open)
|
||||
export const resetUpdateApplyState = () => $updateApply.set(IDLE)
|
||||
export const openUpdateOverlayFor = (target: UpdateTarget) => {
|
||||
$updateOverlayTarget.set(target)
|
||||
$updateOverlayOpen.set(true)
|
||||
void (target === 'backend' ? checkBackendUpdates() : checkUpdates())
|
||||
}
|
||||
export const resetUpdateApplyState = () => {
|
||||
$updateApply.set(IDLE)
|
||||
$backendUpdateApply.set(IDLE)
|
||||
}
|
||||
|
||||
const UPDATE_TOAST_ID = 'desktop-update-available'
|
||||
// Time-based snooze instead of per-sha dismissal: this repo lands ~100 commits
|
||||
|
|
@ -89,7 +105,7 @@ export function reportBackendContract(contract: number | undefined): void {
|
|||
}
|
||||
|
||||
notify({
|
||||
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyUpdates() },
|
||||
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyBackendUpdate() },
|
||||
durationMs: 0,
|
||||
id: SKEW_TOAST_ID,
|
||||
kind: 'warning',
|
||||
|
|
@ -140,13 +156,8 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the updates dialog and kicks off a fresh check so the user always
|
||||
* sees current state, even if a stale status is cached from earlier.
|
||||
*/
|
||||
export function openUpdatesWindow(): void {
|
||||
$updateOverlayOpen.set(true)
|
||||
void checkUpdates()
|
||||
openUpdateOverlayFor(isRemoteMode() ? 'backend' : 'client')
|
||||
}
|
||||
|
||||
/** Re-read the running app's version from the Electron main process and
|
||||
|
|
@ -181,9 +192,6 @@ function isRemoteMode(): boolean {
|
|||
return $connection.get()?.mode === 'remote'
|
||||
}
|
||||
|
||||
/** Map the backend's /api/hermes/update/check shape onto the overlay's
|
||||
* DesktopUpdateStatus. `can_apply` / `message` are preserved so the overlay
|
||||
* can show guidance (e.g. docker/nix) instead of an Install button. */
|
||||
function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateStatus {
|
||||
const behind = res.behind ?? 0
|
||||
|
||||
|
|
@ -191,55 +199,42 @@ function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateStatus {
|
|||
supported: res.can_apply,
|
||||
message: res.message ?? undefined,
|
||||
behind: behind > 0 ? behind : 0,
|
||||
// targetSha gates the "update available" toast in maybeNotifyUpdateAvailable;
|
||||
// synthesize a stable marker when the backend reports it's behind.
|
||||
targetSha: res.update_available ? `backend:${res.current_version}` : undefined,
|
||||
commits: res.commits,
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
async function checkBackendUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
if ($updateChecking.get()) {
|
||||
return $updateStatus.get()
|
||||
export async function checkBackendUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
if (!isRemoteMode() || $backendUpdateChecking.get()) {
|
||||
return $backendUpdateStatus.get()
|
||||
}
|
||||
|
||||
$updateChecking.set(true)
|
||||
$backendUpdateChecking.set(true)
|
||||
|
||||
try {
|
||||
const res = await checkHermesUpdate(true)
|
||||
const status = mapBackendCheck(res)
|
||||
$updateStatus.set(status)
|
||||
const status = mapBackendCheck(await checkHermesUpdate(true))
|
||||
$backendUpdateStatus.set(status)
|
||||
maybeNotifyUpdateAvailable(status)
|
||||
|
||||
return status
|
||||
} catch (error) {
|
||||
const previous = $updateStatus.get()
|
||||
const fallback: DesktopUpdateStatus = {
|
||||
supported: previous?.supported ?? true,
|
||||
branch: previous?.branch,
|
||||
supported: $backendUpdateStatus.get()?.supported ?? true,
|
||||
error: 'check-failed',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
|
||||
$updateStatus.set(fallback)
|
||||
$backendUpdateStatus.set(fallback)
|
||||
|
||||
return fallback
|
||||
} finally {
|
||||
$updateChecking.set(false)
|
||||
$backendUpdateChecking.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
// Remote thin-client mode: the version pill points at the BACKEND, not the
|
||||
// local Electron clone. Source the overlay from the backend's own
|
||||
// /api/hermes/update/check so behind-count + "what's changed" describe the
|
||||
// machine the user is actually connected to.
|
||||
if (isRemoteMode()) {
|
||||
return checkBackendUpdates()
|
||||
}
|
||||
|
||||
const bridge = window.hermesDesktop?.updates
|
||||
|
||||
if (!bridge || $updateChecking.get()) {
|
||||
|
|
@ -252,9 +247,6 @@ export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
|
|||
const status = await bridge.check()
|
||||
$updateStatus.set(status)
|
||||
maybeNotifyUpdateAvailable(status)
|
||||
// The update check pulls the latest hermes_cli + bundled package metadata
|
||||
// into place. Re-read the running version so About reflects the now-fresh
|
||||
// checkout rather than the one captured at process start.
|
||||
void refreshDesktopVersion()
|
||||
|
||||
return status
|
||||
|
|
@ -278,14 +270,6 @@ export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
|
|||
}
|
||||
|
||||
export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promise<DesktopUpdateApplyResult> {
|
||||
// Remote mode: apply the update on the BACKEND via its HTTP API (the same
|
||||
// path the command-center "Update Hermes" button uses), then poll the action
|
||||
// to completion. The Electron git bridge would update the local client clone,
|
||||
// which is the wrong target when the version pill points at a remote backend.
|
||||
if (isRemoteMode()) {
|
||||
return applyBackendUpdate()
|
||||
}
|
||||
|
||||
const bridge = window.hermesDesktop?.updates
|
||||
|
||||
if (!bridge) {
|
||||
|
|
@ -320,40 +304,32 @@ export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promis
|
|||
}
|
||||
}
|
||||
|
||||
/** Apply the update on the connected backend: POST /api/hermes/update, then
|
||||
* poll the spawned action to completion. Drives $updateApply so the overlay
|
||||
* shows progress + a terminal state, mirroring the local apply flow. */
|
||||
async function applyBackendUpdate(): Promise<DesktopUpdateApplyResult> {
|
||||
export async function applyBackendUpdate(): Promise<DesktopUpdateApplyResult> {
|
||||
dismissNotification(UPDATE_TOAST_ID)
|
||||
$updateApply.set({ ...IDLE, applying: true, stage: 'prepare', message: 'Updating backend…' })
|
||||
$backendUpdateApply.set({ ...IDLE, applying: true, stage: 'prepare', message: 'Updating backend…' })
|
||||
|
||||
try {
|
||||
const started = await updateHermes()
|
||||
|
||||
// updateHermes returns ok:false for non-applyable installs (e.g. docker)
|
||||
// with guidance in the response; surface it as a manual state.
|
||||
if (!started.ok) {
|
||||
const message = (started as { message?: string }).message || 'Update not available for this backend.'
|
||||
const command = (started as { update_command?: string }).update_command || 'hermes update'
|
||||
$updateApply.set({ ...IDLE, applying: false, stage: 'manual', message, command })
|
||||
$backendUpdateApply.set({ ...IDLE, applying: false, stage: 'manual', message, command })
|
||||
|
||||
return { ok: false, error: 'manual', manual: true, message, command }
|
||||
}
|
||||
|
||||
$updateApply.set({ ...IDLE, applying: true, stage: 'pull', message: 'Backend updating…' })
|
||||
$backendUpdateApply.set({ ...IDLE, applying: true, stage: 'pull', message: 'Backend updating…' })
|
||||
|
||||
// Poll the action until it stops running (cap the wait — the dashboard
|
||||
// restarts mid-update, which drops this connection; that's expected).
|
||||
let last: Awaited<ReturnType<typeof getActionStatus>> | null = null
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 1500))
|
||||
try {
|
||||
last = await getActionStatus(started.name, 200)
|
||||
} catch {
|
||||
// Connection dropped — most likely the backend restarted to load the
|
||||
// new code. Treat as the (expected) restart phase, not a failure.
|
||||
$updateApply.set({
|
||||
...$updateApply.get(),
|
||||
// The dashboard restarts mid-update, dropping this connection — expected, not a failure.
|
||||
$backendUpdateApply.set({
|
||||
...$backendUpdateApply.get(),
|
||||
applying: false,
|
||||
stage: 'restart',
|
||||
message: 'Backend restarting to load the update…'
|
||||
|
|
@ -368,8 +344,8 @@ async function applyBackendUpdate(): Promise<DesktopUpdateApplyResult> {
|
|||
}
|
||||
|
||||
const ok = !!last && (last.exit_code ?? 1) === 0
|
||||
$updateApply.set({
|
||||
...$updateApply.get(),
|
||||
$backendUpdateApply.set({
|
||||
...$backendUpdateApply.get(),
|
||||
applying: false,
|
||||
stage: ok ? 'restart' : 'error',
|
||||
error: ok ? null : 'apply-failed',
|
||||
|
|
@ -381,7 +357,7 @@ async function applyBackendUpdate(): Promise<DesktopUpdateApplyResult> {
|
|||
: { ok: false, error: 'apply-failed', message: 'Backend update failed.' }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
$updateApply.set({ ...$updateApply.get(), applying: false, stage: 'error', error: 'apply-failed', message })
|
||||
$backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: false, stage: 'error', error: 'apply-failed', message })
|
||||
|
||||
return { ok: false, error: 'apply-failed', message }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue