From 58c8d86bd5c98e09c2e1b31cc3754e86817e4881 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 17:47:39 -0500 Subject: [PATCH 1/3] feat(desktop): let the status bar be hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar is always-on chrome today. Hiding it is `⌘⇧S`, the ⌘K palette, or the bottom row of its own right-click menu — VS Code's set of doors, minus their unbound default (they ship `toggleStatusbarVisibility` with no keybinding and Hermes has no chord dispatcher for a `⌘K ⌘S` two-stroke). Hidden unmounts the bar rather than hiding it, so the 15s status poll and the per-turn readouts stop with it. Visibility persists per window profile and defaults on. --- apps/desktop/src/app/contrib/controller.tsx | 24 +++++++- apps/desktop/src/app/hooks/use-keybinds.ts | 2 + .../src/app/shell/statusbar-controls.tsx | 59 ++++++++++++------- .../app/shell/statusbar-visibility.test.tsx | 31 +++++++++- apps/desktop/src/i18n/en.ts | 2 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh.ts | 2 + apps/desktop/src/lib/keybinds/actions.ts | 5 ++ apps/desktop/src/store/statusbar-prefs.ts | 10 ++++ 9 files changed, 110 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index cd40143dcd8b..678f131cf7f2 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -36,7 +36,7 @@ import { useContributions } from '@/contrib/react/use-contributions' import { registry } from '@/contrib/registry' import { discoverRuntimePlugins } from '@/contrib/runtime-loader' import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime' -import { LayoutDashboard } from '@/lib/icons' +import { LayoutDashboard, PanelBottom } from '@/lib/icons' import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions' import { Codecs, persistentAtom } from '@/lib/persisted' import { $artifactTabs } from '@/store/artifacts' @@ -56,6 +56,7 @@ import { $filePreviewTabs, $filePreviewTarget, $previewTarget, closeRightRail } import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review' import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session' import { watchSessionPins } from '@/store/session-pin-sync' +import { $statusbarVisible, toggleStatusbarVisible } from '@/store/statusbar-prefs' import type { SessionDragPayload } from '../chat/composer/inline-refs' import { watchRouteTiles } from '../chat/route-tile' @@ -299,6 +300,20 @@ registry.registerMany([ run: resetLayoutTree } satisfies PaletteContribution }, + // Hiding the bar removes the surface that would otherwise offer it back, so + // ⌘K is the guaranteed door in (alongside the rebindable ⌘⇧S). + { + id: 'view.toggleStatusbar', + area: PALETTE_AREA, + data: { + id: 'view.toggleStatusbar', + label: 'Toggle status bar', + action: 'view.toggleStatusbar', + icon: PanelBottom, + keywords: ['status bar', 'statusbar', 'bottom bar', 'hide', 'show', 'chrome'], + run: toggleStatusbarVisible + } satisfies PaletteContribution + }, // The keybind panel's non-titlebar door (the keyboard icon is gone). { id: 'keybinds.panel', @@ -641,6 +656,7 @@ function TitlebarSlot({ area, className, style }: TitlebarSlotProps) { export function ContribController() { const sidebarOpen = useStore($sidebarOpen) + const statusbarVisible = useStore($statusbarVisible) return ( {/* The REAL statusbar (model pill, command center, agents, …) with - statusBar.left/right contributions merged in. */} - + statusBar.left/right contributions merged in. Unmounted — not + just hidden — while toggled off, so its 15s status poll and the + per-turn readouts stop with it. */} + {statusbarVisible && } diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 7e49029d1cea..66d74e9411f2 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -48,6 +48,7 @@ import { switcherActive, switcherJustClosed } from '@/store/session-switcher' +import { toggleStatusbarVisible } from '@/store/statusbar-prefs' import { openNewWindow } from '@/store/windows' import { useTheme } from '@/themes/context' @@ -174,6 +175,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { 'view.toggleRightSidebar': () => layoutHasRootSide('right') ? toggleFileBrowserOpen() : setTerminalTakeover(!$terminalTakeover.get()), 'view.toggleReview': toggleReview, + 'view.toggleStatusbar': toggleStatusbarVisible, 'view.showFiles': showFiles, 'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()), // Create first so the pane's open-effect ensure sees a non-empty set and diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx index a33608b5edd6..7dbc9335f71e 100644 --- a/apps/desktop/src/app/shell/statusbar-controls.tsx +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -6,6 +6,7 @@ import { ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, + ContextMenuItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuTrigger @@ -13,8 +14,9 @@ import { 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 { useKeybindHint } from '@/lib/keybinds/use-keybind-hint' import { cn } from '@/lib/utils' -import { $statusbarHiddenIds, setStatusbarItemVisible } from '@/store/statusbar-prefs' +import { $statusbarHiddenIds, setStatusbarItemVisible, toggleStatusbarVisible } from '@/store/statusbar-prefs' // Shared chrome styling for interactive statusbar items (button / link / menu // trigger). The 'text' variant intentionally omits hover/transition/disabled. @@ -121,7 +123,8 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr /** 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. */ + * menu reads like the surface it edits. Hiding the whole bar lives at the + * bottom — VS Code puts it on the same context menu. */ function StatusbarVisibilityMenu({ hiddenIds, items, @@ -151,32 +154,44 @@ function StatusbarVisibilityMenu({ }) }, [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} - - ))} + {toggles.length > 0 && ( + <> + {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} + + ))} + + + )} + + {copy.hideStatusbar} + + ) } +/** The live ⌘⇧S hint on the hide row — the way back once the bar is gone. */ +function StatusbarHideHint() { + const hint = useKeybindHint('view.toggleStatusbar') + + return hint ? {hint} : null +} + /** Memoized: `useStatusbarItems` rebuilds the item array whenever ANY of its * inputs change, but each individual item object is usually identical across * those rebuilds. Without this, one changed item (the running timer, say) diff --git a/apps/desktop/src/app/shell/statusbar-visibility.test.tsx b/apps/desktop/src/app/shell/statusbar-visibility.test.tsx index 04defbe29479..9d334a0330dc 100644 --- a/apps/desktop/src/app/shell/statusbar-visibility.test.tsx +++ b/apps/desktop/src/app/shell/statusbar-visibility.test.tsx @@ -3,7 +3,12 @@ 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' +import { + $statusbarHiddenIds, + $statusbarVisible, + STATUSBAR_HIDDEN_BY_DEFAULT, + toggleStatusbarVisible +} from '@/store/statusbar-prefs' class TestResizeObserver { observe() {} @@ -22,6 +27,7 @@ beforeAll(() => { afterEach(() => { cleanup() $statusbarHiddenIds.set([...STATUSBAR_HIDDEN_BY_DEFAULT]) + $statusbarVisible.set(true) }) const item = (id: string, label: string, extra: Partial = {}): StatusbarItem => ({ @@ -118,3 +124,26 @@ describe('statusbar item visibility', () => { expect(within(statusbar).getByText('Session timer')).toBeTruthy() }) }) + +describe('whole-bar visibility', () => { + it('hides the bar from the context menu, leaving the keybind as the way back', async () => { + const statusbar = bar([item('gateway-health', 'Gateway')]) + + openContextMenu(statusbar) + fireEvent.click(await screen.findByRole('menuitem', { name: /hide status bar/i })) + + expect($statusbarVisible.get()).toBe(false) + + toggleStatusbarVisible() + expect($statusbarVisible.get()).toBe(true) + }) + + it('offers the hide row even when no item opted into the show/hide list', async () => { + const statusbar = bar([{ id: 'plugin-thing', label: 'Plugin thing', variant: 'action' }]) + + openContextMenu(statusbar) + + expect(await screen.findByRole('menuitem', { name: /hide status bar/i })).toBeTruthy() + expect(screen.queryByRole('menuitemcheckbox')).toBeNull() + }) +}) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 962116d280f4..18ef5fe917ad 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -257,6 +257,7 @@ export const en: Translations = { 'view.toggleSidebar': 'Toggle sessions sidebar', 'view.toggleRightSidebar': 'Toggle file browser', 'view.toggleReview': 'Toggle review pane', + 'view.toggleStatusbar': 'Toggle status bar', 'view.showFiles': 'Show file browser', 'view.showTerminal': 'Toggle terminal', 'view.newTerminal': 'New terminal', @@ -2427,6 +2428,7 @@ export const en: Translations = { gatewayRestarting: 'restarting…', gatewayTitle: 'Hermes inference gateway status', customizeTitle: 'Show in status bar', + hideStatusbar: 'Hide status bar', toggleApprovalMode: 'Approvals', toggleBackendVersion: 'Backend version', toggleCommandCenter: 'Command Center', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 8187517b0619..a7e90bed02c1 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2033,6 +2033,7 @@ export interface Translations { gatewayRestarting: string gatewayTitle: string customizeTitle: string + hideStatusbar: string toggleApprovalMode: string toggleBackendVersion: string toggleCommandCenter: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index bc46aa6aaf4e..2b9e9c5b29ef 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -252,6 +252,7 @@ export const zh: Translations = { 'view.toggleSidebar': '切换会话侧边栏', 'view.toggleRightSidebar': '切换文件浏览器', 'view.toggleReview': '切换审查面板', + 'view.toggleStatusbar': '切换状态栏', 'view.showFiles': '显示文件浏览器', 'view.showTerminal': '显示终端', 'view.terminalSelection': '将终端选区发送到输入框', @@ -2603,6 +2604,7 @@ export const zh: Translations = { gatewayRestarting: '重启中…', gatewayTitle: 'Hermes 推理网关状态', customizeTitle: '在状态栏中显示', + hideStatusbar: '隐藏状态栏', toggleApprovalMode: '审批', toggleBackendVersion: '后端版本', toggleCommandCenter: '命令中心', diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index 618b386d2f33..368ab48e6bd9 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -100,6 +100,11 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ // ── View (layout + appearance + the shortcuts panel itself) ─────────────── { id: 'view.toggleSidebar', category: 'view', defaults: ['mod+b'] }, { id: 'view.toggleRightSidebar', category: 'view', defaults: ['mod+j'] }, + // ⌘⇧S — "s" for status bar. VS Code ships + // `workbench.action.toggleStatusbarVisibility` unbound (it's a chord-free + // gap in their View family) and Hermes has no chord dispatcher, so this + // takes the nearest free single combo instead of a ⌘K ⌘S two-stroke. + { id: 'view.toggleStatusbar', category: 'view', defaults: ['mod+shift+s'] }, // ⌘G — "g" for git; the review pane is the source-control view. { id: 'view.toggleReview', category: 'view', defaults: ['mod+g'] }, { id: 'view.showFiles', category: 'view', defaults: [] }, diff --git a/apps/desktop/src/store/statusbar-prefs.ts b/apps/desktop/src/store/statusbar-prefs.ts index 177aa393f08a..cc3a330ae348 100644 --- a/apps/desktop/src/store/statusbar-prefs.ts +++ b/apps/desktop/src/store/statusbar-prefs.ts @@ -1,6 +1,16 @@ import { Codecs, persistentAtom } from '@/lib/persisted' 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`. 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, true, Codecs.bool) + +export function toggleStatusbarVisible() { + $statusbarVisible.set(!$statusbarVisible.get()) +} // 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" — From f4e042f4665e0f981b5df003aebbd6ef971770b8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 18:02:20 -0500 Subject: [PATCH 2/3] style(desktop): sit every pane tab strip on the sidebar surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strips painted with `--theme-card-seed` — the raw, unmixed seed rather than a surface token — so they read as their own band beside the sidebar and titlebar, which share `--ui-bg-sidebar`. Fixed at the default in `PaneTab` rather than per strip. The right-rail preview strip never set the vars at all, so its inactive tabs fell through to the seed even though its container was already on the sidebar surface; correcting the fallback fixes that one for free and keeps the next strip from regressing. With the default right, the two zone strips no longer need to redeclare the var and paint the token directly. --- .../pane-shell/tree/renderer/tree-group.tsx | 10 +++++----- apps/desktop/src/components/ui/pane-tab.tsx | 14 ++++++++------ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index fc920ff2ca7a..b46d4068479e 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -353,7 +353,7 @@ export function TreeGroup({
{ setMenuPane( diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx index 5606e9a7f288..eb553a770e5a 100644 --- a/apps/desktop/src/components/ui/pane-tab.tsx +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -23,10 +23,12 @@ const TAB_ACTIVE = 'h-full text-foreground [--tab-bg:var(--pane-tab-active-bg,va // so it costs no layout and can't shift the tab. const TAB_ACTIVE_UNDERLINE = 'shadow-[inset_0_-2px_0_var(--pane-tab-active-accent,var(--theme-primary))]' -// Inactive = gutter. Hover DARKENS: active is the lighter content surface, so a -// lightening wash made the two nearly indistinguishable. +// Inactive = gutter, defaulting to the shared chrome surface so a strip that +// sets no vars still matches the sidebar/titlebar instead of falling through to +// the raw (unmixed) card seed. Hover DARKENS: surfaces this close in value need +// a darkening wash to register at all. const TAB_IDLE = - 'text-(--ui-text-tertiary) [--tab-bg:var(--pane-tab-strip-bg,var(--theme-card-seed))] hover:shadow-[inset_0_0_0_100vmax_color-mix(in_srgb,#000_var(--ui-tab-hover-darken),transparent)] hover:text-(--ui-text-secondary)' + 'text-(--ui-text-tertiary) [--tab-bg:var(--pane-tab-strip-bg,var(--ui-sidebar-surface-background))] hover:shadow-[inset_0_0_0_100vmax_color-mix(in_srgb,#000_var(--ui-tab-hover-darken),transparent)] hover:text-(--ui-text-secondary)' interface PaneTabProps extends React.ComponentProps<'div'> { active?: boolean @@ -49,9 +51,9 @@ const isMetaClose = (event: { button: number; metaKey: boolean }) => event.butto /** * Editor tab shell — preview rail + zone headers + collapsed vertical rails. * - * Strip sets `--pane-tab-active-bg` (content surface) and `--pane-tab-strip-bg` - * (gutter; prefer `--theme-card-seed` = VS Code `tab.inactiveBackground`). - * Active merges into content; inactive sits flush in the gutter. + * Defaults need no vars: the active tab takes the editor surface, inactive the + * sidebar one. Override `--pane-tab-active-bg` to change what the active tab + * merges into, `--pane-tab-strip-bg` for a gutter unlike the bar around it. */ export const PaneTab = React.forwardRef(function PaneTab( { From 96db67b849e78d9c4c0bc0e1efc6dc95f22bcbcc Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:12:24 +0000 Subject: [PATCH 3/3] fmt(js): `npm run fix` on merge (#72967) Co-authored-by: github-actions[bot] --- apps/desktop/src/app/chat/composer/index.tsx | 10 +--------- .../src/app/session/hooks/use-preview-routing.ts | 6 +----- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 91029f83de79..18aa1b97801a 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1,14 +1,6 @@ import { ComposerPrimitive } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { - type ClipboardEvent, - type FormEvent, - type KeyboardEvent, - useCallback, - useEffect, - useMemo, - useRef -} from 'react' +import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useRef } from 'react' import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock' import { Button } from '@/components/ui/button' diff --git a/apps/desktop/src/app/session/hooks/use-preview-routing.ts b/apps/desktop/src/app/session/hooks/use-preview-routing.ts index eaf0eb3cca6a..409cb51c0504 100644 --- a/apps/desktop/src/app/session/hooks/use-preview-routing.ts +++ b/apps/desktop/src/app/session/hooks/use-preview-routing.ts @@ -26,11 +26,7 @@ function asRecord(payload: unknown): Record { return payload && typeof payload === 'object' ? (payload as Record) : {} } -export function usePreviewRouting({ - baseHandleGatewayEvent, - currentCwd, - requestGateway -}: PreviewRoutingOptions) { +export function usePreviewRouting({ baseHandleGatewayEvent, currentCwd, requestGateway }: PreviewRoutingOptions) { const restartPreviewServer = useCallback( async (url: string, context?: string) => { const sessionId = $focusedRuntimeId.get()