From c5336b472e4a1876af8410578ad45d4d1ddfc313 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 19:36:18 -0500 Subject: [PATCH] feat(statusbar): right-click to choose what the bar shows The status bar shipped every affordance it had, so approvals, the terminal toggle, agents, cron and webhooks sat there permanently for users who never touched them. Those five now start hidden and the bar owns a context menu that turns them back on, persisted per install. Items opt in by naming themselves with `toggleLabel`, so a plugin contribution that doesn't opt in always shows; the system icon and the version/update pills are listed but locked on, since hiding the way back into settings strands the user. Preferences store the hidden set rather than the visible one, so an item added to the bar in a later version appears for existing users instead of staying silently off. --- .../app/shell/hooks/use-statusbar-items.tsx | 19 ++- .../src/app/shell/statusbar-controls.tsx | 138 ++++++++++++++---- .../app/shell/statusbar-visibility.test.tsx | 99 +++++++++++++ .../src/components/ui/context-menu.tsx | 25 ++++ apps/desktop/src/i18n/en.ts | 7 + apps/desktop/src/i18n/types.ts | 7 + apps/desktop/src/i18n/zh.ts | 7 + apps/desktop/src/store/gateway-switch.test.ts | 4 +- apps/desktop/src/store/gateway-switch.ts | 2 +- apps/desktop/src/store/statusbar-prefs.ts | 38 +++++ 10 files changed, 314 insertions(+), 32 deletions(-) create mode 100644 apps/desktop/src/app/shell/statusbar-visibility.test.tsx create mode 100644 apps/desktop/src/store/statusbar-prefs.ts 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 ( -
- {/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item — for - example "Connecting…" on a fresh/untitled session — can't paint a - horizontal scrollbar across the bottom of the window. Items already - `truncate` their labels, so clipping is the right behavior. */} -
- {leftItems - .filter(item => !item.hidden) - .map(item => ( - - ))} -
-
- {items - .filter(item => !item.hidden) - .map(item => ( - - ))} -
-
+ + +
+ {/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item — for + example "Connecting…" on a fresh/untitled session — can't paint a + horizontal scrollbar across the bottom of the window. Items already + `truncate` their labels, so clipping is the right behavior. */} +
+ {leftItems.filter(visible).map(item => ( + + ))} +
+
+ {items.filter(visible).map(item => ( + + ))} +
+
+
+ +
+ ) +} + +/** 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]) +}