diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index f9a640000aba..e367e8058260 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -605,7 +605,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { // reopen paints from cache and revalidates in the background. const configQuery = useQuery({ queryKey: ['command-palette', 'config'], - queryFn: getHermesConfigRecord + queryFn: () => getHermesConfigRecord() }) const sessionsQuery = useQuery({ diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index da4ff828cb85..b288cd37b6f5 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -59,7 +59,7 @@ import { $previewOpenRequest, $previewTabs, closeRightRail } from '@/store/previ import { $reviewOpen, closeReview, openReview, REVIEW_PANE_ID } from '@/store/review' import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session' import { watchSessionPins } from '@/store/session-pin-sync' -import { $statusbarVisible } from '@/store/statusbar-prefs' +import { $statusbarVisible, toggleStatusbarVisible } from '@/store/statusbar-prefs' import type { SessionDragPayload } from '../chat/composer/inline-refs' import { watchRouteTiles } from '../chat/route-tile' @@ -302,7 +302,7 @@ registry.registerMany([ icon: PanelBottom, keywords: ['status bar', 'statusbar', 'bottom bar', 'hide', 'show', 'chrome'], get: () => $statusbarVisible.get(), - set: enabled => $statusbarVisible.set(enabled) + set: () => toggleStatusbarVisible() }), // The keybind panel's non-titlebar door (the keyboard icon is gone). { diff --git a/apps/desktop/src/app/contrib/surfaces.tsx b/apps/desktop/src/app/contrib/surfaces.tsx index 750248fbfebf..16441e8af4b9 100644 --- a/apps/desktop/src/app/contrib/surfaces.tsx +++ b/apps/desktop/src/app/contrib/surfaces.tsx @@ -13,6 +13,7 @@ import { Navigate, Route, Routes, useParams } from 'react-router' import { ContribBoundary } from '@/contrib/react/boundary' import { useContributions } from '@/contrib/react/use-contributions' +import { $desktopStatusbarMode } from '@/store/desktop-statusbar' import { $activeGatewayProfile } from '@/store/profile' import { $freshDraftReady, $gatewayState } from '@/store/session' @@ -76,6 +77,7 @@ export const StatusbarSurface = memo(function StatusbarSurface({ chatOpen: boolean commandCenterOpen: boolean }) { + const desktopStatusbarMode = useStore($desktopStatusbarMode) const gatewayState = useStore($gatewayState) const freshDraftReady = useStore($freshDraftReady) const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, actions.requestGateway) @@ -98,7 +100,7 @@ export const StatusbarSurface = memo(function StatusbarSurface({ toggleCommandCenter: actions.toggleCommandCenter }) - return + return }) /** The workspace pane: the real route table (chat + full-page views + plugin diff --git a/apps/desktop/src/app/hooks/use-config-record.ts b/apps/desktop/src/app/hooks/use-config-record.ts index ca4f00cb2a59..d5a3a72a32e8 100644 --- a/apps/desktop/src/app/hooks/use-config-record.ts +++ b/apps/desktop/src/app/hooks/use-config-record.ts @@ -1,8 +1,9 @@ import { useQuery } from '@tanstack/react-query' import { getHermesConfigRecord } from '@/hermes' -import { queryClient, writeCache } from '@/lib/query-client' -import type { HermesConfigRecord } from '@/types/hermes' +import { HERMES_CONFIG_KEY } from '@/store/hermes-config-record' + +export { invalidateHermesConfig, setHermesConfigCache } from '@/store/hermes-config-record' // One shared cache for the whole profile config record (`GET /api/config`). // Every settings surface (MCP, model, config) reads and writes through this key @@ -11,12 +12,6 @@ import type { HermesConfigRecord } from '@/types/hermes' // // Distinct from session/hooks/use-hermes-config.ts, which is side-effecting — // it pushes personality/cwd/voice/… into the session stores for live chat. -export const HERMES_CONFIG_KEY = ['hermes-config-record'] as const - // staleTime 0 → serve cache instantly, background-revalidate on every mount. export const useHermesConfigRecord = () => - useQuery({ queryKey: HERMES_CONFIG_KEY, queryFn: getHermesConfigRecord, staleTime: 0 }) - -export const setHermesConfigCache = writeCache(HERMES_CONFIG_KEY) - -export const invalidateHermesConfig = () => queryClient.invalidateQueries({ queryKey: HERMES_CONFIG_KEY }) + useQuery({ queryKey: HERMES_CONFIG_KEY, queryFn: () => getHermesConfigRecord(), staleTime: 0 }) diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts index ca0ca4dc0a45..0b94cd8b47c4 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { $terminalFontFamily, setTerminalFontFamilyFromConfig } from '@/app/right-sidebar/terminal/terminal-font' import { getHermesConfig } from '@/hermes' import { persistString } from '@/lib/storage' +import { $desktopStatusbarMode, applyDesktopStatusbarFromConfig } from '@/store/desktop-statusbar' import { $currentCwd, $currentFastMode, @@ -21,10 +22,14 @@ import { import { useHermesConfig } from './use-hermes-config' vi.mock('@/hermes', () => ({ + getApiRequestProfile: vi.fn(() => null), getHermesConfig: vi.fn(), - getHermesConfigDefaults: vi.fn().mockResolvedValue({}) + getHermesConfigDefaults: vi.fn().mockResolvedValue({}), + getHermesConfigRecord: vi.fn().mockResolvedValue({}), + saveHermesConfig: vi.fn().mockResolvedValue({ ok: true }) })) +const LEGACY_STATUSBAR_VISIBLE_STORAGE_KEY = 'hermes.desktop.statusbarVisible' const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd' function deferred() { @@ -49,6 +54,8 @@ describe('useHermesConfig refreshHermesConfig', () => { setCurrentReasoningEffort('') setDefaultReasoningEffort('') setTerminalFontFamilyFromConfig('') + persistString(LEGACY_STATUSBAR_VISIBLE_STORAGE_KEY, null) + applyDesktopStatusbarFromConfig({ display: { desktop_statusbar: 'off' } }) persistString(WORKSPACE_CWD_KEY, null) }) @@ -142,17 +149,50 @@ describe('useHermesConfig refreshHermesConfig', () => { refreshC = result.current.refreshHermesConfig(true) }) - profileC.resolve({ agent: { reasoning_effort: 'low', service_tier: 'normal' } }) + profileC.resolve({ + agent: { reasoning_effort: 'low', service_tier: 'normal' }, + display: { desktop_statusbar: 'auto-hide' } + }) await act(async () => { await refreshC }) - profileB.resolve({ agent: { reasoning_effort: 'high', service_tier: 'priority' } }) + profileB.resolve({ + agent: { reasoning_effort: 'high', service_tier: 'priority' }, + display: { desktop_statusbar: 'on' } + }) await act(async () => { await refreshB }) expect($currentReasoningEffort.get()).toBe('low') expect($currentFastMode.get()).toBe(false) + expect($desktopStatusbarMode.get()).toBe('auto-hide') + }) + + it('does not let an older ordinary refresh overwrite a newer status bar value', async () => { + const older = deferred>>() + const newer = deferred>>() + vi.mocked(getHermesConfig).mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise) + + const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } })) + + let olderRefresh!: Promise + let newerRefresh!: Promise + act(() => { + olderRefresh = result.current.refreshHermesConfig() + newerRefresh = result.current.refreshHermesConfig() + }) + + newer.resolve({ display: { desktop_statusbar: 'auto-hide' } }) + await act(async () => { + await newerRefresh + }) + older.resolve({ display: { desktop_statusbar: 'on' } }) + await act(async () => { + await olderRefresh + }) + + expect($desktopStatusbarMode.get()).toBe('auto-hide') }) it('loads the profile terminal font for already-mounted terminal surfaces', async () => { diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.ts index 1250e4ca0255..c98ae1385f65 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts @@ -4,6 +4,7 @@ import { setTerminalFontFamilyFromConfig } from '@/app/right-sidebar/terminal/te import { getHermesConfig, getHermesConfigDefaults } from '@/hermes' import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime' import { normalize } from '@/lib/text' +import { applyDesktopStatusbarFromConfig, migrateLegacyDesktopStatusbarPreference } from '@/store/desktop-statusbar' import { getComposerSelectionGeneration, getCurrentModelSource, @@ -56,11 +57,7 @@ export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) { const refreshHermesConfig = useCallback( async (force = false) => { - if (force) { - profileRefreshEpochRef.current += 1 - } - - const profileRefreshEpoch = profileRefreshEpochRef.current + const profileRefreshEpoch = ++profileRefreshEpochRef.current const selectionGeneration = getComposerSelectionGeneration() try { @@ -110,6 +107,8 @@ export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) { setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds)) setSttEnabled(config.stt?.enabled !== false) setTerminalFontFamilyFromConfig(config.terminal?.font_family) + applyDesktopStatusbarFromConfig(config) + void migrateLegacyDesktopStatusbarPreference(config).catch(() => undefined) applyAutoSpeakFromConfig(config) applyVoiceStopPhraseFromConfig(config) applyThinkingSoundFromConfig(config) diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 998f84bb6cfc..de77f459bc40 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -13,7 +13,13 @@ import { selectableCardClass } from '@/lib/selectable-card' import { normalize } from '@/lib/text' import { cn } from '@/lib/utils' import { $backdrop, setBackdrop } from '@/store/backdrop' +import { + $desktopStatusbarMode, + type DesktopStatusbarMode, + persistDesktopStatusbarMode +} from '@/store/desktop-statusbar' import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent' +import { notifyError } from '@/store/notifications' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { $reactionsEnabled, setReactionsEnabled } from '@/store/reactions-enabled' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' @@ -24,6 +30,8 @@ import { installVscodeThemeFromMarketplace } from '@/themes/install' import type { DesktopTheme } from '@/themes/types' import { $marketplaceInstalls, isUserTheme, removeUserTheme } from '@/themes/user-themes' +import { setHermesConfigCache } from '../hooks/use-config-record' + import { MODE_OPTIONS } from './constants' import { PetSettings } from './pet-settings' import { ListRow, SectionHeading, SettingsContent } from './primitives' @@ -244,7 +252,7 @@ function MarketplaceThemeResults({ ) } -export function AppearanceSettings() { +export function AppearanceSettings({ onConfigSaved }: { onConfigSaved?: () => void }) { const { t, isSavingLocale } = useI18n() const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme() const toolViewMode = useStore($toolViewMode) @@ -254,6 +262,7 @@ export function AppearanceSettings() { const translucency = useStore($translucency) const reactionsEnabled = useStore($reactionsEnabled) const backdrop = useStore($backdrop) + const desktopStatusbarMode = useStore($desktopStatusbarMode) const installs = useStore($marketplaceInstalls) const profiles = useStore($profiles) const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile)) @@ -299,6 +308,12 @@ export function AppearanceSettings() { const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` })) + const statusbarOptions = [ + { id: 'on', label: a.statusbarOn }, + { id: 'auto-hide', label: a.statusbarAutoHide }, + { id: 'off', label: a.statusbarOff } + ] as const satisfies readonly { id: DesktopStatusbarMode; label: string }[] + const matchedScalePreset = matchUiScalePreset(zoomPercent) return ( @@ -433,6 +448,26 @@ export function AppearanceSettings() { + { + triggerHaptic('selection') + void persistDesktopStatusbarMode(mode) + .then(config => { + setHermesConfigCache(config) + onConfigSaved?.() + }) + .catch(error => notifyError(error, a.statusbarSaveFailed)) + }} + options={statusbarOptions} + value={desktopStatusbarMode} + /> + } + description={a.statusbarDesc} + title={a.statusbarTitle} + /> + diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 51f9ab9c2dc6..428dc1bdc115 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -249,6 +249,7 @@ export const ENUM_OPTIONS: Record = { 'context.engine': ['compressor', 'default', 'custom'], // '' = inherit the agent's own effort; the rest is the shared scale. 'delegation.reasoning_effort': ['', ...REASONING_EFFORTS], + 'display.desktop_statusbar': ['on', 'off', 'auto-hide'], // NOTE: memory.provider is intentionally NOT listed here. Its options are // discovery-driven and served by the backend config schema (merged // per-request in web_server._schema_with_dynamic_provider_options), so diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index f55503702b48..7c6fd4e1f674 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -300,7 +300,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set {activeView === 'config:appearance' ? ( - + ) : activeView === 'about' ? ( ) : activeView === 'gateway' ? ( diff --git a/apps/desktop/src/app/shell/statusbar-controls.test.tsx b/apps/desktop/src/app/shell/statusbar-controls.test.tsx new file mode 100644 index 000000000000..da8bed214d8a --- /dev/null +++ b/apps/desktop/src/app/shell/statusbar-controls.test.tsx @@ -0,0 +1,113 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n' + +import { StatusbarControls } from './statusbar-controls' + +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) + +function renderStatusbar(mode: 'auto-hide' | 'off' | 'on') { + return render( + + + + + + ) +} + +describe('Desktop status bar visibility', () => { + it('renders normally in on mode', () => { + renderStatusbar('on') + + expect(screen.getByRole('contentinfo')).not.toBeNull() + expect(screen.queryByLabelText('Reveal the desktop status bar')).toBeNull() + }) + + it('does not render the status bar in off mode', () => { + renderStatusbar('off') + + expect(screen.queryByRole('contentinfo')).toBeNull() + }) + + it('keeps a keyboard-focusable bottom-edge reveal target in auto-hide mode', () => { + renderStatusbar('auto-hide') + + const revealZone = screen.getByLabelText('Reveal the desktop status bar') + const statusbar = screen.getByRole('contentinfo') + + expect(revealZone.getAttribute('tabindex')).toBe('0') + expect(statusbar.classList.contains('translate-y-full')).toBe(true) + expect(statusbar.classList.contains('opacity-0')).toBe(true) + }) + + it('stays revealed while focus is inside a portaled status-bar menu', async () => { + render( + + + + + + ) + + const revealZone = screen.getByLabelText('Reveal the desktop status bar') + const statusbar = screen.getByRole('contentinfo') + const trigger = screen.getByRole('button', { name: 'Session' }) + + fireEvent.pointerDown(trigger, { button: 0 }) + + const menuItem = await screen.findByRole('menuitem', { name: 'Settings' }) + menuItem.focus() + + expect(revealZone.contains(menuItem)).toBe(false) + expect(menuItem.ownerDocument.activeElement).toBe(menuItem) + expect(trigger.getAttribute('data-state')).toBe('open') + expect(statusbar.classList.contains('has-data-[state=open]:translate-y-0')).toBe(true) + expect(statusbar.classList.contains('has-data-[state=open]:opacity-100')).toBe(true) + }) + + it('stays revealed while focus is inside the portaled status-bar context menu', async () => { + renderStatusbar('auto-hide') + + const revealZone = screen.getByLabelText('Reveal the desktop status bar') + const statusbar = screen.getByRole('contentinfo') + + fireEvent.pointerDown(statusbar, { button: 2, ctrlKey: false, pointerType: 'mouse' }) + fireEvent.contextMenu(statusbar, { button: 2 }) + + const hideItem = await screen.findByRole('menuitem', { name: /hide status bar/i }) + hideItem.focus() + + expect(revealZone.contains(hideItem)).toBe(false) + expect(hideItem.ownerDocument.activeElement).toBe(hideItem) + expect(statusbar.getAttribute('data-state')).toBe('open') + expect(statusbar.classList.contains('data-[state=open]:translate-y-0')).toBe(true) + expect(statusbar.classList.contains('data-[state=open]:opacity-100')).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx index 855d016bffdd..4c1d544b2133 100644 --- a/apps/desktop/src/app/shell/statusbar-controls.tsx +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -16,6 +16,7 @@ import { Tip, TipKeybindLabel, Tooltip, TooltipContent, TooltipProvider, Tooltip import { useI18n } from '@/i18n' import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint' import { cn } from '@/lib/utils' +import type { DesktopStatusbarMode } from '@/store/desktop-statusbar' import { $statusbarHiddenIds, setStatusbarItemVisible, toggleStatusbarVisible } from '@/store/statusbar-prefs' // Shared chrome styling for interactive statusbar items (button / link / menu @@ -80,21 +81,39 @@ export type SetStatusbarItemGroup = (id: string, items: readonly StatusbarItem[] interface StatusbarControlsProps extends ComponentProps<'footer'> { leftItems?: readonly StatusbarItem[] items?: readonly StatusbarItem[] + mode?: DesktopStatusbarMode } -export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) { +export function StatusbarControls({ + className, + leftItems = [], + items = [], + mode = 'on', + ...props +}: StatusbarControlsProps) { + const { t } = useI18n() const navigate = useNavigate() const hiddenIds = useStore($statusbarHiddenIds) const visible = (item: StatusbarItem) => !item.hidden && (item.lockedVisible || !item.toggleLabel || !hiddenIds.includes(item.id)) - return ( + if (mode === 'off') { + return null + } + + // Radix portals menu content under document.body. Dropdown triggers keep + // data-state="open" inside the footer, while the ContextMenu trigger puts + // that state on the footer itself. Preserve both markers so moving focus or + // the pointer into either portal does not retract an auto-hidden bar. + const statusbar = (