Merge upstream main into feat/hermes-relay-shared-metrics

This commit is contained in:
Alex Fournier 2026-07-27 16:24:46 -07:00
commit d7b8a63eb7
13 changed files with 125 additions and 51 deletions

View file

@ -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'

View file

@ -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 (
<SidebarProvider
@ -714,8 +730,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>

View file

@ -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

View file

@ -26,11 +26,7 @@ function asRecord(payload: unknown): Record<string, unknown> {
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
}
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()

View file

@ -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)

View file

@ -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()
})
})

View file

@ -353,7 +353,7 @@ export function TreeGroup({
<ZoneMenu {...zoneMenu}>
<div
className={cn(
'flex h-full w-7 shrink-0 cursor-pointer select-none flex-col items-stretch bg-(--pane-tab-strip-bg) [--pane-tab-strip-bg:var(--theme-card-seed)]',
'flex h-full w-7 shrink-0 cursor-pointer select-none flex-col items-stretch bg-(--ui-sidebar-surface-background)',
// Strip line faces the content the zone collapsed away from.
railSide === 'right' ? PANE_TAB_STRIP_LINE_LEFT : PANE_TAB_STRIP_LINE_RIGHT
)}
@ -397,11 +397,11 @@ export function TreeGroup({
{headerVisible && (
<ZoneMenu {...zoneMenu}>
<div
// Active = sidebar surface (merges into body). Strip =
// `--theme-card-seed` (VS Code `tab.inactiveBackground`). No bottom
// rule — the active tab's primary underline is the only seam.
// Strip and active tab both sit on the sidebar surface, so the
// header reads as one piece of chrome with the titlebar above it.
// No bottom rule — the active tab's primary underline is the only seam.
// data-zone-tabstrip: a drop over here STACKS (drag-session reads it).
className="group/pane-header relative flex h-7 shrink-0 select-none bg-(--pane-tab-strip-bg) [-webkit-app-region:no-drag] [--pane-tab-active-bg:var(--ui-sidebar-surface-background)] [--pane-tab-strip-bg:var(--theme-card-seed)]"
className="group/pane-header relative flex h-7 shrink-0 select-none bg-(--ui-sidebar-surface-background) [-webkit-app-region:no-drag] [--pane-tab-active-bg:var(--ui-sidebar-surface-background)]"
data-zone-tabstrip={node.id}
onContextMenu={e => {
setMenuPane(

View file

@ -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<HTMLDivElement, PaneTabProps>(function PaneTab(
{

View file

@ -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',

View file

@ -2030,6 +2030,7 @@ export interface Translations {
gatewayRestarting: string
gatewayTitle: string
customizeTitle: string
hideStatusbar: string
toggleApprovalMode: string
toggleBackendVersion: string
toggleCommandCenter: string

View file

@ -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: '命令中心',

View file

@ -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: [] },

View file

@ -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" —