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]) +}