mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge pull request #73884 from NousResearch/bb/cmdk-update
fix(desktop): make the ⌘K Update Hermes command actually update
This commit is contained in:
commit
8b2d810956
7 changed files with 395 additions and 65 deletions
|
|
@ -49,6 +49,7 @@ import {
|
|||
} from '@/lib/icons'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { resolveVersionStatus } from '@/lib/version-status'
|
||||
import { $repoWorktrees } from '@/store/coding-status'
|
||||
import {
|
||||
$commandPaletteOpen,
|
||||
|
|
@ -59,8 +60,16 @@ import {
|
|||
import { $bindings } from '@/store/keybinds'
|
||||
import { openPetGenerate } from '@/store/pet-generate'
|
||||
import { requestStartWorkSession } from '@/store/projects'
|
||||
import { $connection } from '@/store/session'
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
import { applyBackendUpdate } from '@/store/updates'
|
||||
import {
|
||||
$backendUpdateApply,
|
||||
$backendUpdateStatus,
|
||||
$desktopVersion,
|
||||
$updateApply,
|
||||
$updateStatus,
|
||||
requestActiveUpdate
|
||||
} from '@/store/updates'
|
||||
import { canOpenNewWindow, openNewWindow } from '@/store/windows'
|
||||
import { luminance } from '@/themes/color'
|
||||
import { type ThemeMode, useTheme } from '@/themes/context'
|
||||
|
|
@ -93,6 +102,8 @@ interface PaletteItem {
|
|||
action?: string
|
||||
/** Renders a trailing check: this row IS the current setting (theme, mode). */
|
||||
active?: boolean
|
||||
/** Muted text beside the label — state the row acts on (a version, a count). */
|
||||
detail?: string
|
||||
icon: IconComponent
|
||||
id: string
|
||||
/** Keep the palette open after running (live-preview pickers like theme/mode). */
|
||||
|
|
@ -242,6 +253,7 @@ const PaletteRow = memo(function PaletteRow({
|
|||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.detail && <span className="truncate text-muted-foreground/80">{item.detail}</span>}
|
||||
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
|
||||
{item.to && <ChevronRight className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')} />}
|
||||
{item.active && <Check className={cn('size-3.5 shrink-0 text-primary', !combo && !item.to && 'ml-auto')} />}
|
||||
|
|
@ -345,6 +357,35 @@ export function CommandPalette() {
|
|||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState<string | null>(null)
|
||||
|
||||
// The Update row names the same install the statusbar names — same target
|
||||
// selection, same resolver. Reduced to the label string: an in-flight apply
|
||||
// rewrites these stores on every progress line, and only a changed string
|
||||
// should rebuild the palette's groups.
|
||||
const connection = useStore($connection)
|
||||
const desktopVersion = useStore($desktopVersion)
|
||||
const clientStatus = useStore($updateStatus)
|
||||
const clientApply = useStore($updateApply)
|
||||
const backendStatus = useStore($backendUpdateStatus)
|
||||
const backendApply = useStore($backendUpdateApply)
|
||||
|
||||
const updateVersionLabel = useMemo(() => {
|
||||
const backend = connection?.mode === 'remote'
|
||||
const apply = backend ? backendApply : clientApply
|
||||
const status = backend ? backendStatus : clientStatus
|
||||
|
||||
return resolveVersionStatus({
|
||||
applying: apply.applying || apply.stage === 'restart',
|
||||
behind: status?.behind ?? 0,
|
||||
copy: t.shell.statusbar,
|
||||
remote: backend,
|
||||
restarting: apply.stage === 'restart',
|
||||
sha: status?.currentSha?.slice(0, 7) ?? null,
|
||||
target: backend ? 'backend' : 'client',
|
||||
updateAvailable: status?.updateAvailable,
|
||||
version: backend ? status?.currentVersion : desktopVersion?.appVersion
|
||||
}).label
|
||||
}, [backendApply, backendStatus, clientApply, clientStatus, connection?.mode, desktopVersion?.appVersion, t])
|
||||
|
||||
// cmdk's onSelect doesn't forward the triggering event — keep the last
|
||||
// click/keydown modifiers so session rows can honour ⌘-Enter / ⌘-click.
|
||||
const lastSelectMods = useRef<{ ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }>({
|
||||
|
|
@ -582,11 +623,12 @@ export function CommandPalette() {
|
|||
run: () => void runGatewayRestart()
|
||||
},
|
||||
{
|
||||
detail: updateVersionLabel,
|
||||
icon: Download,
|
||||
id: 'cc-update-hermes',
|
||||
keywords: ['update', 'upgrade', 'hermes', 'version', 'system', 'restart'],
|
||||
label: cc.updateHermes,
|
||||
run: () => void applyBackendUpdate()
|
||||
run: () => requestActiveUpdate()
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -663,7 +705,7 @@ export function CommandPalette() {
|
|||
]
|
||||
: [])
|
||||
]
|
||||
}, [contributedItems, go, settingsSectionLabel, t, worktrees])
|
||||
}, [contributedItems, go, settingsSectionLabel, t, updateVersionLabel, worktrees])
|
||||
|
||||
// The long, granular lists (settings fields, API keys, MCP servers, archived
|
||||
// chats) only surface once the user types — otherwise they'd bury the
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
|||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { resolveVersionStatus } from '@/lib/version-status'
|
||||
import { copyFilePath, revealFile } from '@/store/file-actions'
|
||||
import { revealFileInTree } from '@/store/layout'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
|
|
@ -218,42 +219,33 @@ export function useStatusbarItems({
|
|||
: 'text-destructive hover:text-destructive'
|
||||
|
||||
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'
|
||||
|
||||
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
|
||||
? `${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),
|
||||
sha && copy.commit(sha),
|
||||
updateStatus?.branch && copy.branch(updateStatus.branch)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
const status = resolveVersionStatus({
|
||||
applying,
|
||||
applyMessage: updateApply.message,
|
||||
behind: updateStatus?.behind ?? 0,
|
||||
branch: updateStatus?.branch,
|
||||
copy,
|
||||
remote: connection?.mode === 'remote',
|
||||
restarting: updateApply.stage === 'restart',
|
||||
sha: updateStatus?.currentSha?.slice(0, 7) ?? null,
|
||||
target: 'client',
|
||||
version: desktopVersion?.appVersion
|
||||
})
|
||||
|
||||
return {
|
||||
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
|
||||
detail: appVersion && sha && !applying && !remote ? sha : undefined,
|
||||
hidden: !appVersion && !sha,
|
||||
className: status.hasUpdate ? 'text-primary hover:text-primary' : undefined,
|
||||
detail: status.detail,
|
||||
hidden: status.unknown,
|
||||
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
|
||||
id: 'version-client',
|
||||
label,
|
||||
label: status.label,
|
||||
// Update state is not a preference: hiding it is how a user misses that
|
||||
// their client is behind. Listed in the menu, but locked on.
|
||||
lockedVisible: true,
|
||||
onSelect: () => openUpdateOverlayFor('client'),
|
||||
title: tooltip || undefined,
|
||||
title: status.tooltip,
|
||||
toggleLabel: copy.toggleVersion,
|
||||
variant: 'action'
|
||||
}
|
||||
|
|
@ -274,38 +266,29 @@ export function useStatusbarItems({
|
|||
return null
|
||||
}
|
||||
|
||||
const backendVersion = statusSnapshot?.version
|
||||
const behind = backendUpdateStatus?.behind ?? 0
|
||||
const updateAvailable = backendUpdateStatus?.updateAvailable || behind > 0
|
||||
const applying = backendUpdateApply.applying || backendUpdateApply.stage === 'restart'
|
||||
|
||||
const base = copy.backendLabel(backendVersion ?? copy.unknown)
|
||||
|
||||
const behindHint =
|
||||
!applying && behind > 0 ? ` (+${behind})` : !applying && updateAvailable ? ` (${copy.update})` : ''
|
||||
|
||||
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'),
|
||||
!applying && behind <= 0 && updateAvailable && copy.update,
|
||||
backendVersion && copy.backendVersion(backendVersion)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
const status = resolveVersionStatus({
|
||||
applying,
|
||||
applyMessage: backendUpdateApply.message,
|
||||
behind: backendUpdateStatus?.behind ?? 0,
|
||||
copy,
|
||||
remote: true,
|
||||
restarting: backendUpdateApply.stage === 'restart',
|
||||
target: 'backend',
|
||||
updateAvailable: backendUpdateStatus?.updateAvailable,
|
||||
version: statusSnapshot?.version
|
||||
})
|
||||
|
||||
return {
|
||||
className: !applying && updateAvailable ? 'text-primary hover:text-primary' : undefined,
|
||||
hidden: !backendVersion,
|
||||
className: status.hasUpdate ? 'text-primary hover:text-primary' : undefined,
|
||||
hidden: status.unknown,
|
||||
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
|
||||
id: 'version-backend',
|
||||
label,
|
||||
label: status.label,
|
||||
lockedVisible: true,
|
||||
onSelect: () => openUpdateOverlayFor('backend'),
|
||||
title: tooltip || undefined,
|
||||
title: status.tooltip,
|
||||
toggleLabel: copy.toggleBackendVersion,
|
||||
variant: 'action'
|
||||
}
|
||||
|
|
|
|||
2
apps/desktop/src/global.d.ts
vendored
2
apps/desktop/src/global.d.ts
vendored
|
|
@ -373,6 +373,8 @@ export interface DesktopUpdateStatus {
|
|||
error?: string
|
||||
behind?: number
|
||||
currentSha?: string
|
||||
/** Backend only: the version string the backend reports for itself. */
|
||||
currentVersion?: string
|
||||
targetSha?: string
|
||||
commits?: DesktopUpdateCommit[]
|
||||
dirty?: boolean
|
||||
|
|
|
|||
85
apps/desktop/src/lib/version-status.test.ts
Normal file
85
apps/desktop/src/lib/version-status.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { en } from '@/i18n/en'
|
||||
|
||||
import { resolveVersionStatus } from './version-status'
|
||||
|
||||
const copy = en.shell.statusbar
|
||||
|
||||
const client = (over: Partial<Parameters<typeof resolveVersionStatus>[0]> = {}) =>
|
||||
resolveVersionStatus({ applying: false, copy, remote: false, restarting: false, target: 'client', ...over })
|
||||
|
||||
const backend = (over: Partial<Parameters<typeof resolveVersionStatus>[0]> = {}) =>
|
||||
resolveVersionStatus({ applying: false, copy, remote: true, restarting: false, target: 'backend', ...over })
|
||||
|
||||
describe('resolveVersionStatus', () => {
|
||||
it('labels a current local client with its version and sha detail', () => {
|
||||
const status = client({ sha: 'abc1234', version: '0.4.2' })
|
||||
|
||||
expect(status.label).toBe('v0.4.2')
|
||||
expect(status.detail).toBe('abc1234')
|
||||
expect(status.hasUpdate).toBe(false)
|
||||
expect(status.unknown).toBe(false)
|
||||
})
|
||||
|
||||
it('appends the commit diff when the client is behind', () => {
|
||||
const status = client({ behind: 12, branch: 'main', version: '0.4.2' })
|
||||
|
||||
expect(status.label).toBe('v0.4.2 (+12)')
|
||||
expect(status.hasUpdate).toBe(true)
|
||||
expect(status.tooltip).toContain('12 commits behind main')
|
||||
})
|
||||
|
||||
it('names the client as one of two versions in remote mode', () => {
|
||||
expect(client({ remote: true, version: '0.4.2' }).label).toBe('client v0.4.2')
|
||||
})
|
||||
|
||||
it('falls back to the sha, then to unknown, when there is no version', () => {
|
||||
expect(client({ sha: 'abc1234' }).label).toBe('abc1234')
|
||||
expect(client({ sha: 'abc1234' }).unknown).toBe(false)
|
||||
expect(client().label).toBe(copy.unknown)
|
||||
expect(client().unknown).toBe(true)
|
||||
})
|
||||
|
||||
it('drops the diff and the sha detail while an apply is in flight', () => {
|
||||
const applying = client({ applying: true, behind: 3, sha: 'abc1234', version: '0.4.2' })
|
||||
|
||||
expect(applying.label).toBe('v0.4.2 · update')
|
||||
expect(applying.detail).toBeUndefined()
|
||||
expect(applying.hasUpdate).toBe(false)
|
||||
|
||||
expect(client({ applying: true, restarting: true, version: '0.4.2' }).label).toBe('v0.4.2 · restart')
|
||||
})
|
||||
|
||||
it('leads the tooltip with the apply message while applying', () => {
|
||||
expect(client({ applyMessage: 'Pulling…', applying: true, version: '0.4.2' }).tooltip).toBe(
|
||||
'Pulling… · Hermes Desktop v0.4.2'
|
||||
)
|
||||
expect(client({ applying: true, version: '0.4.2' }).tooltip).toBe(
|
||||
`${copy.updateInProgress} · Hermes Desktop v0.4.2`
|
||||
)
|
||||
})
|
||||
|
||||
it('labels the backend target distinctly and never claims a client sha', () => {
|
||||
const status = backend({ sha: 'abc1234', version: '0.4.2' })
|
||||
|
||||
expect(status.label).toBe('backend v0.4.2')
|
||||
expect(status.detail).toBeUndefined()
|
||||
expect(status.tooltip).toBe('Backend v0.4.2')
|
||||
})
|
||||
|
||||
it('falls back to (update) for a backend that cannot count commits', () => {
|
||||
const status = backend({ updateAvailable: true, version: '0.4.2' })
|
||||
|
||||
expect(status.label).toBe('backend v0.4.2 (update)')
|
||||
expect(status.hasUpdate).toBe(true)
|
||||
})
|
||||
|
||||
it('prefers the exact commit diff over the generic (update) hint', () => {
|
||||
expect(backend({ behind: 4, updateAvailable: true, version: '0.4.2' }).label).toBe('backend v0.4.2 (+4)')
|
||||
})
|
||||
|
||||
it('hides a backend row that has no version at all', () => {
|
||||
expect(backend().unknown).toBe(true)
|
||||
})
|
||||
})
|
||||
106
apps/desktop/src/lib/version-status.ts
Normal file
106
apps/desktop/src/lib/version-status.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Pure derivation of how the app names an update target: the label
|
||||
* (`v0.4.2`, `backend v0.4.2 (+12)`, `v0.4.2 · update`), its tooltip, and
|
||||
* whether an update is waiting.
|
||||
*
|
||||
* The statusbar and the command palette both name the same two targets, so the
|
||||
* wording lives here once — a palette row and its statusbar item can't drift
|
||||
* into describing the same install differently.
|
||||
*/
|
||||
|
||||
import type { UpdateTarget } from '@/lib/update-copy'
|
||||
|
||||
export interface VersionStatusCopy {
|
||||
backendLabel: (version: string) => string
|
||||
backendVersion: (version: string) => string
|
||||
branch: (branch: string) => string
|
||||
clientLabel: (version: string) => string
|
||||
commit: (sha: string) => string
|
||||
commitsBehind: (count: number, branch: string) => string
|
||||
desktopVersion: (version: string) => string
|
||||
restart: string
|
||||
unknown: string
|
||||
update: string
|
||||
updateInProgress: string
|
||||
}
|
||||
|
||||
export interface VersionStatusInput {
|
||||
/** True while an apply is in flight (including the restart hand-off). */
|
||||
applying: boolean
|
||||
/** Latest line from the apply stream — leads the tooltip while applying. */
|
||||
applyMessage?: string
|
||||
behind?: number
|
||||
branch?: string
|
||||
copy: VersionStatusCopy
|
||||
/** Remote mode: the client is one of two versions on screen, so it says so. */
|
||||
remote: boolean
|
||||
/** The apply reached the restart stage — labels `restart`, not `update`. */
|
||||
restarting: boolean
|
||||
/** Client only: short commit sha of the running build. */
|
||||
sha?: null | string
|
||||
target: UpdateTarget
|
||||
/** Backend only: an update the commit count can't express (pip installs). */
|
||||
updateAvailable?: boolean
|
||||
version?: null | string
|
||||
}
|
||||
|
||||
export interface VersionStatusResult {
|
||||
/** Secondary text beside the label — the commit sha, when it adds anything. */
|
||||
detail?: string
|
||||
/** An update is waiting: callers tint the row with it. */
|
||||
hasUpdate: boolean
|
||||
label: string
|
||||
tooltip?: string
|
||||
/** Nothing identifies this target yet — callers hide the row. */
|
||||
unknown: boolean
|
||||
}
|
||||
|
||||
export function resolveVersionStatus({
|
||||
applyMessage,
|
||||
applying,
|
||||
behind = 0,
|
||||
branch,
|
||||
copy,
|
||||
remote,
|
||||
restarting,
|
||||
sha = null,
|
||||
target,
|
||||
updateAvailable,
|
||||
version = null
|
||||
}: VersionStatusInput): VersionStatusResult {
|
||||
const client = target === 'client'
|
||||
const busy = applying || restarting
|
||||
const available = behind > 0 || (!client && !!updateAvailable)
|
||||
|
||||
// A client with no version still identifies itself by sha; a backend can't.
|
||||
const named = version ?? (client ? sha : null) ?? copy.unknown
|
||||
|
||||
const base = !client
|
||||
? copy.backendLabel(named)
|
||||
: remote
|
||||
? copy.clientLabel(named)
|
||||
: (version && `v${version}`) || named
|
||||
|
||||
// Commits behind is the precise diff; `(update)` is the fallback for a
|
||||
// backend that knows it's stale but can't count (pip, non-git checkout).
|
||||
const hint = busy ? '' : behind > 0 ? ` (+${behind})` : available ? ` (${copy.update})` : ''
|
||||
|
||||
const tooltip = [
|
||||
busy && (applyMessage || copy.updateInProgress),
|
||||
!busy && behind > 0 && copy.commitsBehind(behind, (client ? branch : 'main') || '...'),
|
||||
!busy && behind <= 0 && available && copy.update,
|
||||
version && (client ? copy.desktopVersion(version) : copy.backendVersion(version)),
|
||||
client && sha && copy.commit(sha),
|
||||
client && branch && copy.branch(branch)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return {
|
||||
detail: client && version && sha && !busy && !remote ? sha : undefined,
|
||||
hasUpdate: !busy && available,
|
||||
label: busy ? `${base} · ${restarting ? copy.restart : copy.update}` : `${base}${hint}`,
|
||||
tooltip: tooltip || undefined,
|
||||
unknown: !version && !(client && sha)
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,8 @@ const {
|
|||
applyUpdates,
|
||||
$updateApply,
|
||||
$updateOverlayOpen,
|
||||
$updateOverlayTarget,
|
||||
requestActiveUpdate,
|
||||
resetUpdateApplyState,
|
||||
startUpdatePoller,
|
||||
stopUpdatePoller,
|
||||
|
|
@ -69,6 +71,18 @@ const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus =>
|
|||
|
||||
const lastToast = () => notifySpy.mock.calls.at(-1)?.[0] as { onDismiss: () => void }
|
||||
|
||||
const setRemote = (on: boolean) =>
|
||||
setConnection({
|
||||
baseUrl: 'http://box:9119',
|
||||
isFullscreen: false,
|
||||
mode: on ? 'remote' : 'local',
|
||||
nativeOverlayWidth: 0,
|
||||
token: 't',
|
||||
wsUrl: 'ws://box:9119',
|
||||
logs: [],
|
||||
windowButtonPosition: null
|
||||
})
|
||||
|
||||
describe('maybeNotifyUpdateAvailable', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear()
|
||||
|
|
@ -175,18 +189,6 @@ describe('checkBackendUpdates', () => {
|
|||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const setRemote = (on: boolean) =>
|
||||
setConnection({
|
||||
baseUrl: 'http://box:9119',
|
||||
isFullscreen: false,
|
||||
mode: on ? 'remote' : 'local',
|
||||
nativeOverlayWidth: 0,
|
||||
token: 't',
|
||||
wsUrl: 'ws://box:9119',
|
||||
logs: [],
|
||||
windowButtonPosition: null
|
||||
})
|
||||
|
||||
it('maps the backend /update/check onto the backend status, including commits', async () => {
|
||||
setRemote(true)
|
||||
checkHermesUpdateSpy.mockResolvedValue({
|
||||
|
|
@ -254,6 +256,96 @@ describe('checkBackendUpdates', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// The ⌘K "Update Hermes" row. It used to call applyBackendUpdate() flat, which
|
||||
// in local mode aimed at the backend checkout instead of the client and, with
|
||||
// no overlay open, showed nothing at all.
|
||||
describe('requestActiveUpdate', () => {
|
||||
const applyClientMock = vi.fn()
|
||||
const checkClientMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
storage.clear()
|
||||
notifySpy.mockClear()
|
||||
dismissSpy.mockClear()
|
||||
applyClientMock.mockReset().mockResolvedValue({ ok: true, handedOff: true })
|
||||
checkClientMock.mockReset().mockResolvedValue(status({ behind: 0 }))
|
||||
updateHermesSpy.mockReset().mockResolvedValue({ ok: true, name: 'update' })
|
||||
checkHermesUpdateSpy.mockReset().mockResolvedValue({
|
||||
install_method: 'git',
|
||||
current_version: '0.4.2',
|
||||
behind: 0,
|
||||
update_available: false,
|
||||
can_apply: true,
|
||||
update_command: null,
|
||||
message: null
|
||||
})
|
||||
getActionStatusSpy.mockReset().mockResolvedValue({ lines: [], running: false, exit_code: 0 })
|
||||
resetUpdateApplyState()
|
||||
$updateStatus.set(null)
|
||||
$backendUpdateStatus.set(null)
|
||||
$updateOverlayOpen.set(false)
|
||||
;(globalThis as unknown as { window: unknown }).window = {
|
||||
hermesDesktop: { updates: { apply: applyClientMock, check: checkClientMock } }
|
||||
}
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setRemote(false)
|
||||
delete (globalThis as unknown as { window?: unknown }).window
|
||||
})
|
||||
|
||||
it('applies the CLIENT update in local mode, never the backend', async () => {
|
||||
setRemote(false)
|
||||
$updateStatus.set(status({ behind: 3 }))
|
||||
|
||||
requestActiveUpdate()
|
||||
await vi.waitFor(() => expect(applyClientMock).toHaveBeenCalled())
|
||||
|
||||
expect(updateHermesSpy).not.toHaveBeenCalled()
|
||||
expect($updateOverlayTarget.get()).toBe('client')
|
||||
})
|
||||
|
||||
it('applies the BACKEND update in remote mode', async () => {
|
||||
setRemote(true)
|
||||
$backendUpdateStatus.set(status({ behind: 3 }))
|
||||
|
||||
requestActiveUpdate()
|
||||
await vi.waitFor(() => expect(updateHermesSpy).toHaveBeenCalled())
|
||||
|
||||
expect(applyClientMock).not.toHaveBeenCalled()
|
||||
expect($updateOverlayTarget.get()).toBe('backend')
|
||||
})
|
||||
|
||||
it('always opens the overlay, so selecting the row is never a silent no-op', () => {
|
||||
setRemote(false)
|
||||
$updateStatus.set(status({ behind: 3 }))
|
||||
|
||||
requestActiveUpdate()
|
||||
|
||||
expect($updateOverlayOpen.get()).toBe(true)
|
||||
})
|
||||
|
||||
it('opens the overlay to re-check instead of applying when already current', () => {
|
||||
setRemote(false)
|
||||
$updateStatus.set(status({ behind: 0, updateAvailable: false }))
|
||||
|
||||
requestActiveUpdate()
|
||||
|
||||
expect($updateOverlayOpen.get()).toBe(true)
|
||||
expect(applyClientMock).not.toHaveBeenCalled()
|
||||
expect(updateHermesSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies on a backend that reports an update it cannot count commits for', async () => {
|
||||
setRemote(true)
|
||||
$backendUpdateStatus.set(status({ behind: 0, updateAvailable: true }))
|
||||
|
||||
requestActiveUpdate()
|
||||
await vi.waitFor(() => expect(updateHermesSpy).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyUpdates terminal state', () => {
|
||||
const applyMock = vi.fn()
|
||||
|
||||
|
|
|
|||
|
|
@ -254,6 +254,25 @@ export function startActiveUpdate(): void {
|
|||
void (target === 'backend' ? applyBackendUpdate() : applyUpdates())
|
||||
}
|
||||
|
||||
/**
|
||||
* Command-palette entry point. The About panel's "Update now" only renders once
|
||||
* we know an update is waiting; this row is always listed, so it also has to
|
||||
* handle "already current" — open the overlay for the active target and let its
|
||||
* check answer, and only apply when there's something to install.
|
||||
*/
|
||||
export function requestActiveUpdate(): void {
|
||||
const target: UpdateTarget = isRemoteMode() ? 'backend' : 'client'
|
||||
const status = target === 'backend' ? $backendUpdateStatus.get() : $updateStatus.get()
|
||||
|
||||
if ((status?.behind ?? 0) > 0 || status?.updateAvailable) {
|
||||
startActiveUpdate()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
openUpdateOverlayFor(target)
|
||||
}
|
||||
|
||||
/** Re-read the running app's version from the Electron main process and
|
||||
* publish it on `$desktopVersion`. Called when the About panel mounts, the
|
||||
* update flow finishes, and the window regains focus, so the About text
|
||||
|
|
@ -294,6 +313,7 @@ function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateStatus {
|
|||
message: res.message ?? undefined,
|
||||
updateAvailable: res.update_available,
|
||||
behind: behind > 0 ? behind : 0,
|
||||
currentVersion: res.current_version,
|
||||
targetSha: res.update_available ? `backend:${res.current_version}` : undefined,
|
||||
commits: res.commits,
|
||||
fetchedAt: Date.now()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue