diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx
index f9a640000aba..e367e8058260 100644
--- a/apps/desktop/src/app/command-palette/index.tsx
+++ b/apps/desktop/src/app/command-palette/index.tsx
@@ -605,7 +605,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) {
// reopen paints from cache and revalidates in the background.
const configQuery = useQuery({
queryKey: ['command-palette', 'config'],
- queryFn: getHermesConfigRecord
+ queryFn: () => getHermesConfigRecord()
})
const sessionsQuery = useQuery({
diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx
index da4ff828cb85..b288cd37b6f5 100644
--- a/apps/desktop/src/app/contrib/controller.tsx
+++ b/apps/desktop/src/app/contrib/controller.tsx
@@ -59,7 +59,7 @@ import { $previewOpenRequest, $previewTabs, closeRightRail } from '@/store/previ
import { $reviewOpen, closeReview, openReview, REVIEW_PANE_ID } from '@/store/review'
import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session'
import { watchSessionPins } from '@/store/session-pin-sync'
-import { $statusbarVisible } from '@/store/statusbar-prefs'
+import { $statusbarVisible, toggleStatusbarVisible } from '@/store/statusbar-prefs'
import type { SessionDragPayload } from '../chat/composer/inline-refs'
import { watchRouteTiles } from '../chat/route-tile'
@@ -302,7 +302,7 @@ registry.registerMany([
icon: PanelBottom,
keywords: ['status bar', 'statusbar', 'bottom bar', 'hide', 'show', 'chrome'],
get: () => $statusbarVisible.get(),
- set: enabled => $statusbarVisible.set(enabled)
+ set: () => toggleStatusbarVisible()
}),
// The keybind panel's non-titlebar door (the keyboard icon is gone).
{
diff --git a/apps/desktop/src/app/contrib/surfaces.tsx b/apps/desktop/src/app/contrib/surfaces.tsx
index 750248fbfebf..16441e8af4b9 100644
--- a/apps/desktop/src/app/contrib/surfaces.tsx
+++ b/apps/desktop/src/app/contrib/surfaces.tsx
@@ -13,6 +13,7 @@ import { Navigate, Route, Routes, useParams } from 'react-router'
import { ContribBoundary } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
+import { $desktopStatusbarMode } from '@/store/desktop-statusbar'
import { $activeGatewayProfile } from '@/store/profile'
import { $freshDraftReady, $gatewayState } from '@/store/session'
@@ -76,6 +77,7 @@ export const StatusbarSurface = memo(function StatusbarSurface({
chatOpen: boolean
commandCenterOpen: boolean
}) {
+ const desktopStatusbarMode = useStore($desktopStatusbarMode)
const gatewayState = useStore($gatewayState)
const freshDraftReady = useStore($freshDraftReady)
const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, actions.requestGateway)
@@ -98,7 +100,7 @@ export const StatusbarSurface = memo(function StatusbarSurface({
toggleCommandCenter: actions.toggleCommandCenter
})
- return
+ return
})
/** The workspace pane: the real route table (chat + full-page views + plugin
diff --git a/apps/desktop/src/app/hooks/use-config-record.ts b/apps/desktop/src/app/hooks/use-config-record.ts
index ca4f00cb2a59..d5a3a72a32e8 100644
--- a/apps/desktop/src/app/hooks/use-config-record.ts
+++ b/apps/desktop/src/app/hooks/use-config-record.ts
@@ -1,8 +1,9 @@
import { useQuery } from '@tanstack/react-query'
import { getHermesConfigRecord } from '@/hermes'
-import { queryClient, writeCache } from '@/lib/query-client'
-import type { HermesConfigRecord } from '@/types/hermes'
+import { HERMES_CONFIG_KEY } from '@/store/hermes-config-record'
+
+export { invalidateHermesConfig, setHermesConfigCache } from '@/store/hermes-config-record'
// One shared cache for the whole profile config record (`GET /api/config`).
// Every settings surface (MCP, model, config) reads and writes through this key
@@ -11,12 +12,6 @@ import type { HermesConfigRecord } from '@/types/hermes'
//
// Distinct from session/hooks/use-hermes-config.ts, which is side-effecting —
// it pushes personality/cwd/voice/… into the session stores for live chat.
-export const HERMES_CONFIG_KEY = ['hermes-config-record'] as const
-
// staleTime 0 → serve cache instantly, background-revalidate on every mount.
export const useHermesConfigRecord = () =>
- useQuery({ queryKey: HERMES_CONFIG_KEY, queryFn: getHermesConfigRecord, staleTime: 0 })
-
-export const setHermesConfigCache = writeCache(HERMES_CONFIG_KEY)
-
-export const invalidateHermesConfig = () => queryClient.invalidateQueries({ queryKey: HERMES_CONFIG_KEY })
+ useQuery({ queryKey: HERMES_CONFIG_KEY, queryFn: () => getHermesConfigRecord(), staleTime: 0 })
diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts
index ca0ca4dc0a45..0b94cd8b47c4 100644
--- a/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts
+++ b/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { $terminalFontFamily, setTerminalFontFamilyFromConfig } from '@/app/right-sidebar/terminal/terminal-font'
import { getHermesConfig } from '@/hermes'
import { persistString } from '@/lib/storage'
+import { $desktopStatusbarMode, applyDesktopStatusbarFromConfig } from '@/store/desktop-statusbar'
import {
$currentCwd,
$currentFastMode,
@@ -21,10 +22,14 @@ import {
import { useHermesConfig } from './use-hermes-config'
vi.mock('@/hermes', () => ({
+ getApiRequestProfile: vi.fn(() => null),
getHermesConfig: vi.fn(),
- getHermesConfigDefaults: vi.fn().mockResolvedValue({})
+ getHermesConfigDefaults: vi.fn().mockResolvedValue({}),
+ getHermesConfigRecord: vi.fn().mockResolvedValue({}),
+ saveHermesConfig: vi.fn().mockResolvedValue({ ok: true })
}))
+const LEGACY_STATUSBAR_VISIBLE_STORAGE_KEY = 'hermes.desktop.statusbarVisible'
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
function deferred() {
@@ -49,6 +54,8 @@ describe('useHermesConfig refreshHermesConfig', () => {
setCurrentReasoningEffort('')
setDefaultReasoningEffort('')
setTerminalFontFamilyFromConfig('')
+ persistString(LEGACY_STATUSBAR_VISIBLE_STORAGE_KEY, null)
+ applyDesktopStatusbarFromConfig({ display: { desktop_statusbar: 'off' } })
persistString(WORKSPACE_CWD_KEY, null)
})
@@ -142,17 +149,50 @@ describe('useHermesConfig refreshHermesConfig', () => {
refreshC = result.current.refreshHermesConfig(true)
})
- profileC.resolve({ agent: { reasoning_effort: 'low', service_tier: 'normal' } })
+ profileC.resolve({
+ agent: { reasoning_effort: 'low', service_tier: 'normal' },
+ display: { desktop_statusbar: 'auto-hide' }
+ })
await act(async () => {
await refreshC
})
- profileB.resolve({ agent: { reasoning_effort: 'high', service_tier: 'priority' } })
+ profileB.resolve({
+ agent: { reasoning_effort: 'high', service_tier: 'priority' },
+ display: { desktop_statusbar: 'on' }
+ })
await act(async () => {
await refreshB
})
expect($currentReasoningEffort.get()).toBe('low')
expect($currentFastMode.get()).toBe(false)
+ expect($desktopStatusbarMode.get()).toBe('auto-hide')
+ })
+
+ it('does not let an older ordinary refresh overwrite a newer status bar value', async () => {
+ const older = deferred>>()
+ const newer = deferred>>()
+ vi.mocked(getHermesConfig).mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise)
+
+ const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } }))
+
+ let olderRefresh!: Promise
+ let newerRefresh!: Promise
+ act(() => {
+ olderRefresh = result.current.refreshHermesConfig()
+ newerRefresh = result.current.refreshHermesConfig()
+ })
+
+ newer.resolve({ display: { desktop_statusbar: 'auto-hide' } })
+ await act(async () => {
+ await newerRefresh
+ })
+ older.resolve({ display: { desktop_statusbar: 'on' } })
+ await act(async () => {
+ await olderRefresh
+ })
+
+ expect($desktopStatusbarMode.get()).toBe('auto-hide')
})
it('loads the profile terminal font for already-mounted terminal surfaces', async () => {
diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.ts
index 1250e4ca0255..c98ae1385f65 100644
--- a/apps/desktop/src/app/session/hooks/use-hermes-config.ts
+++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts
@@ -4,6 +4,7 @@ import { setTerminalFontFamilyFromConfig } from '@/app/right-sidebar/terminal/te
import { getHermesConfig, getHermesConfigDefaults } from '@/hermes'
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime'
import { normalize } from '@/lib/text'
+import { applyDesktopStatusbarFromConfig, migrateLegacyDesktopStatusbarPreference } from '@/store/desktop-statusbar'
import {
getComposerSelectionGeneration,
getCurrentModelSource,
@@ -56,11 +57,7 @@ export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) {
const refreshHermesConfig = useCallback(
async (force = false) => {
- if (force) {
- profileRefreshEpochRef.current += 1
- }
-
- const profileRefreshEpoch = profileRefreshEpochRef.current
+ const profileRefreshEpoch = ++profileRefreshEpochRef.current
const selectionGeneration = getComposerSelectionGeneration()
try {
@@ -110,6 +107,8 @@ export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) {
setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
setSttEnabled(config.stt?.enabled !== false)
setTerminalFontFamilyFromConfig(config.terminal?.font_family)
+ applyDesktopStatusbarFromConfig(config)
+ void migrateLegacyDesktopStatusbarPreference(config).catch(() => undefined)
applyAutoSpeakFromConfig(config)
applyVoiceStopPhraseFromConfig(config)
applyThinkingSoundFromConfig(config)
diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx
index 998f84bb6cfc..de77f459bc40 100644
--- a/apps/desktop/src/app/settings/appearance-settings.tsx
+++ b/apps/desktop/src/app/settings/appearance-settings.tsx
@@ -13,7 +13,13 @@ import { selectableCardClass } from '@/lib/selectable-card'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { $backdrop, setBackdrop } from '@/store/backdrop'
+import {
+ $desktopStatusbarMode,
+ type DesktopStatusbarMode,
+ persistDesktopStatusbarMode
+} from '@/store/desktop-statusbar'
import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent'
+import { notifyError } from '@/store/notifications'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $reactionsEnabled, setReactionsEnabled } from '@/store/reactions-enabled'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
@@ -24,6 +30,8 @@ import { installVscodeThemeFromMarketplace } from '@/themes/install'
import type { DesktopTheme } from '@/themes/types'
import { $marketplaceInstalls, isUserTheme, removeUserTheme } from '@/themes/user-themes'
+import { setHermesConfigCache } from '../hooks/use-config-record'
+
import { MODE_OPTIONS } from './constants'
import { PetSettings } from './pet-settings'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
@@ -244,7 +252,7 @@ function MarketplaceThemeResults({
)
}
-export function AppearanceSettings() {
+export function AppearanceSettings({ onConfigSaved }: { onConfigSaved?: () => void }) {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
@@ -254,6 +262,7 @@ export function AppearanceSettings() {
const translucency = useStore($translucency)
const reactionsEnabled = useStore($reactionsEnabled)
const backdrop = useStore($backdrop)
+ const desktopStatusbarMode = useStore($desktopStatusbarMode)
const installs = useStore($marketplaceInstalls)
const profiles = useStore($profiles)
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
@@ -299,6 +308,12 @@ export function AppearanceSettings() {
const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` }))
+ const statusbarOptions = [
+ { id: 'on', label: a.statusbarOn },
+ { id: 'auto-hide', label: a.statusbarAutoHide },
+ { id: 'off', label: a.statusbarOff }
+ ] as const satisfies readonly { id: DesktopStatusbarMode; label: string }[]
+
const matchedScalePreset = matchUiScalePreset(zoomPercent)
return (
@@ -433,6 +448,26 @@ export function AppearanceSettings() {
+ {
+ triggerHaptic('selection')
+ void persistDesktopStatusbarMode(mode)
+ .then(config => {
+ setHermesConfigCache(config)
+ onConfigSaved?.()
+ })
+ .catch(error => notifyError(error, a.statusbarSaveFailed))
+ }}
+ options={statusbarOptions}
+ value={desktopStatusbarMode}
+ />
+ }
+ description={a.statusbarDesc}
+ title={a.statusbarTitle}
+ />
+
diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts
index 51f9ab9c2dc6..428dc1bdc115 100644
--- a/apps/desktop/src/app/settings/constants.ts
+++ b/apps/desktop/src/app/settings/constants.ts
@@ -249,6 +249,7 @@ export const ENUM_OPTIONS: Record = {
'context.engine': ['compressor', 'default', 'custom'],
// '' = inherit the agent's own effort; the rest is the shared scale.
'delegation.reasoning_effort': ['', ...REASONING_EFFORTS],
+ 'display.desktop_statusbar': ['on', 'off', 'auto-hide'],
// NOTE: memory.provider is intentionally NOT listed here. Its options are
// discovery-driven and served by the backend config schema (merged
// per-request in web_server._schema_with_dynamic_provider_options), so
diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx
index f55503702b48..7c6fd4e1f674 100644
--- a/apps/desktop/src/app/settings/index.tsx
+++ b/apps/desktop/src/app/settings/index.tsx
@@ -300,7 +300,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
{activeView === 'config:appearance' ? (
-
+
) : activeView === 'about' ? (
) : activeView === 'gateway' ? (
diff --git a/apps/desktop/src/app/shell/statusbar-controls.test.tsx b/apps/desktop/src/app/shell/statusbar-controls.test.tsx
new file mode 100644
index 000000000000..da8bed214d8a
--- /dev/null
+++ b/apps/desktop/src/app/shell/statusbar-controls.test.tsx
@@ -0,0 +1,113 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { MemoryRouter } from 'react-router'
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
+
+import { I18nProvider } from '@/i18n'
+
+import { StatusbarControls } from './statusbar-controls'
+
+class TestResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+beforeAll(() => {
+ vi.stubGlobal('ResizeObserver', TestResizeObserver)
+ Element.prototype.hasPointerCapture ??= () => false
+ Element.prototype.setPointerCapture ??= () => undefined
+ Element.prototype.releasePointerCapture ??= () => undefined
+ HTMLElement.prototype.scrollIntoView ??= () => undefined
+})
+
+afterEach(cleanup)
+
+function renderStatusbar(mode: 'auto-hide' | 'off' | 'on') {
+ return render(
+
+
+
+
+
+ )
+}
+
+describe('Desktop status bar visibility', () => {
+ it('renders normally in on mode', () => {
+ renderStatusbar('on')
+
+ expect(screen.getByRole('contentinfo')).not.toBeNull()
+ expect(screen.queryByLabelText('Reveal the desktop status bar')).toBeNull()
+ })
+
+ it('does not render the status bar in off mode', () => {
+ renderStatusbar('off')
+
+ expect(screen.queryByRole('contentinfo')).toBeNull()
+ })
+
+ it('keeps a keyboard-focusable bottom-edge reveal target in auto-hide mode', () => {
+ renderStatusbar('auto-hide')
+
+ const revealZone = screen.getByLabelText('Reveal the desktop status bar')
+ const statusbar = screen.getByRole('contentinfo')
+
+ expect(revealZone.getAttribute('tabindex')).toBe('0')
+ expect(statusbar.classList.contains('translate-y-full')).toBe(true)
+ expect(statusbar.classList.contains('opacity-0')).toBe(true)
+ })
+
+ it('stays revealed while focus is inside a portaled status-bar menu', async () => {
+ render(
+
+
+
+
+
+ )
+
+ const revealZone = screen.getByLabelText('Reveal the desktop status bar')
+ const statusbar = screen.getByRole('contentinfo')
+ const trigger = screen.getByRole('button', { name: 'Session' })
+
+ fireEvent.pointerDown(trigger, { button: 0 })
+
+ const menuItem = await screen.findByRole('menuitem', { name: 'Settings' })
+ menuItem.focus()
+
+ expect(revealZone.contains(menuItem)).toBe(false)
+ expect(menuItem.ownerDocument.activeElement).toBe(menuItem)
+ expect(trigger.getAttribute('data-state')).toBe('open')
+ expect(statusbar.classList.contains('has-data-[state=open]:translate-y-0')).toBe(true)
+ expect(statusbar.classList.contains('has-data-[state=open]:opacity-100')).toBe(true)
+ })
+
+ it('stays revealed while focus is inside the portaled status-bar context menu', async () => {
+ renderStatusbar('auto-hide')
+
+ const revealZone = screen.getByLabelText('Reveal the desktop status bar')
+ const statusbar = screen.getByRole('contentinfo')
+
+ fireEvent.pointerDown(statusbar, { button: 2, ctrlKey: false, pointerType: 'mouse' })
+ fireEvent.contextMenu(statusbar, { button: 2 })
+
+ const hideItem = await screen.findByRole('menuitem', { name: /hide status bar/i })
+ hideItem.focus()
+
+ expect(revealZone.contains(hideItem)).toBe(false)
+ expect(hideItem.ownerDocument.activeElement).toBe(hideItem)
+ expect(statusbar.getAttribute('data-state')).toBe('open')
+ expect(statusbar.classList.contains('data-[state=open]:translate-y-0')).toBe(true)
+ expect(statusbar.classList.contains('data-[state=open]:opacity-100')).toBe(true)
+ })
+})
diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx
index 855d016bffdd..4c1d544b2133 100644
--- a/apps/desktop/src/app/shell/statusbar-controls.tsx
+++ b/apps/desktop/src/app/shell/statusbar-controls.tsx
@@ -16,6 +16,7 @@ import { Tip, TipKeybindLabel, Tooltip, TooltipContent, TooltipProvider, Tooltip
import { useI18n } from '@/i18n'
import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint'
import { cn } from '@/lib/utils'
+import type { DesktopStatusbarMode } from '@/store/desktop-statusbar'
import { $statusbarHiddenIds, setStatusbarItemVisible, toggleStatusbarVisible } from '@/store/statusbar-prefs'
// Shared chrome styling for interactive statusbar items (button / link / menu
@@ -80,21 +81,39 @@ export type SetStatusbarItemGroup = (id: string, items: readonly StatusbarItem[]
interface StatusbarControlsProps extends ComponentProps<'footer'> {
leftItems?: readonly StatusbarItem[]
items?: readonly StatusbarItem[]
+ mode?: DesktopStatusbarMode
}
-export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
+export function StatusbarControls({
+ className,
+ leftItems = [],
+ items = [],
+ mode = 'on',
+ ...props
+}: StatusbarControlsProps) {
+ const { t } = useI18n()
const navigate = useNavigate()
const hiddenIds = useStore($statusbarHiddenIds)
const visible = (item: StatusbarItem) =>
!item.hidden && (item.lockedVisible || !item.toggleLabel || !hiddenIds.includes(item.id))
- return (
+ if (mode === 'off') {
+ return null
+ }
+
+ // Radix portals menu content under document.body. Dropdown triggers keep
+ // data-state="open" inside the footer, while the ContextMenu trigger puts
+ // that state on the footer itself. Preserve both markers so moving focus or
+ // the pointer into either portal does not retract an auto-hidden bar.
+ const statusbar = (
)
+
+ if (mode === 'auto-hide') {
+ return (
+
+ {statusbar}
+
+ )
+ }
+
+ return statusbar
}
/** Right-click the bar to choose what it shows. Lists every item that named
diff --git a/apps/desktop/src/app/shell/statusbar-visibility.test.tsx b/apps/desktop/src/app/shell/statusbar-visibility.test.tsx
index b4bce684bb1c..72c86b481d80 100644
--- a/apps/desktop/src/app/shell/statusbar-visibility.test.tsx
+++ b/apps/desktop/src/app/shell/statusbar-visibility.test.tsx
@@ -1,8 +1,18 @@
-import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
+import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
-import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const getHermesConfigRecord = vi.fn(async () => ({}))
+const saveHermesConfig = vi.fn(async (_config: unknown) => ({ ok: true }))
+
+vi.mock('@/hermes', () => ({
+ getApiRequestProfile: () => null,
+ getHermesConfigRecord: () => getHermesConfigRecord(),
+ saveHermesConfig: (config: unknown) => saveHermesConfig(config)
+}))
import { StatusbarControls, type StatusbarItem } from '@/app/shell/statusbar-controls'
+import { $desktopStatusbarMode, applyDesktopStatusbarFromConfig } from '@/store/desktop-statusbar'
import {
$statusbarHiddenIds,
$statusbarVisible,
@@ -24,10 +34,15 @@ beforeAll(() => {
HTMLElement.prototype.scrollIntoView ??= () => undefined
})
+beforeEach(() => {
+ vi.clearAllMocks()
+ applyDesktopStatusbarFromConfig({ display: { desktop_statusbar: 'on' } })
+})
+
afterEach(() => {
cleanup()
$statusbarHiddenIds.set([...STATUSBAR_HIDDEN_BY_DEFAULT])
- $statusbarVisible.set(true)
+ applyDesktopStatusbarFromConfig({ display: { desktop_statusbar: 'off' } })
})
const item = (id: string, label: string, extra: Partial = {}): StatusbarItem => ({
@@ -132,9 +147,13 @@ describe('whole-bar visibility', () => {
openContextMenu(statusbar)
fireEvent.click(await screen.findByRole('menuitem', { name: /hide status bar/i }))
+ await waitFor(() => expect(saveHermesConfig).toHaveBeenCalledTimes(1))
+ await waitFor(() => expect($desktopStatusbarMode.get()).toBe('off'))
expect($statusbarVisible.get()).toBe(false)
toggleStatusbarVisible()
+ await waitFor(() => expect(saveHermesConfig).toHaveBeenCalledTimes(2))
+ await waitFor(() => expect($desktopStatusbarMode.get()).toBe('on'))
expect($statusbarVisible.get()).toBe(true)
})
diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts
index 9088b1e7a7a2..4d9d70787439 100644
--- a/apps/desktop/src/hermes.ts
+++ b/apps/desktop/src/hermes.ts
@@ -716,9 +716,9 @@ export function getHermesConfig(profile?: string): Promise {
})
}
-export function getHermesConfigRecord(): Promise {
+export function getHermesConfigRecord(profile?: null | string): Promise {
return window.hermesDesktop.api({
- ...profileScoped(),
+ ...profileScoped(profile),
path: '/api/config'
})
}
@@ -738,9 +738,9 @@ export function getHermesConfigSchema(): Promise {
})
}
-export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: boolean }> {
+export function saveHermesConfig(config: HermesConfigRecord, profile?: null | string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
- ...profileScoped(),
+ ...profileScoped(profile),
path: '/api/config',
method: 'PUT',
body: { config }
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 5d72bd090e95..7d8a6921e0cb 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -445,6 +445,13 @@ export const en: Translations = {
terminalFontPlaceholder: 'MesloLGS NF or a CSS font stack',
terminalFontPreview: 'Glyph preview',
terminalFontReset: 'Use default',
+ statusbarTitle: 'Desktop Status Bar',
+ statusbarDesc: 'Show it, hide it, or reveal it only when you move to the bottom edge.',
+ statusbarOn: 'On',
+ statusbarOff: 'Off',
+ statusbarAutoHide: 'Auto-hide',
+ statusbarReveal: 'Reveal the desktop status bar',
+ statusbarSaveFailed: 'Could not save the desktop status bar setting.',
translucencyTitle: 'Window Translucency',
translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.',
backdropTitle: 'Chat Backdrop',
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 13bfd18c3bad..f9907e54953a 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -321,6 +321,13 @@ export const ja = defineLocale({
terminalFontPlaceholder: 'MesloLGS NF または CSS フォントスタック',
terminalFontPreview: 'グリフのプレビュー',
terminalFontReset: '既定値を使用',
+ statusbarTitle: 'デスクトップステータスバー',
+ statusbarDesc: '常に表示、非表示、またはウィンドウ下端に移動したときだけ表示します。',
+ statusbarOn: 'オン',
+ statusbarOff: 'オフ',
+ statusbarAutoHide: '自動的に隠す',
+ statusbarReveal: 'デスクトップステータスバーを表示',
+ statusbarSaveFailed: 'デスクトップステータスバー設定を保存できませんでした。',
translucencyTitle: 'ウィンドウの透過',
translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',
backdropTitle: 'チャット背景',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 79b56030d6c6..c9cdc9f42895 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -353,6 +353,13 @@ export interface Translations {
terminalFontPlaceholder: string
terminalFontPreview: string
terminalFontReset: string
+ statusbarTitle: string
+ statusbarDesc: string
+ statusbarOn: string
+ statusbarOff: string
+ statusbarAutoHide: string
+ statusbarReveal: string
+ statusbarSaveFailed: string
translucencyTitle: string
translucencyDesc: string
backdropTitle: string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index 7f0fcfde88bc..16f0dea00ab2 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -313,6 +313,13 @@ export const zhHant = defineLocale({
terminalFontPlaceholder: 'MesloLGS NF 或 CSS 字型堆疊',
terminalFontPreview: '字形預覽',
terminalFontReset: '使用預設字型',
+ statusbarTitle: '桌面狀態列',
+ statusbarDesc: '一律顯示、隱藏,或僅在移到視窗底部邊緣時顯示。',
+ statusbarOn: '顯示',
+ statusbarOff: '隱藏',
+ statusbarAutoHide: '自動隱藏',
+ statusbarReveal: '顯示桌面狀態列',
+ statusbarSaveFailed: '無法儲存桌面狀態列設定。',
translucencyTitle: '視窗透明',
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
backdropTitle: '聊天背景',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index a9ccfee5068a..71b423843b5a 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -437,6 +437,13 @@ export const zh: Translations = {
terminalFontPlaceholder: 'MesloLGS NF 或 CSS 字体栈',
terminalFontPreview: '字形预览',
terminalFontReset: '使用默认字体',
+ statusbarTitle: '桌面状态栏',
+ statusbarDesc: '始终显示、隐藏,或仅在移到窗口底部边缘时显示。',
+ statusbarOn: '显示',
+ statusbarOff: '隐藏',
+ statusbarAutoHide: '自动隐藏',
+ statusbarReveal: '显示桌面状态栏',
+ statusbarSaveFailed: '无法保存桌面状态栏设置。',
translucencyTitle: '窗口透明',
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
backdropTitle: '聊天背景',
diff --git a/apps/desktop/src/store/desktop-statusbar.test.ts b/apps/desktop/src/store/desktop-statusbar.test.ts
new file mode 100644
index 000000000000..d223f058c306
--- /dev/null
+++ b/apps/desktop/src/store/desktop-statusbar.test.ts
@@ -0,0 +1,158 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const getHermesConfigRecord = vi.fn()
+const getApiRequestProfile = vi.fn<() => null | string>(() => null)
+const saveHermesConfig = vi.fn()
+
+vi.mock('@/hermes', () => ({
+ getApiRequestProfile: () => getApiRequestProfile(),
+ getHermesConfigRecord: (profile?: null | string) => getHermesConfigRecord(profile),
+ saveHermesConfig: (config: unknown, profile?: null | string) => saveHermesConfig(config, profile)
+}))
+
+import { readKey, writeKey } from '@/lib/storage'
+
+import {
+ $desktopStatusbarMode,
+ applyDesktopStatusbarFromConfig,
+ migrateLegacyDesktopStatusbarPreference,
+ normalizeDesktopStatusbarMode,
+ persistDesktopStatusbarMode,
+ toggleDesktopStatusbarVisible
+} from './desktop-statusbar'
+
+const LEGACY_STATUSBAR_VISIBLE_STORAGE_KEY = 'hermes.desktop.statusbarVisible'
+
+function deferred() {
+ let resolve!: (value: T | PromiseLike) => void
+
+ const promise = new Promise(done => {
+ resolve = done
+ })
+
+ return { promise, resolve }
+}
+
+describe('desktop status bar preference', () => {
+ beforeEach(() => {
+ writeKey(LEGACY_STATUSBAR_VISIBLE_STORAGE_KEY, null)
+ vi.clearAllMocks()
+ getApiRequestProfile.mockReturnValue(null)
+ applyDesktopStatusbarFromConfig({ display: { desktop_statusbar: 'off' } })
+ })
+
+ it('normalizes unknown and missing values to the quiet default', () => {
+ expect(normalizeDesktopStatusbarMode(undefined)).toBe('off')
+ expect(normalizeDesktopStatusbarMode('sometimes')).toBe('off')
+ expect(normalizeDesktopStatusbarMode('auto-hide')).toBe('auto-hide')
+ })
+
+ it('applies a saved profile preference', () => {
+ applyDesktopStatusbarFromConfig({ display: { desktop_statusbar: 'off' } })
+
+ expect($desktopStatusbarMode.get()).toBe('off')
+ })
+
+ it('preserves sibling config fields while persisting', async () => {
+ getHermesConfigRecord.mockResolvedValue({ display: { language: 'zh', skin: 'slate' }, terminal: { cwd: '/work' } })
+ saveHermesConfig.mockResolvedValue({ ok: true })
+
+ const saved = await persistDesktopStatusbarMode('auto-hide')
+
+ expect($desktopStatusbarMode.get()).toBe('auto-hide')
+ expect(saved).toEqual({
+ display: { desktop_statusbar: 'auto-hide', language: 'zh', skin: 'slate' },
+ terminal: { cwd: '/work' }
+ })
+ expect(saveHermesConfig).toHaveBeenCalledWith(saved, null)
+ })
+
+ it('rolls back the optimistic preference when persistence fails', async () => {
+ getHermesConfigRecord.mockRejectedValue(new Error('offline'))
+
+ await expect(persistDesktopStatusbarMode('on')).rejects.toThrow('offline')
+ expect($desktopStatusbarMode.get()).toBe('off')
+ })
+
+ it('toggles a visible mode off and restores the always-visible mode', async () => {
+ getHermesConfigRecord.mockResolvedValue({ display: { language: 'zh' } })
+ saveHermesConfig.mockResolvedValue({ ok: true })
+ applyDesktopStatusbarFromConfig({ display: { desktop_statusbar: 'auto-hide' } })
+
+ await toggleDesktopStatusbarVisible()
+
+ expect($desktopStatusbarMode.get()).toBe('off')
+ expect(saveHermesConfig).toHaveBeenLastCalledWith({ display: { desktop_statusbar: 'off', language: 'zh' } }, null)
+
+ await toggleDesktopStatusbarVisible()
+
+ expect($desktopStatusbarMode.get()).toBe('on')
+ expect(saveHermesConfig).toHaveBeenLastCalledWith({ display: { desktop_statusbar: 'on', language: 'zh' } }, null)
+ })
+
+ it('migrates the released whole-bar preference into the active profile', async () => {
+ writeKey(LEGACY_STATUSBAR_VISIBLE_STORAGE_KEY, 'true')
+ const config = { display: { desktop_statusbar: 'off', language: 'zh' } }
+ getHermesConfigRecord.mockResolvedValue(config)
+ saveHermesConfig.mockResolvedValue({ ok: true })
+
+ applyDesktopStatusbarFromConfig(config)
+
+ expect($desktopStatusbarMode.get()).toBe('on')
+
+ await migrateLegacyDesktopStatusbarPreference(config)
+
+ expect(saveHermesConfig).toHaveBeenCalledWith({ display: { desktop_statusbar: 'on', language: 'zh' } }, null)
+ expect(readKey(LEGACY_STATUSBAR_VISIBLE_STORAGE_KEY)).toBeNull()
+ })
+
+ it('prefers an explicit non-default config over the legacy whole-bar key', async () => {
+ writeKey(LEGACY_STATUSBAR_VISIBLE_STORAGE_KEY, 'false')
+ const config = { display: { desktop_statusbar: 'auto-hide' } }
+
+ applyDesktopStatusbarFromConfig(config)
+ await migrateLegacyDesktopStatusbarPreference(config)
+
+ expect($desktopStatusbarMode.get()).toBe('auto-hide')
+ expect(getHermesConfigRecord).not.toHaveBeenCalled()
+ expect(readKey(LEGACY_STATUSBAR_VISIBLE_STORAGE_KEY)).toBeNull()
+ })
+
+ it('serializes rapid changes so the latest intent wins', async () => {
+ const firstSave = deferred<{ ok: boolean }>()
+ getHermesConfigRecord.mockResolvedValue({ display: { language: 'zh' } })
+ saveHermesConfig.mockReturnValueOnce(firstSave.promise).mockResolvedValueOnce({ ok: true })
+
+ const show = persistDesktopStatusbarMode('on')
+ const hide = persistDesktopStatusbarMode('off')
+
+ expect($desktopStatusbarMode.get()).toBe('off')
+ await vi.waitFor(() => expect(saveHermesConfig).toHaveBeenCalledTimes(1))
+ expect(getHermesConfigRecord).toHaveBeenCalledTimes(1)
+
+ firstSave.resolve({ ok: true })
+ await Promise.all([show, hide])
+
+ expect(getHermesConfigRecord).toHaveBeenCalledTimes(2)
+ expect(saveHermesConfig).toHaveBeenLastCalledWith({ display: { desktop_statusbar: 'off', language: 'zh' } }, null)
+ expect($desktopStatusbarMode.get()).toBe('off')
+ })
+
+ it('pins both halves of a config write to the profile that started it', async () => {
+ const profileAConfig = deferred>()
+ getApiRequestProfile.mockReturnValue('profile-a')
+ getHermesConfigRecord.mockReturnValue(profileAConfig.promise)
+ saveHermesConfig.mockResolvedValue({ ok: true })
+
+ const saving = persistDesktopStatusbarMode('on')
+ await vi.waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalledWith('profile-a'))
+
+ getApiRequestProfile.mockReturnValue('profile-b')
+ applyDesktopStatusbarFromConfig({ display: { desktop_statusbar: 'off' } })
+ profileAConfig.resolve({ display: { language: 'ja' } })
+ await saving
+
+ expect(saveHermesConfig).toHaveBeenCalledWith({ display: { desktop_statusbar: 'on', language: 'ja' } }, 'profile-a')
+ expect($desktopStatusbarMode.get()).toBe('off')
+ })
+})
diff --git a/apps/desktop/src/store/desktop-statusbar.ts b/apps/desktop/src/store/desktop-statusbar.ts
new file mode 100644
index 000000000000..ddad4dc8265d
--- /dev/null
+++ b/apps/desktop/src/store/desktop-statusbar.ts
@@ -0,0 +1,170 @@
+import { atom } from 'nanostores'
+
+import { getApiRequestProfile, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
+import { readKey, writeKey } from '@/lib/storage'
+import { setHermesConfigCache } from '@/store/hermes-config-record'
+import type { HermesConfigRecord } from '@/types/hermes'
+
+export type DesktopStatusbarMode = 'auto-hide' | 'off' | 'on'
+
+const LEGACY_STATUSBAR_VISIBLE_STORAGE_KEY = 'hermes.desktop.statusbarVisible'
+
+export const DEFAULT_DESKTOP_STATUSBAR_MODE: DesktopStatusbarMode = 'off'
+
+function legacyDesktopStatusbarMode(): DesktopStatusbarMode | null {
+ const value = readKey(LEGACY_STATUSBAR_VISIBLE_STORAGE_KEY)
+
+ return value === 'true' ? 'on' : value === 'false' ? 'off' : null
+}
+
+export const $desktopStatusbarMode = atom(
+ legacyDesktopStatusbarMode() ?? DEFAULT_DESKTOP_STATUSBAR_MODE
+)
+
+interface PendingStatusbarIntent {
+ generation: number
+ mode: DesktopStatusbarMode
+}
+
+const confirmedModes = new Map()
+const pendingIntents = new Map()
+
+let intentGeneration = 0
+let legacyMigration: null | Promise = null
+let persistenceQueue: Promise = Promise.resolve()
+
+export function normalizeDesktopStatusbarMode(value: unknown): DesktopStatusbarMode {
+ return value === 'off' || value === 'auto-hide' || value === 'on' ? value : DEFAULT_DESKTOP_STATUSBAR_MODE
+}
+
+function profileKey(profile: null | string): string {
+ return profile || 'default'
+}
+
+function publishDesktopStatusbarMode(mode: DesktopStatusbarMode): void {
+ $desktopStatusbarMode.set(mode)
+}
+
+export function applyDesktopStatusbarFromConfig(
+ config: { display?: { desktop_statusbar?: unknown } | null } | null | undefined
+): void {
+ const key = profileKey(getApiRequestProfile())
+ const configuredMode = normalizeDesktopStatusbarMode(config?.display?.desktop_statusbar)
+
+ // v2026.7.30 persisted the old whole-bar boolean before this profile-scoped
+ // setting existed. The new config default is off, so a legacy value only
+ // needs to win while the effective config is still that default. A real
+ // on/auto-hide config already expresses newer intent and takes precedence.
+ const mode =
+ configuredMode === DEFAULT_DESKTOP_STATUSBAR_MODE
+ ? (legacyDesktopStatusbarMode() ?? configuredMode)
+ : configuredMode
+
+ confirmedModes.set(key, mode)
+ publishDesktopStatusbarMode(pendingIntents.get(key)?.mode ?? mode)
+}
+
+/**
+ * Persist the profile-scoped Desktop status bar preference. The atom updates
+ * optimistically so the chrome responds immediately, then rolls back if the
+ * whole-record config write fails.
+ */
+export function persistDesktopStatusbarMode(mode: DesktopStatusbarMode): Promise {
+ const profile = getApiRequestProfile()
+ const key = profileKey(profile)
+ const generation = ++intentGeneration
+ const fallbackMode = confirmedModes.get(key) ?? $desktopStatusbarMode.get()
+
+ pendingIntents.set(key, { generation, mode })
+ publishDesktopStatusbarMode(mode)
+
+ const operation = persistenceQueue.then(async () => {
+ const record = await getHermesConfigRecord(profile)
+
+ const display =
+ record.display && typeof record.display === 'object' && !Array.isArray(record.display)
+ ? (record.display as Record)
+ : {}
+
+ const next = { ...record, display: { ...display, desktop_statusbar: mode } }
+
+ await saveHermesConfig(next, profile)
+ writeKey(LEGACY_STATUSBAR_VISIBLE_STORAGE_KEY, null)
+ confirmedModes.set(key, mode)
+
+ if (pendingIntents.get(key)?.generation === generation) {
+ pendingIntents.delete(key)
+
+ if (profileKey(getApiRequestProfile()) === key) {
+ publishDesktopStatusbarMode(mode)
+ }
+ }
+
+ if (profileKey(getApiRequestProfile()) === key) {
+ setHermesConfigCache(next)
+ }
+
+ return next
+ })
+
+ persistenceQueue = operation.then(
+ () => undefined,
+ () => undefined
+ )
+
+ return operation.catch(error => {
+ if (pendingIntents.get(key)?.generation === generation) {
+ pendingIntents.delete(key)
+
+ if (profileKey(getApiRequestProfile()) === key) {
+ publishDesktopStatusbarMode(confirmedModes.get(key) ?? fallbackMode)
+ }
+ }
+
+ throw error
+ })
+}
+
+/**
+ * Move the pre-config whole-bar preference into the active profile once. The
+ * old key is removed only after a successful config write, so an unavailable
+ * backend leaves the user's current choice in place and can retry later.
+ */
+export function migrateLegacyDesktopStatusbarPreference(
+ config: { display?: { desktop_statusbar?: unknown } | null } | null | undefined
+): Promise {
+ if (legacyMigration) {
+ return legacyMigration
+ }
+
+ const legacyMode = legacyDesktopStatusbarMode()
+
+ if (!legacyMode) {
+ return Promise.resolve(null)
+ }
+
+ const configuredMode = normalizeDesktopStatusbarMode(config?.display?.desktop_statusbar)
+
+ if (configuredMode !== DEFAULT_DESKTOP_STATUSBAR_MODE) {
+ writeKey(LEGACY_STATUSBAR_VISIBLE_STORAGE_KEY, null)
+
+ return Promise.resolve(null)
+ }
+
+ legacyMigration = persistDesktopStatusbarMode(legacyMode).finally(() => {
+ legacyMigration = null
+ })
+
+ return legacyMigration
+}
+
+/**
+ * Shared whole-bar toggle used by the keybind, command palette, and context
+ * menu. The whole-bar action remains binary like main's original control;
+ * auto-hide stays available as an explicit Appearance setting.
+ */
+export async function toggleDesktopStatusbarVisible(): Promise {
+ const current = $desktopStatusbarMode.get()
+
+ return persistDesktopStatusbarMode(current === 'off' ? 'on' : 'off')
+}
diff --git a/apps/desktop/src/store/hermes-config-record.ts b/apps/desktop/src/store/hermes-config-record.ts
new file mode 100644
index 000000000000..21ebc2466a36
--- /dev/null
+++ b/apps/desktop/src/store/hermes-config-record.ts
@@ -0,0 +1,11 @@
+import { queryClient, writeCache } from '@/lib/query-client'
+import type { HermesConfigRecord } from '@/types/hermes'
+
+// Shared cache identity for the effective config record of the routed profile.
+// Keeping the writer outside React lets stores update it after config-backed
+// keybinds and context-menu actions, not only after Settings form saves.
+export const HERMES_CONFIG_KEY = ['hermes-config-record'] as const
+
+export const setHermesConfigCache = writeCache(HERMES_CONFIG_KEY)
+
+export const invalidateHermesConfig = () => queryClient.invalidateQueries({ queryKey: HERMES_CONFIG_KEY })
diff --git a/apps/desktop/src/store/statusbar-prefs.ts b/apps/desktop/src/store/statusbar-prefs.ts
index a5f9468c8764..44f05923fcdd 100644
--- a/apps/desktop/src/store/statusbar-prefs.ts
+++ b/apps/desktop/src/store/statusbar-prefs.ts
@@ -1,16 +1,22 @@
+import { computed } from 'nanostores'
+
import { Codecs, persistentAtom } from '@/lib/persisted'
+import { $desktopStatusbarMode, toggleDesktopStatusbarVisible } from './desktop-statusbar'
+
const STATUSBAR_HIDDEN_STORAGE_KEY = 'hermes.desktop.statusbarHidden'
-const STATUSBAR_VISIBLE_STORAGE_KEY = 'hermes.desktop.statusbarVisible'
-// Whole-bar visibility, VS Code's `workbench.statusBar.visible`. Off by default
-// — the bar is opt-in. Hiding it unmounts the bar (its 15s status poll goes with
-// it), so the way back is the `view.toggleStatusbar` keybind or the ⌘K row,
-// never the bar itself.
-export const $statusbarVisible = persistentAtom(STATUSBAR_VISIBLE_STORAGE_KEY, false, Codecs.bool)
+// Compatibility facade for the whole-bar UI added before the profile-scoped
+// on/off/auto-hide setting. One source of truth keeps Settings, the keybind,
+// the command palette, and the context menu in sync. The controller consumes
+// this derived boolean to unmount the bar (and its 15s status poll) in off mode.
+export const $statusbarVisible = computed($desktopStatusbarMode, mode => mode !== 'off')
export function toggleStatusbarVisible() {
- $statusbarVisible.set(!$statusbarVisible.get())
+ // These chrome actions have no error surface. Persistence already rolls the
+ // optimistic atom back, so consume the rejection instead of leaking an
+ // unhandled promise.
+ void toggleDesktopStatusbarVisible().catch(() => undefined)
}
// Items the bar hides until the user turns them on from its context menu. The
diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts
index 796784ff61c2..5345ae77fa4b 100644
--- a/apps/desktop/src/types/hermes.ts
+++ b/apps/desktop/src/types/hermes.ts
@@ -328,6 +328,7 @@ export interface HermesConfig {
service_tier?: string
}
display?: {
+ desktop_statusbar?: string
personality?: string
skin?: string
interim_assistant_messages?: boolean
diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py
index 74055e47be97..d40d983a3f95 100644
--- a/hermes_cli/config_defaults.py
+++ b/hermes_cli/config_defaults.py
@@ -1122,6 +1122,9 @@
"focus_view": False,
"focus_saved_tool_progress": "all",
"skin": "default",
+ # Desktop bottom status bar. Off keeps the default workspace quiet;
+ # users can opt into an always-visible or bottom-edge reveal surface.
+ "desktop_statusbar": "off", # on | off | auto-hide
# UI language for static user-facing messages (approval prompts, a
# handful of gateway slash-command replies). Does NOT affect agent
# responses, log lines, tool outputs, or slash-command descriptions.
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index a9a922aa5474..6ce55748b1d9 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -925,6 +925,11 @@ def _timezone_options() -> List[str]:
"description": "How resumed sessions display history",
"options": ["minimal", "full", "off"],
},
+ "display.desktop_statusbar": {
+ "type": "select",
+ "description": "Desktop bottom status bar visibility",
+ "options": ["on", "off", "auto-hide"],
+ },
"display.busy_input_mode": {
"type": "select",
"description": "Input behavior while agent is running",
diff --git a/tests/cli/test_desktop_statusbar_config.py b/tests/cli/test_desktop_statusbar_config.py
new file mode 100644
index 000000000000..4d9516db2ca0
--- /dev/null
+++ b/tests/cli/test_desktop_statusbar_config.py
@@ -0,0 +1,5 @@
+from hermes_cli.config import DEFAULT_CONFIG
+
+
+def test_desktop_statusbar_defaults_off_for_a_quiet_workspace():
+ assert DEFAULT_CONFIG["display"]["desktop_statusbar"] == "off"
diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md
index f27335dbd41e..7bbc6a03b9af 100644
--- a/website/docs/user-guide/configuration.md
+++ b/website/docs/user-guide/configuration.md
@@ -1588,6 +1588,7 @@ display:
personality: "" # Legacy cosmetic field still surfaced in some summaries
compact: false # Compact output mode (less whitespace)
resume_display: full # full (show previous messages on resume) | minimal (one-liner only)
+ desktop_statusbar: off # Desktop only: on | off | auto-hide
bell_on_complete: false # Play terminal bell when agent finishes (great for long tasks)
show_reasoning: true # Show model reasoning/thinking above each response (default: true; toggle with /reasoning show|hide)
streaming: false # Stream tokens to terminal as they arrive (real-time output)
@@ -1605,6 +1606,23 @@ display:
language: en # UI language for static messages (approval prompts, some gateway replies). en | zh | zh-hant | ja | de | es | fr | tr | uk | af | ko | it | ga | pt | ru | hu
```
+### Desktop status bar
+
+`display.desktop_statusbar` controls the bottom status bar in Hermes Desktop. It is independent from the terminal TUI `/statusbar` command and `display.tui_statusbar` setting.
+
+| Value | Behavior |
+|-------|----------|
+| `on` | Always show the Desktop status bar. |
+| `off` | Hide the Desktop status bar (default). Chat input controls remain visible. |
+| `auto-hide` | Hide the bar until the pointer or keyboard focus reaches the bottom edge of the window. |
+
+You can also change this under **Settings → Appearance → Desktop Status Bar**.
+
+```yaml
+display:
+ desktop_statusbar: auto-hide
+```
+
### Per-turn summary and spinner token flow
`display.turn_summary` (default `true`) prints one dim accounting line after each **interactive CLI** turn, summarising what that turn actually did: