mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
feat(desktop): let the status bar be hidden
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.
This commit is contained in:
parent
b429194478
commit
58c8d86bd5
9 changed files with 110 additions and 26 deletions
|
|
@ -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 (
|
||||
<SidebarProvider
|
||||
|
|
@ -707,8 +723,10 @@ export function ContribController() {
|
|||
<SessionTileCloseConfirm />
|
||||
|
||||
{/* The REAL statusbar (model pill, command center, agents, …) with
|
||||
statusBar.left/right contributions merged in. */}
|
||||
<WiredPane part="statusbar" />
|
||||
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 && <WiredPane part="statusbar" />}
|
||||
</div>
|
||||
</ContribWiring>
|
||||
</SidebarProvider>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<ContextMenuContent className="w-52">
|
||||
<ContextMenuLabel>{copy.customizeTitle}</ContextMenuLabel>
|
||||
<ContextMenuSeparator />
|
||||
{toggles.map(item => (
|
||||
<ContextMenuCheckboxItem
|
||||
checked={item.lockedVisible || !hiddenIds.includes(item.id)}
|
||||
disabled={item.lockedVisible}
|
||||
key={item.id}
|
||||
onCheckedChange={checked => 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()}
|
||||
>
|
||||
<span className="truncate">{item.toggleLabel}</span>
|
||||
</ContextMenuCheckboxItem>
|
||||
))}
|
||||
{toggles.length > 0 && (
|
||||
<>
|
||||
<ContextMenuLabel>{copy.customizeTitle}</ContextMenuLabel>
|
||||
<ContextMenuSeparator />
|
||||
{toggles.map(item => (
|
||||
<ContextMenuCheckboxItem
|
||||
checked={item.lockedVisible || !hiddenIds.includes(item.id)}
|
||||
disabled={item.lockedVisible}
|
||||
key={item.id}
|
||||
onCheckedChange={checked => 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()}
|
||||
>
|
||||
<span className="truncate">{item.toggleLabel}</span>
|
||||
</ContextMenuCheckboxItem>
|
||||
))}
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onSelect={toggleStatusbarVisible}>
|
||||
<span className="truncate">{copy.hideStatusbar}</span>
|
||||
<StatusbarHideHint />
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 ? <span className="ml-auto pl-2 text-(--ui-text-quaternary)">{hint}</span> : 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)
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -2033,6 +2033,7 @@ export interface Translations {
|
|||
gatewayRestarting: string
|
||||
gatewayTitle: string
|
||||
customizeTitle: string
|
||||
hideStatusbar: string
|
||||
toggleApprovalMode: string
|
||||
toggleBackendVersion: string
|
||||
toggleCommandCenter: string
|
||||
|
|
|
|||
|
|
@ -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: '命令中心',
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
|
|
|
|||
|
|
@ -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" —
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue