diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 0014e4e71f12..59f8ba0cbae3 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -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({ > {item.label} + {item.detail && {item.detail}} {combo && } {item.to && } {item.active && } @@ -345,6 +357,35 @@ export function CommandPalette() { const [search, setSearch] = useState('') const [page, setPage] = useState(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 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 fb2aac6d2daa..0aa3c69fe3b6 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -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(() => { - 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 ? : , 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 ? : , id: 'version-backend', - label, + label: status.label, lockedVisible: true, onSelect: () => openUpdateOverlayFor('backend'), - title: tooltip || undefined, + title: status.tooltip, toggleLabel: copy.toggleBackendVersion, variant: 'action' } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 336f95b54ae2..a53965a63b59 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 diff --git a/apps/desktop/src/lib/version-status.test.ts b/apps/desktop/src/lib/version-status.test.ts new file mode 100644 index 000000000000..54a4bb2d5760 --- /dev/null +++ b/apps/desktop/src/lib/version-status.test.ts @@ -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[0]> = {}) => + resolveVersionStatus({ applying: false, copy, remote: false, restarting: false, target: 'client', ...over }) + +const backend = (over: Partial[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) + }) +}) diff --git a/apps/desktop/src/lib/version-status.ts b/apps/desktop/src/lib/version-status.ts new file mode 100644 index 000000000000..030464e8ecac --- /dev/null +++ b/apps/desktop/src/lib/version-status.ts @@ -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) + } +} diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts index 7b19ba15f459..9dd1cbed6a38 100644 --- a/apps/desktop/src/store/updates.test.ts +++ b/apps/desktop/src/store/updates.test.ts @@ -51,6 +51,8 @@ const { applyUpdates, $updateApply, $updateOverlayOpen, + $updateOverlayTarget, + requestActiveUpdate, resetUpdateApplyState, startUpdatePoller, stopUpdatePoller, @@ -69,6 +71,18 @@ const status = (over: Partial = {}): 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() diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index 1f0998a5e069..0b46920e30d2 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -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()