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 a2841a1bbe8..b365a97e722 100644
--- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
+++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
@@ -245,8 +245,12 @@ export function useStatusbarItems({
icon: applying ? : ,
id: 'version-client',
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,
+ toggleLabel: copy.toggleVersion,
variant: 'action'
}
}, [
@@ -295,8 +299,10 @@ export function useStatusbarItems({
icon: applying ? : ,
id: 'version-backend',
label,
+ lockedVisible: true,
onSelect: () => openUpdateOverlayFor('backend'),
title: tooltip || undefined,
+ toggleLabel: copy.toggleBackendVersion,
variant: 'action'
}
}, [
@@ -346,8 +352,12 @@ export function useStatusbarItems({
className: `w-7 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
icon: ,
id: 'command-center',
+ // The system icon: the way into every other surface, including the
+ // settings that would bring a hidden item back. Never hideable.
+ lockedVisible: true,
onSelect: toggleCommandCenter,
title: commandCenterOpen ? copy.closeCommandCenter : copy.openCommandCenter,
+ toggleLabel: copy.toggleCommandCenter,
variant: 'action'
},
{
@@ -365,6 +375,7 @@ export function useStatusbarItems({
menuClassName: 'w-72',
menuContent: gatewayMenuContent,
title: inferenceStatus?.reason || copy.gatewayTitle,
+ toggleLabel: copy.gateway,
variant: 'menu'
},
{
@@ -398,6 +409,7 @@ export function useStatusbarItems({
]
: undefined,
title: currentCwd || undefined,
+ toggleLabel: copy.toggleWorkspace,
variant: 'menu'
},
{
@@ -423,6 +435,7 @@ export function useStatusbarItems({
label: copy.agents,
onSelect: openAgents,
title: agentsOpen ? copy.closeAgents : copy.openAgents,
+ toggleLabel: copy.agents,
variant: 'action'
},
{
@@ -431,6 +444,7 @@ export function useStatusbarItems({
label: copy.cron,
title: copy.openCron,
to: CRON_ROUTE,
+ toggleLabel: copy.cron,
variant: 'action'
},
{
@@ -439,6 +453,7 @@ export function useStatusbarItems({
label: copy.webhooks,
title: copy.openWebhooks,
to: WEBHOOKS_ROUTE,
+ toggleLabel: copy.webhooks,
variant: 'action'
}
],
@@ -504,7 +519,8 @@ export function useStatusbarItems({
},
{
...approvalModeItem,
- hidden: gatewayState !== 'open'
+ hidden: gatewayState !== 'open',
+ toggleLabel: copy.toggleApprovalMode
},
{
actionId: 'view.showTerminal',
@@ -514,6 +530,7 @@ export function useStatusbarItems({
id: 'terminal',
onSelect: () => setTerminalTakeover(!$terminalTakeover.get()),
title: terminalTakeover ? copy.hideTerminal : copy.showTerminal,
+ toggleLabel: copy.toggleTerminal,
variant: 'action'
},
clientVersionItem,
diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx
index 58376559ce0..9e3950626ba 100644
--- a/apps/desktop/src/app/shell/statusbar-controls.tsx
+++ b/apps/desktop/src/app/shell/statusbar-controls.tsx
@@ -1,9 +1,20 @@
-import { type ComponentProps, memo, type ReactNode, useState } from 'react'
+import { useStore } from '@nanostores/react'
+import { type ComponentProps, memo, type ReactNode, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
+import {
+ ContextMenu,
+ ContextMenuCheckboxItem,
+ ContextMenuContent,
+ ContextMenuLabel,
+ ContextMenuSeparator,
+ ContextMenuTrigger
+} from '@/components/ui/context-menu'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Tip, TipKeybindLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
+import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
+import { $statusbarHiddenIds, setStatusbarItemVisible } from '@/store/statusbar-prefs'
// Shared chrome styling for interactive statusbar items (button / link / menu
// trigger). The 'text' variant intentionally omits hover/transition/disabled.
@@ -47,6 +58,14 @@ export interface StatusbarItem {
title?: string
to?: string
variant?: 'action' | 'link' | 'menu' | 'text'
+ /** Plain-text name for the bar's right-click show/hide menu. An item without
+ * one is never listed there and always shows — the safe default for plugin
+ * contributions that don't opt in. */
+ toggleLabel?: string
+ /** Listed in the menu but not switchable: the bar's own affordances (command
+ * center, update/version pills) would strand the user if they could be
+ * hidden from the surface that hides them. */
+ lockedVisible?: boolean
}
export interface StatusbarSelectModifiers {
@@ -63,35 +82,98 @@ interface StatusbarControlsProps extends ComponentProps<'footer'> {
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
const navigate = useNavigate()
+ const hiddenIds = useStore($statusbarHiddenIds)
+
+ const visible = (item: StatusbarItem) =>
+ !item.hidden && (item.lockedVisible || !item.toggleLabel || !hiddenIds.includes(item.id))
return (
-
+
+
+
+
+
+
+ )
+}
+
+/** Right-click the bar to choose what it shows. Lists every item that named
+ * itself with `toggleLabel`, in bar order (left cluster then right), so the
+ * menu reads like the surface it edits. */
+function StatusbarVisibilityMenu({
+ hiddenIds,
+ items,
+ leftItems
+}: {
+ hiddenIds: readonly string[]
+ items: readonly StatusbarItem[]
+ leftItems: readonly StatusbarItem[]
+}) {
+ const { t } = useI18n()
+ const copy = t.shell.statusbar
+
+ // Deduped by id: an item can legitimately appear in both clusters across
+ // renders (contributions move sides), and a repeated checkbox would let one
+ // row's toggle silently contradict the other's.
+ const toggles = useMemo(() => {
+ const seen = new Set()
+
+ return [...leftItems, ...items].filter(item => {
+ if (!item.toggleLabel || seen.has(item.id)) {
+ return false
+ }
+
+ seen.add(item.id)
+
+ return true
+ })
+ }, [items, leftItems])
+
+ if (toggles.length === 0) {
+ return null
+ }
+
+ return (
+
+ {copy.customizeTitle}
+
+ {toggles.map(item => (
+ setStatusbarItemVisible(item.id, checked)}
+ // Radix closes the menu on select; keep it open so several items can
+ // be toggled in one pass (this is a preferences surface, not a
+ // command list).
+ onSelect={event => event.preventDefault()}
+ >
+ {item.toggleLabel}
+
+ ))}
+
)
}
diff --git a/apps/desktop/src/app/shell/statusbar-visibility.test.tsx b/apps/desktop/src/app/shell/statusbar-visibility.test.tsx
new file mode 100644
index 00000000000..cb26f286aa8
--- /dev/null
+++ b/apps/desktop/src/app/shell/statusbar-visibility.test.tsx
@@ -0,0 +1,99 @@
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
+
+import { StatusbarControls, type StatusbarItem } from '@/app/shell/statusbar-controls'
+import { $statusbarHiddenIds, STATUSBAR_HIDDEN_BY_DEFAULT } from '@/store/statusbar-prefs'
+
+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()
+ $statusbarHiddenIds.set([...STATUSBAR_HIDDEN_BY_DEFAULT])
+})
+
+const item = (id: string, label: string, extra: Partial = {}): StatusbarItem => ({
+ id,
+ label,
+ toggleLabel: label,
+ variant: 'action',
+ ...extra
+})
+
+function bar(items: StatusbarItem[]) {
+ render(
+
+
+
+ )
+
+ return screen.getByRole('contentinfo')
+}
+
+/** Radix opens a ContextMenu on contextmenu after a pointerdown positions it. */
+function openContextMenu(target: HTMLElement) {
+ fireEvent.pointerDown(target, { button: 2, ctrlKey: false, pointerType: 'mouse' })
+ fireEvent.contextMenu(target, { button: 2 })
+}
+
+describe('statusbar item visibility', () => {
+ it('hides the route/toggle items out of the box and keeps status items', () => {
+ bar([
+ item('cron', 'Cron'),
+ item('webhooks', 'Webhooks'),
+ item('agents', 'Agents'),
+ item('terminal', 'Terminal'),
+ item('approval-mode', 'Approvals'),
+ item('gateway-health', 'Gateway')
+ ])
+
+ for (const label of ['Cron', 'Webhooks', 'Agents', 'Terminal', 'Approvals']) {
+ expect(screen.queryByText(label)).toBeNull()
+ }
+
+ expect(screen.getByText('Gateway')).toBeTruthy()
+ })
+
+ it('shows an item once the user enables it from the bar context menu', async () => {
+ const statusbar = bar([item('cron', 'Cron'), item('gateway-health', 'Gateway')])
+
+ expect(screen.queryByText('Cron')).toBeNull()
+
+ openContextMenu(statusbar)
+
+ const row = await screen.findByRole('menuitemcheckbox', { name: 'Cron' })
+ fireEvent.click(row)
+
+ expect($statusbarHiddenIds.get()).not.toContain('cron')
+ expect(within(statusbar).getByText('Cron')).toBeTruthy()
+ })
+
+ it('never lets the user hide a locked item (system icon / update pill)', async () => {
+ const statusbar = bar([item('command-center', 'Command Center', { lockedVisible: true })])
+
+ openContextMenu(statusbar)
+
+ const row = await screen.findByRole('menuitemcheckbox', { name: 'Command Center' })
+ expect(row.getAttribute('data-disabled')).not.toBeNull()
+ expect(row.getAttribute('aria-checked')).toBe('true')
+ })
+
+ it('leaves items that never opted into the menu alone', () => {
+ $statusbarHiddenIds.set(['plugin-thing'])
+ bar([{ id: 'plugin-thing', label: 'Plugin thing', variant: 'action' }])
+
+ expect(screen.getByText('Plugin thing')).toBeTruthy()
+ })
+})
diff --git a/apps/desktop/src/components/ui/context-menu.tsx b/apps/desktop/src/components/ui/context-menu.tsx
index 1652d68b9f7..286418baf90 100644
--- a/apps/desktop/src/components/ui/context-menu.tsx
+++ b/apps/desktop/src/components/ui/context-menu.tsx
@@ -58,6 +58,30 @@ function ContextMenuItem({
)
}
+function ContextMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
function ContextMenuLabel({
className,
inset,
@@ -142,6 +166,7 @@ function ContextMenuSubContent({
export {
ContextMenu,
+ ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index c1d59a013b8..e651746789a 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -2381,6 +2381,13 @@ export const en: Translations = {
gatewayOffline: 'offline',
gatewayRestarting: 'restarting…',
gatewayTitle: 'Hermes inference gateway status',
+ customizeTitle: 'Show in status bar',
+ toggleApprovalMode: 'Approvals',
+ toggleBackendVersion: 'Backend version',
+ toggleCommandCenter: 'Command Center',
+ toggleTerminal: 'Terminal',
+ toggleVersion: 'Version & updates',
+ toggleWorkspace: 'Workspace',
agents: 'Agents',
closeAgents: 'Close agents',
openAgents: 'Open agents',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 90506fe9237..a572bbd8b6e 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -1990,6 +1990,13 @@ export interface Translations {
gatewayOffline: string
gatewayRestarting: string
gatewayTitle: string
+ customizeTitle: string
+ toggleApprovalMode: string
+ toggleBackendVersion: string
+ toggleCommandCenter: string
+ toggleTerminal: string
+ toggleVersion: string
+ toggleWorkspace: string
agents: string
closeAgents: string
openAgents: string
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index a7a9ec409e7..a233c98d32b 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -2558,6 +2558,13 @@ export const zh: Translations = {
gatewayOffline: '离线',
gatewayRestarting: '重启中…',
gatewayTitle: 'Hermes 推理网关状态',
+ customizeTitle: '在状态栏中显示',
+ toggleApprovalMode: '审批',
+ toggleBackendVersion: '后端版本',
+ toggleCommandCenter: '命令中心',
+ toggleTerminal: '终端',
+ toggleVersion: '版本与更新',
+ toggleWorkspace: '工作区',
agents: '代理',
closeAgents: '关闭代理',
openAgents: '打开代理',
diff --git a/apps/desktop/src/store/gateway-switch.test.ts b/apps/desktop/src/store/gateway-switch.test.ts
index 5473287c9fc..93f004834f8 100644
--- a/apps/desktop/src/store/gateway-switch.test.ts
+++ b/apps/desktop/src/store/gateway-switch.test.ts
@@ -11,9 +11,9 @@ import {
setCronSessions,
setFreshDraftReady,
setMessagingSessions,
+ setSessionProfilesTruncated,
setSessions,
- setSessionsLoading,
- setSessionProfilesTruncated
+ setSessionsLoading
} from '@/store/session'
import { $stalledSessionIds } from '@/store/session-states'
diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts
index 7b1a9c338c8..7dc677b9ca8 100644
--- a/apps/desktop/src/store/gateway-switch.ts
+++ b/apps/desktop/src/store/gateway-switch.ts
@@ -13,8 +13,8 @@ import {
setMessagingSessions,
setMessagingTruncated,
setSelectedStoredSessionId,
- setSessions,
setSessionProfilesTruncated,
+ setSessions,
setSessionsLoading
} from '@/store/session'
import { clearAllSessionStates } from '@/store/session-states'
diff --git a/apps/desktop/src/store/statusbar-prefs.ts b/apps/desktop/src/store/statusbar-prefs.ts
new file mode 100644
index 00000000000..3de11233762
--- /dev/null
+++ b/apps/desktop/src/store/statusbar-prefs.ts
@@ -0,0 +1,38 @@
+import { Codecs, persistentAtom } from '@/lib/persisted'
+
+const STATUSBAR_HIDDEN_STORAGE_KEY = 'hermes.desktop.statusbarHidden'
+
+// Items the bar hides until the user turns them on from its context menu. The
+// bar's job is to answer "is the backend healthy, where am I, what's it doing" —
+// route shortcuts (cron/webhooks/agents), the terminal toggle, and the approval
+// pill are navigation, not status, so they start out of the way.
+export const STATUSBAR_HIDDEN_BY_DEFAULT: readonly string[] = [
+ 'agents',
+ 'approval-mode',
+ 'cron',
+ 'terminal',
+ 'webhooks'
+]
+
+// Stored as the explicit hidden set (not the visible one) so an item added to
+// the bar in a later version shows up for existing users instead of silently
+// staying off. An empty array is a real value — the user turned everything on —
+// so this uses a sanitizing json codec rather than Codecs.stringArray, which
+// drops the key when empty and would resurrect the defaults on next launch.
+export const $statusbarHiddenIds = persistentAtom(
+ STATUSBAR_HIDDEN_STORAGE_KEY,
+ [...STATUSBAR_HIDDEN_BY_DEFAULT],
+ Codecs.json(value =>
+ Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string' && id.length > 0) : []
+ )
+)
+
+export function setStatusbarItemVisible(id: string, visible: boolean) {
+ const hidden = $statusbarHiddenIds.get()
+
+ if (visible === !hidden.includes(id)) {
+ return
+ }
+
+ $statusbarHiddenIds.set(visible ? hidden.filter(entry => entry !== id) : [...hidden, id])
+}