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/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 6994be8431c8..e1bf600d650d 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -38,7 +38,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 { @@ -57,6 +57,7 @@ import { $previewOpenRequest, $previewTabs, closeRightRail } from '@/store/previ 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' @@ -300,6 +301,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', @@ -648,6 +663,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/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() 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/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( { diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 459792634bb0..360cb1ab7c23 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', @@ -2424,6 +2425,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 654961571532..2b62a2aaf2c4 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2030,6 +2030,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 f4b3377edcf5..491bf53cc9be 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': '将终端选区发送到输入框', @@ -2600,6 +2601,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" —