diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
index 3d1f27eb205..eb0fe8bee25 100644
--- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
+++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
@@ -241,10 +241,12 @@ export function useStatusbarItems({
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})` : ''
+ const behindHint =
+ !applying && behind > 0 ? ` (+${behind})` : !applying && updateAvailable ? ` (${copy.update})` : ''
const label = applying
? `${base} · ${backendUpdateApply.stage === 'restart' ? copy.restart : copy.update}`
@@ -253,13 +255,14 @@ export function useStatusbarItems({
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(' · ')
return {
- className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
+ className: !applying && updateAvailable ? 'text-primary hover:text-primary' : undefined,
hidden: !backendVersion,
icon: applying ? : ,
id: 'version-backend',
@@ -272,6 +275,7 @@ export function useStatusbarItems({
connection?.mode,
statusSnapshot?.version,
backendUpdateStatus?.behind,
+ backendUpdateStatus?.updateAvailable,
backendUpdateApply.applying,
backendUpdateApply.message,
backendUpdateApply.stage,
diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx
index b4c0f30cebc..6ec059ded1a 100644
--- a/apps/desktop/src/app/updates-overlay.tsx
+++ b/apps/desktop/src/app/updates-overlay.tsx
@@ -60,6 +60,7 @@ export function UpdatesOverlay() {
}, [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'
@@ -124,6 +125,7 @@ export function UpdatesOverlay() {
onRetryCheck={() => void check()}
status={status}
target={target}
+ updateAvailable={updateAvailable}
/>
)}
@@ -139,7 +141,8 @@ function IdleView({
onLater,
onRetryCheck,
status,
- target
+ target,
+ updateAvailable
}: {
behind: number
checking: boolean
@@ -149,6 +152,7 @@ function IdleView({
onRetryCheck: () => void
status: DesktopUpdateStatus | null
target: UpdateTarget
+ updateAvailable: boolean
}) {
const { t } = useI18n()
const u = t.updates
@@ -198,7 +202,7 @@ function IdleView({
)
}
- if (behind === 0) {
+ if (!updateAvailable) {
return (
{
expect(checkHermesUpdateSpy).toHaveBeenCalled()
expect(result?.behind).toBe(2)
+ expect(result?.updateAvailable).toBe(true)
expect(result?.commits?.[0]?.sha).toBe('abc1234')
expect(result?.supported).toBe(true)
expect($backendUpdateStatus.get()?.commits?.[0]?.summary).toBe('feat: x')
})
+ it('preserves backend update_available when the backend cannot count commits', async () => {
+ setRemote(true)
+ checkHermesUpdateSpy.mockResolvedValue({
+ install_method: 'nixos',
+ current_version: '0.16.0',
+ behind: -1,
+ update_available: true,
+ can_apply: false,
+ update_command: 'managed outside dashboard',
+ message: 'Update available.'
+ })
+
+ const result = await checkBackendUpdates()
+
+ expect(result?.behind).toBe(0)
+ expect(result?.updateAvailable).toBe(true)
+ expect(result?.targetSha).toBe('backend:0.16.0')
+ })
+
it('honours can_apply=false (docker/nix): not supported, carries message', async () => {
setRemote(true)
checkHermesUpdateSpy.mockResolvedValue({
diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts
index 86cf75b4a9b..6591c81bb72 100644
--- a/apps/desktop/src/store/updates.ts
+++ b/apps/desktop/src/store/updates.ts
@@ -247,6 +247,7 @@ function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateStatus {
return {
supported: res.can_apply,
message: res.message ?? undefined,
+ updateAvailable: res.update_available,
behind: behind > 0 ? behind : 0,
targetSha: res.update_available ? `backend:${res.current_version}` : undefined,
commits: res.commits,
diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py
index 68d33e43fdb..df127c54b8a 100644
--- a/hermes_cli/banner.py
+++ b/hermes_cli/banner.py
@@ -197,7 +197,10 @@ def _check_via_local_git(repo_dir: Path) -> Optional[int]:
origin_url = _git_stdout(["remote", "get-url", "origin"], cwd=repo_dir)
if _is_official_ssh_remote(origin_url):
head_rev = _git_stdout(["rev-parse", "HEAD"], cwd=repo_dir)
- return _check_via_rev(head_rev) if head_rev else None
+ checked = _check_via_rev(head_rev) if head_rev else None
+ if checked == UPDATE_AVAILABLE_NO_COUNT:
+ return 1
+ return checked
# Installer checkouts are shallow (`git clone --depth 1`). On a shallow
# clone the history stops at a single commit, so a plain `git fetch` would
diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py
index 66c40a5ab17..84b9e3a6c99 100644
--- a/tests/hermes_cli/test_update_check.py
+++ b/tests/hermes_cli/test_update_check.py
@@ -125,7 +125,7 @@ def test_check_for_updates_official_ssh_origin_uses_https_probe(tmp_path):
with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run):
result = banner._check_via_local_git(repo_dir)
- assert result == banner.UPDATE_AVAILABLE_NO_COUNT
+ assert result == 1
assert ["git", "fetch", "origin", "--quiet"] not in calls