)]
})
)
@@ -178,7 +176,7 @@ describe('refreshSessions identity + loading hygiene', () => {
})
it('still shows loading for the initial (empty-list) fetch', async () => {
- listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
+ listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')] }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
const loadingStates: boolean[] = []
@@ -200,7 +198,7 @@ describe('refreshSessions batches slices into one request', () => {
const messaging = [row('m1', { source: 'telegram', title: 'tg chat' })]
listSidebarSessions.mockResolvedValue(
- sidebar({ sessions: recents, total: 2, profile_totals: { default: 2 } }, cron, messaging)
+ sidebar({ sessions: recents }, cron, messaging)
)
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
@@ -220,7 +218,7 @@ describe('refreshSessions batches slices into one request', () => {
})
it('forwards the active profile scope + section limits to the batched call', async () => {
- listSidebarSessions.mockResolvedValue(sidebar({ sessions: [], total: 0, profile_totals: {} }))
+ listSidebarSessions.mockResolvedValue(sidebar({ sessions: [] }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'work' }))
await act(async () => {
@@ -238,7 +236,7 @@ describe('refreshSessions batches slices into one request', () => {
it('scopes the cron-jobs fetch to the active profile (all → unified view)', async () => {
const { getCronJobs } = await import('@/hermes')
- listSidebarSessions.mockResolvedValue(sidebar({ sessions: [], total: 0, profile_totals: {} }))
+ listSidebarSessions.mockResolvedValue(sidebar({ sessions: [] }))
const scoped = renderHook(() => useSessionListActions({ profileScope: 'work' }))
diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts
index a94573226293..611484e0d739 100644
--- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts
+++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts
@@ -23,10 +23,9 @@ import {
setMessagingPlatformTotals,
setMessagingSessions,
setMessagingTruncated,
- setSessionProfileTotals,
+ setSessionProfilesTruncated,
setSessions,
- setSessionsLoading,
- setSessionsTotal
+ setSessionsLoading
} from '@/store/session'
import { $workingSessionIds, getRecentlySettledSessionIds } from '@/store/session-states'
@@ -202,9 +201,13 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
return sameCronSignature(prev, next) ? prev : next
})
- setSessionsTotal(typeof recents.total === 'number' ? recents.total : recents.sessions.length)
- setSessionProfileTotals(prev => {
- const next = recents.profile_totals ?? {}
+ // "Is there another page?" instead of an exact total: the backend
+ // reports which profiles filled their window, which costs nothing on
+ // top of the rows it already read (the old exact totals ran a COUNT(*)
+ // per profile DB on every refresh). Reference-stable when unchanged so
+ // the sidebar's group memos don't recompute per refresh.
+ setSessionProfilesTruncated(prev => {
+ const next = recents.profiles_truncated ?? {}
const prevKeys = Object.keys(prev)
return prevKeys.length === Object.keys(next).length && prevKeys.every(key => prev[key] === next[key])
@@ -258,8 +261,9 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
...mergeSessionPage(prev.filter(inKey), result.sessions, keep)
])
- const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length
- setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
+ // A full window back means the profile still has more on disk.
+ const truncated = result.sessions.length >= loaded + SIDEBAR_SESSIONS_PAGE_SIZE
+ setSessionProfilesTruncated(prev => ({ ...prev, [key]: truncated }))
}, [])
return {
diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
index a2841a1bbe85..b365a97e722a 100644
--- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
+++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
@@ -245,8 +245,12 @@ export function useStatusbarItems({
icon: applying ? : ,
id: 'version-client',
label,
+ // Update state is not a preference: hiding it is how a user misses that
+ // their client is behind. Listed in the menu, but locked on.
+ lockedVisible: true,
onSelect: () => openUpdateOverlayFor('client'),
title: tooltip || undefined,
+ toggleLabel: copy.toggleVersion,
variant: 'action'
}
}, [
@@ -295,8 +299,10 @@ export function useStatusbarItems({
icon: applying ? : ,
id: 'version-backend',
label,
+ lockedVisible: true,
onSelect: () => openUpdateOverlayFor('backend'),
title: tooltip || undefined,
+ toggleLabel: copy.toggleBackendVersion,
variant: 'action'
}
}, [
@@ -346,8 +352,12 @@ export function useStatusbarItems({
className: `w-7 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
icon: ,
id: 'command-center',
+ // The system icon: the way into every other surface, including the
+ // settings that would bring a hidden item back. Never hideable.
+ lockedVisible: true,
onSelect: toggleCommandCenter,
title: commandCenterOpen ? copy.closeCommandCenter : copy.openCommandCenter,
+ toggleLabel: copy.toggleCommandCenter,
variant: 'action'
},
{
@@ -365,6 +375,7 @@ export function useStatusbarItems({
menuClassName: 'w-72',
menuContent: gatewayMenuContent,
title: inferenceStatus?.reason || copy.gatewayTitle,
+ toggleLabel: copy.gateway,
variant: 'menu'
},
{
@@ -398,6 +409,7 @@ export function useStatusbarItems({
]
: undefined,
title: currentCwd || undefined,
+ toggleLabel: copy.toggleWorkspace,
variant: 'menu'
},
{
@@ -423,6 +435,7 @@ export function useStatusbarItems({
label: copy.agents,
onSelect: openAgents,
title: agentsOpen ? copy.closeAgents : copy.openAgents,
+ toggleLabel: copy.agents,
variant: 'action'
},
{
@@ -431,6 +444,7 @@ export function useStatusbarItems({
label: copy.cron,
title: copy.openCron,
to: CRON_ROUTE,
+ toggleLabel: copy.cron,
variant: 'action'
},
{
@@ -439,6 +453,7 @@ export function useStatusbarItems({
label: copy.webhooks,
title: copy.openWebhooks,
to: WEBHOOKS_ROUTE,
+ toggleLabel: copy.webhooks,
variant: 'action'
}
],
@@ -504,7 +519,8 @@ export function useStatusbarItems({
},
{
...approvalModeItem,
- hidden: gatewayState !== 'open'
+ hidden: gatewayState !== 'open',
+ toggleLabel: copy.toggleApprovalMode
},
{
actionId: 'view.showTerminal',
@@ -514,6 +530,7 @@ export function useStatusbarItems({
id: 'terminal',
onSelect: () => setTerminalTakeover(!$terminalTakeover.get()),
title: terminalTakeover ? copy.hideTerminal : copy.showTerminal,
+ toggleLabel: copy.toggleTerminal,
variant: 'action'
},
clientVersionItem,
diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx
index 58376559ce09..9e3950626ba4 100644
--- a/apps/desktop/src/app/shell/statusbar-controls.tsx
+++ b/apps/desktop/src/app/shell/statusbar-controls.tsx
@@ -1,9 +1,20 @@
-import { type ComponentProps, memo, type ReactNode, useState } from 'react'
+import { useStore } from '@nanostores/react'
+import { type ComponentProps, memo, type ReactNode, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
+import {
+ ContextMenu,
+ ContextMenuCheckboxItem,
+ ContextMenuContent,
+ ContextMenuLabel,
+ ContextMenuSeparator,
+ ContextMenuTrigger
+} from '@/components/ui/context-menu'
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 { cn } from '@/lib/utils'
+import { $statusbarHiddenIds, setStatusbarItemVisible } from '@/store/statusbar-prefs'
// Shared chrome styling for interactive statusbar items (button / link / menu
// trigger). The 'text' variant intentionally omits hover/transition/disabled.
@@ -47,6 +58,14 @@ export interface StatusbarItem {
title?: string
to?: string
variant?: 'action' | 'link' | 'menu' | 'text'
+ /** Plain-text name for the bar's right-click show/hide menu. An item without
+ * one is never listed there and always shows — the safe default for plugin
+ * contributions that don't opt in. */
+ toggleLabel?: string
+ /** Listed in the menu but not switchable: the bar's own affordances (command
+ * center, update/version pills) would strand the user if they could be
+ * hidden from the surface that hides them. */
+ lockedVisible?: boolean
}
export interface StatusbarSelectModifiers {
@@ -63,35 +82,98 @@ interface StatusbarControlsProps extends ComponentProps<'footer'> {
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
const navigate = useNavigate()
+ const hiddenIds = useStore($statusbarHiddenIds)
+
+ const visible = (item: StatusbarItem) =>
+ !item.hidden && (item.lockedVisible || !item.toggleLabel || !hiddenIds.includes(item.id))
return (
-
+
+
+
+
+
+
+ )
+}
+
+/** 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. */
+function StatusbarVisibilityMenu({
+ hiddenIds,
+ items,
+ leftItems
+}: {
+ hiddenIds: readonly string[]
+ items: readonly StatusbarItem[]
+ leftItems: readonly StatusbarItem[]
+}) {
+ const { t } = useI18n()
+ const copy = t.shell.statusbar
+
+ // Deduped by id: an item can legitimately appear in both clusters across
+ // renders (contributions move sides), and a repeated checkbox would let one
+ // row's toggle silently contradict the other's.
+ const toggles = useMemo(() => {
+ const seen = new Set()
+
+ return [...leftItems, ...items].filter(item => {
+ if (!item.toggleLabel || seen.has(item.id)) {
+ return false
+ }
+
+ seen.add(item.id)
+
+ return true
+ })
+ }, [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}
+
+ ))}
+
)
}
diff --git a/apps/desktop/src/app/shell/statusbar-visibility.test.tsx b/apps/desktop/src/app/shell/statusbar-visibility.test.tsx
new file mode 100644
index 000000000000..cb26f286aa8c
--- /dev/null
+++ b/apps/desktop/src/app/shell/statusbar-visibility.test.tsx
@@ -0,0 +1,99 @@
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
+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'
+
+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()
+ $statusbarHiddenIds.set([...STATUSBAR_HIDDEN_BY_DEFAULT])
+})
+
+const item = (id: string, label: string, extra: Partial = {}): StatusbarItem => ({
+ id,
+ label,
+ toggleLabel: label,
+ variant: 'action',
+ ...extra
+})
+
+function bar(items: StatusbarItem[]) {
+ render(
+
+
+
+ )
+
+ return screen.getByRole('contentinfo')
+}
+
+/** Radix opens a ContextMenu on contextmenu after a pointerdown positions it. */
+function openContextMenu(target: HTMLElement) {
+ fireEvent.pointerDown(target, { button: 2, ctrlKey: false, pointerType: 'mouse' })
+ fireEvent.contextMenu(target, { button: 2 })
+}
+
+describe('statusbar item visibility', () => {
+ it('hides the route/toggle items out of the box and keeps status items', () => {
+ bar([
+ item('cron', 'Cron'),
+ item('webhooks', 'Webhooks'),
+ item('agents', 'Agents'),
+ item('terminal', 'Terminal'),
+ item('approval-mode', 'Approvals'),
+ item('gateway-health', 'Gateway')
+ ])
+
+ for (const label of ['Cron', 'Webhooks', 'Agents', 'Terminal', 'Approvals']) {
+ expect(screen.queryByText(label)).toBeNull()
+ }
+
+ expect(screen.getByText('Gateway')).toBeTruthy()
+ })
+
+ it('shows an item once the user enables it from the bar context menu', async () => {
+ const statusbar = bar([item('cron', 'Cron'), item('gateway-health', 'Gateway')])
+
+ expect(screen.queryByText('Cron')).toBeNull()
+
+ openContextMenu(statusbar)
+
+ const row = await screen.findByRole('menuitemcheckbox', { name: 'Cron' })
+ fireEvent.click(row)
+
+ expect($statusbarHiddenIds.get()).not.toContain('cron')
+ expect(within(statusbar).getByText('Cron')).toBeTruthy()
+ })
+
+ it('never lets the user hide a locked item (system icon / update pill)', async () => {
+ const statusbar = bar([item('command-center', 'Command Center', { lockedVisible: true })])
+
+ openContextMenu(statusbar)
+
+ const row = await screen.findByRole('menuitemcheckbox', { name: 'Command Center' })
+ expect(row.getAttribute('data-disabled')).not.toBeNull()
+ expect(row.getAttribute('aria-checked')).toBe('true')
+ })
+
+ it('leaves items that never opted into the menu alone', () => {
+ $statusbarHiddenIds.set(['plugin-thing'])
+ bar([{ id: 'plugin-thing', label: 'Plugin thing', variant: 'action' }])
+
+ expect(screen.getByText('Plugin thing')).toBeTruthy()
+ })
+})
diff --git a/apps/desktop/src/components/ui/context-menu.tsx b/apps/desktop/src/components/ui/context-menu.tsx
index 1652d68b9f70..286418baf907 100644
--- a/apps/desktop/src/components/ui/context-menu.tsx
+++ b/apps/desktop/src/components/ui/context-menu.tsx
@@ -58,6 +58,30 @@ function ContextMenuItem({
)
}
+function ContextMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
function ContextMenuLabel({
className,
inset,
@@ -142,6 +166,7 @@ function ContextMenuSubContent({
export {
ContextMenu,
+ ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts
index f829471c5a33..6b4775742368 100644
--- a/apps/desktop/src/hermes.test.ts
+++ b/apps/desktop/src/hermes.test.ts
@@ -150,8 +150,9 @@ describe('Hermes REST helpers', () => {
// Slices reassembled from the legacy per-slice route with the same
// scoping: recents on the caller's profile, cron + messaging cross-profile.
expect(result.recents.sessions.map(s => s.id)).toEqual(['recent-1'])
- expect(result.recents.total).toBe(7)
- expect(result.recents.profile_totals).toEqual({ default: 7 })
+ // One row back against a 30-row window: the profile is fully loaded, so
+ // the legacy path must not claim there's another page.
+ expect(result.recents.profiles_truncated).toEqual({ default: false })
expect(result.cron.sessions.map(s => s.id)).toEqual(['cron-1'])
expect(result.messaging.sessions.map(s => s.id)).toEqual(['msg-1'])
diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts
index 27a9e2346803..5d19debb4287 100644
--- a/apps/desktop/src/hermes.ts
+++ b/apps/desktop/src/hermes.ts
@@ -420,8 +420,24 @@ export async function listAllProfileSessions(
// splices remote profiles per slice (see interceptSessionRequestForRemote).
export interface SidebarSessionSlice {
sessions: SessionInfo[]
- total?: number
- profile_totals?: Record
+ /** Per-profile "the window came back full, more rows exist on disk" flags —
+ * what pagination needs, without a COUNT(*) per profile DB per refresh. */
+ profiles_truncated?: Record
+}
+
+/** Which profiles filled their per-profile window in a returned page. The
+ * legacy per-slice endpoint doesn't report this, so derive it from the rows:
+ * a profile at (or over) the cap still has more on disk. */
+function profilesTruncatedFrom(sessions: SessionInfo[], cap: number): Record {
+ const counts = new Map()
+
+ for (const session of sessions) {
+ const key = session.profile || 'default'
+
+ counts.set(key, (counts.get(key) ?? 0) + 1)
+ }
+
+ return Object.fromEntries([...counts].map(([name, count]) => [name, count >= cap]))
}
export interface SidebarSessionsResponse {
@@ -494,7 +510,10 @@ async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise {
beforeEach(() => {
$gatewaySwitching.set(false)
setSessions([{ id: 's1', title: 'old', profile: 'default' } as never])
- setSessionsTotal(1)
+ setSessionProfilesTruncated({ default: true })
setCronSessions([{ id: 'c1', title: 'cron', profile: 'default' } as never])
setMessagingSessions([{ id: 'm1', title: 'tg', profile: 'default' } as never])
$stalledSessionIds.set(['s1'])
@@ -50,7 +50,7 @@ describe('wipeSessionListsForGatewaySwitch', () => {
wipeSessionListsForGatewaySwitch()
expect($sessions.get()).toEqual([])
- expect($sessionsTotal.get()).toBe(0)
+ expect($sessionProfilesTruncated.get()).toEqual({})
expect($cronSessions.get()).toEqual([])
expect($messagingSessions.get()).toEqual([])
expect($stalledSessionIds.get()).toEqual([])
diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts
index df5227af9e10..507ade879c00 100644
--- a/apps/desktop/src/store/gateway-switch.ts
+++ b/apps/desktop/src/store/gateway-switch.ts
@@ -14,10 +14,9 @@ import {
setMessagingSessions,
setMessagingTruncated,
setSelectedStoredSessionId,
- setSessionProfileTotals,
+ setSessionProfilesTruncated,
setSessions,
- setSessionsLoading,
- setSessionsTotal
+ setSessionsLoading
} from '@/store/session'
import { clearAllSessionStates } from '@/store/session-states'
@@ -43,8 +42,7 @@ export function wipeSessionListsForGatewaySwitch(): void {
// "batched sidebar endpoint missing" capability verdict across the switch.
resetSidebarBatchCapability()
setSessions([])
- setSessionsTotal(0)
- setSessionProfileTotals({})
+ setSessionProfilesTruncated({})
setCronSessions([])
setMessagingSessions([])
setMessagingPlatformTotals({})
diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts
index 31ea5ff832a2..340770936d0b 100644
--- a/apps/desktop/src/store/session.ts
+++ b/apps/desktop/src/store/session.ts
@@ -270,7 +270,6 @@ export function mergeSessionPage(
export const $connection = atom(null)
export const $gatewayState = atom('idle')
export const $sessions = atom([])
-export const $sessionsTotal = atom(0)
// Cron-job sessions (source === 'cron') are fetched as their own list so the
// scheduler's always-newest sessions never crowd recents out of the page
// budget. Powers the collapsed "Cron jobs" sidebar section.
@@ -294,11 +293,13 @@ export const $messagingPlatformTotals = atom>({})
// True when the combined seed fetch hit MESSAGING_SECTION_LIMIT, so at least
// one platform may have more rows on disk than were loaded.
export const $messagingTruncated = atom(false)
-// Listable conversation count per profile (children excluded), keyed by profile
-// name. Lets the sidebar scope its "Load more" footer to the active profile so a
-// huge default profile doesn't keep "Load more" visible while browsing a small
-// one. Empty for single-profile users (fall back to $sessionsTotal).
-export const $sessionProfileTotals = atom>({})
+// Whether a profile's last session page was CAPPED by the request limit, keyed
+// by profile name — i.e. more rows exist on disk than were loaded. Replaces the
+// old exact per-profile totals: rendering `loaded/total` in the sidebar cost a
+// COUNT(*) per profile DB on every refresh and only ever confused people, while
+// "is there another page?" is what pagination actually needs and comes free
+// from the row count the query already returned.
+export const $sessionProfilesTruncated = atom>({})
export const $sessionsLoading = atom(true)
export const $activeSessionId = atom(null)
export const $selectedStoredSessionId = atom(null)
@@ -376,14 +377,13 @@ export const $sessionPickerOpen = atom(false)
export const setConnection = (next: Updater) => updateAtom($connection, next)
export const setGatewayState = (next: Updater) => updateAtom($gatewayState, next)
export const setSessions = (next: Updater) => updateAtom($sessions, next)
-export const setSessionsTotal = (next: Updater) => updateAtom($sessionsTotal, next)
export const setCronSessions = (next: Updater) => updateAtom($cronSessions, next)
export const setMessagingSessions = (next: Updater) => updateAtom($messagingSessions, next)
export const setMessagingPlatformTotals = (next: Updater>) =>
updateAtom($messagingPlatformTotals, next)
export const setMessagingTruncated = (next: Updater) => updateAtom($messagingTruncated, next)
-export const setSessionProfileTotals = (next: Updater>) =>
- updateAtom($sessionProfileTotals, next)
+export const setSessionProfilesTruncated = (next: Updater>) =>
+ updateAtom($sessionProfilesTruncated, next)
export const setSessionsLoading = (next: Updater) => updateAtom($sessionsLoading, next)
export const setActiveSessionId = (next: Updater) => updateAtom($activeSessionId, next)
export const setActiveSessionStoredIdRotation = (next: Updater) =>
diff --git a/apps/desktop/src/store/statusbar-prefs.ts b/apps/desktop/src/store/statusbar-prefs.ts
new file mode 100644
index 000000000000..3de112337625
--- /dev/null
+++ b/apps/desktop/src/store/statusbar-prefs.ts
@@ -0,0 +1,38 @@
+import { Codecs, persistentAtom } from '@/lib/persisted'
+
+const STATUSBAR_HIDDEN_STORAGE_KEY = 'hermes.desktop.statusbarHidden'
+
+// 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" —
+// route shortcuts (cron/webhooks/agents), the terminal toggle, and the approval
+// pill are navigation, not status, so they start out of the way.
+export const STATUSBAR_HIDDEN_BY_DEFAULT: readonly string[] = [
+ 'agents',
+ 'approval-mode',
+ 'cron',
+ 'terminal',
+ 'webhooks'
+]
+
+// Stored as the explicit hidden set (not the visible one) so an item added to
+// the bar in a later version shows up for existing users instead of silently
+// staying off. An empty array is a real value — the user turned everything on —
+// so this uses a sanitizing json codec rather than Codecs.stringArray, which
+// drops the key when empty and would resurrect the defaults on next launch.
+export const $statusbarHiddenIds = persistentAtom(
+ STATUSBAR_HIDDEN_STORAGE_KEY,
+ [...STATUSBAR_HIDDEN_BY_DEFAULT],
+ Codecs.json(value =>
+ Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string' && id.length > 0) : []
+ )
+)
+
+export function setStatusbarItemVisible(id: string, visible: boolean) {
+ const hidden = $statusbarHiddenIds.get()
+
+ if (visible === !hidden.includes(id)) {
+ return
+ }
+
+ $statusbarHiddenIds.set(visible ? hidden.filter(entry => entry !== id) : [...hidden, id])
+}
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index 5ae0010ca472..9f7933286f22 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -5076,8 +5076,7 @@ def get_profiles_sessions_sidebar(
recents_rows: List[Dict[str, Any]] = []
cron_rows: List[Dict[str, Any]] = []
messaging_rows: List[Dict[str, Any]] = []
- recents_total = 0
- recents_profile_totals: Dict[str, int] = {}
+ recents_truncated: Dict[str, bool] = {}
errors: List[Dict[str, str]] = []
now = time.time()
@@ -5116,18 +5115,13 @@ def get_profiles_sessions_sidebar(
continue
try:
if recents_scope == "all" or name == recents_scope:
- recents_rows.extend(
- _tag(_slice(db, exclude=recents_exclude_list, cap=recents_cap), name)
- )
- rtotal = db.session_count(
- exclude_sources=recents_exclude_list or None,
- min_message_count=1,
- include_archived=False,
- archived_only=False,
- exclude_children=True,
- )
- recents_total += rtotal
- recents_profile_totals[name] = rtotal
+ profile_rows = _slice(db, exclude=recents_exclude_list, cap=recents_cap)
+ # A full window means more rows remain on disk. That is all the
+ # sidebar's "load more" needs, and unlike an exact COUNT(*) per
+ # profile per refresh it costs nothing beyond the rows already
+ # read.
+ recents_truncated[name] = len(profile_rows) >= recents_cap
+ recents_rows.extend(_tag(profile_rows, name))
cron_rows.extend(_tag(_slice(db, source="cron", cap=cron_cap), name))
messaging_rows.extend(
_tag(_slice(db, exclude=messaging_exclude_list, cap=messaging_cap), name)
@@ -5146,8 +5140,7 @@ def get_profiles_sessions_sidebar(
return {
"recents": {
"sessions": _window(recents_rows, recents_cap),
- "total": recents_total,
- "profile_totals": recents_profile_totals,
+ "profiles_truncated": recents_truncated,
},
"cron": {"sessions": _window(cron_rows, cron_cap)},
"messaging": {
diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py
index 070c86943d23..24ef3d0f8c40 100644
--- a/tests/hermes_cli/test_web_server.py
+++ b/tests/hermes_cli/test_web_server.py
@@ -2186,7 +2186,9 @@ class TestWebServerEndpoints:
assert row["profile"] == "default"
assert row["is_default_profile"] is True
assert isinstance(data.get("errors"), list)
- assert data["recents"]["total"] >= 1
+ # Pagination reports "was this window capped?" per profile, not an exact
+ # COUNT(*) — one row against a 20-row cap means nothing more to load.
+ assert data["recents"]["profiles_truncated"]["default"] is False
def test_sessions_endpoint_reads_requested_profile(self):
"""The machine dashboard's global profile switcher must retarget