Merge pull request #73884 from NousResearch/bb/cmdk-update

fix(desktop): make the ⌘K Update Hermes command actually update
This commit is contained in:
brooklyn! 2026-07-29 01:41:16 -05:00 committed by GitHub
commit 8b2d810956
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 395 additions and 65 deletions

View file

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

View file

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

View file

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

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

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

View file

@ -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()

View file

@ -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()