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 8f92dbfcc6e..4f2df6f9bf3 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -231,10 +231,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}` @@ -243,13 +245,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', @@ -262,6 +265,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 a42a6d9f482..1ed9e327f66 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' @@ -119,6 +120,7 @@ export function UpdatesOverlay() { onRetryCheck={() => void check()} status={status} target={target} + updateAvailable={updateAvailable} /> )} @@ -134,7 +136,8 @@ function IdleView({ onLater, onRetryCheck, status, - target + target, + updateAvailable }: { behind: number checking: boolean @@ -144,6 +147,7 @@ function IdleView({ onRetryCheck: () => void status: DesktopUpdateStatus | null target: UpdateTarget + updateAvailable: boolean }) { const { t } = useI18n() const u = t.updates @@ -196,7 +200,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 97a0fadb14b..eb70afcb342 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -249,6 +249,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