Merge pull request #72336 from NousResearch/bb/statusbar-prefs

Quieter status bar and sidebar counts
This commit is contained in:
brooklyn! 2026-07-26 20:10:16 -05:00 committed by GitHub
commit e1ace0ac98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 416 additions and 139 deletions

View file

@ -8,10 +8,6 @@ import { cn } from '@/lib/utils'
// sections and the project/workspace tree, so it lives outside either to keep
// imports one-directional (no index <-> projects cycle).
/** `loaded/total` when there's more on the server, else just the loaded count. */
export const countLabel = (loaded: number, total: number): string =>
total > loaded ? `${loaded}/${total}` : String(loaded)
/** The muted count chip next to a section/workspace label. */
export function SidebarCount({ children }: { children: React.ReactNode }) {
return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>

View file

@ -89,10 +89,9 @@ import {
$messagingPlatformTotals,
$messagingSessions,
$messagingTruncated,
$sessionProfileTotals,
$sessionProfilesTruncated,
$sessions,
$sessionsLoading,
$sessionsTotal,
sessionPinId,
setCurrentCwd
} from '@/store/session'
@ -108,7 +107,6 @@ import {
} from '../../routes'
import type { SidebarNavItem } from '../../types'
import { countLabel } from './chrome'
import { SidebarCronJobsSection } from './cron-jobs-section'
import { SidebarLoadMoreRow } from './load-more-row'
import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds } from './order'
@ -300,8 +298,7 @@ export function ChatSidebar({
const messagingPlatformTotals = useStore($messagingPlatformTotals)
const messagingTruncated = useStore($messagingTruncated)
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const sessionProfileTotals = useStore($sessionProfileTotals)
const sessionProfilesTruncated = useStore($sessionProfilesTruncated)
const workingSessionIds = useStore($workingSessionIds)
const profiles = useStore($profiles)
const profileScope = useStore($profileScope)
@ -937,7 +934,7 @@ export function ChatSidebar({
...group,
loadingMore: Boolean(profileLoadMorePending[group.id]),
onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0)
hasMore: Boolean(sessionProfilesTruncated[group.id])
}))
// default (root) first, then the rest alphabetically.
.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
@ -948,7 +945,7 @@ export function ChatSidebar({
loadMoreForProfileGroup,
onLoadMoreProfileSessions,
profileLoadMorePending,
sessionProfileTotals
sessionProfilesTruncated
])
// The flat Sessions list always shows ALL recent sessions; Projects is a
@ -956,22 +953,16 @@ export function ChatSidebar({
const displayAgentSessions = agentSessions
// Pagination is scope-aware. In "All profiles" mode it tracks the global
// unified set. When scoped to one profile it must compare that profile's own
// loaded rows against that profile's total — otherwise a huge default profile
// keeps "Load more" stuck on while you browse a small one (the aggregator's
// total sums every profile). Per-profile totals come from the aggregator
// (children excluded); fall back to the global total / loaded count.
// unified set; scoped to one profile it tracks that profile's own truncation
// flag — otherwise a huge default profile keeps "Load more" stuck on while
// you browse a small one. The backend reports whether its page was capped
// rather than an exact count, so no COUNT(*) runs per refresh.
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
const knownSessionTotal = Math.max(
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
loadedSessionCount
)
const hasMoreSessions = showAllProfiles
? Object.values(sessionProfilesTruncated).some(Boolean)
: Boolean(sessionProfilesTruncated[profileScope])
const hasMoreSessions = knownSessionTotal > loadedSessionCount
const recentsMeta = countLabel(displayAgentSessions.length, knownSessionTotal)
const displayRecentsCountRef = useRef(0)
const loadedRecentsCountRef = useRef(0)
displayRecentsCountRef.current = displayAgentSessions.length
@ -1390,9 +1381,7 @@ export function ChatSidebar({
reposScanning && !projectsSkeletonVisible ? (
<GlyphSpinner ariaLabel={s.loading} className="text-[0.6875rem] text-(--ui-text-quaternary)" />
) : undefined
) : (
recentsMeta
)
) : undefined
}
liveSessions={inProject ? agentSessions : undefined}
onArchiveSession={onArchiveSession}
@ -1458,7 +1447,7 @@ export function ChatSidebar({
platformName={group.label}
/>
}
labelMeta={countLabel(group.sessions.length, group.total)}
labelMeta={String(shownSessions.length)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}

View file

@ -9,7 +9,7 @@ import { notifyError } from '@/store/notifications'
import { newSessionInProfile } from '@/store/profile'
import { switchBranchInRepo } from '@/store/projects'
import { countLabel, SidebarRowStack } from '../chrome'
import { SidebarRowStack } from '../chrome'
import { SidebarLoadMoreRow } from '../load-more-row'
import { SIDEBAR_GROUP_PAGE, useWorkspaceNodeOpen } from './model'
@ -37,11 +37,13 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
const [visibleCount, setVisibleCount] = useState(SIDEBAR_GROUP_PAGE)
const loadedCount = group.sessions.length
// Profile groups know their on-disk total (children excluded); workspace
// groups only ever page within what's already loaded.
const totalCount = isProfileGroup ? Math.max(group.totalCount ?? loadedCount, loadedCount) : loadedCount
const visibleSessions = group.sessions.slice(0, visibleCount)
const hiddenCount = Math.max(0, totalCount - visibleSessions.length)
// Profile groups can have more rows on the server than are loaded — the
// aggregator reports `hasMore` so the lane can offer another page without
// pricing an exact total per refresh. Workspace groups only ever page within
// what's already loaded.
const hiddenLoaded = Math.max(0, loadedCount - visibleSessions.length)
const hiddenCount = isProfileGroup && group.hasMore ? Math.max(hiddenLoaded, 1) : hiddenLoaded
const nextCount = Math.min(SIDEBAR_GROUP_PAGE, hiddenCount)
// Leading glyph: profile color dot, a home mark for the repo's primary
@ -63,7 +65,7 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
setVisibleCount(target)
if (target > loadedCount && loadedCount < totalCount) {
if (target > loadedCount && group.hasMore) {
group.onLoadMore?.()
}
}
@ -119,7 +121,7 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
</div>
)
}
count={isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length}
count={visibleSessions.length}
icon={leadingIcon}
label={group.label}
onToggle={toggleOpen}

View file

@ -30,7 +30,10 @@ export interface SidebarSessionGroup {
mode?: 'profile' | 'source' | 'workspace'
onLoadMore?: () => void
sourceId?: string
totalCount?: number
/** Profile lanes only: the backend page was capped, so more rows exist on
* disk than were loaded. Replaces the old exact `totalCount`, which cost a
* COUNT(*) per profile on every sidebar refresh just to render `n/total`. */
hasMore?: boolean
}
/** A repo node: holds its branch/worktree lanes (`repo -> lane -> sessions`). */

View file

@ -53,7 +53,6 @@ import {
setSelectedStoredSessionId,
setSessions,
setSessionStartedAt,
setSessionsTotal,
setTurnStartedAt,
setYoloActive
} from '@/store/session'
@ -1293,9 +1292,6 @@ export function useSessionActions({
// the delete RPC is in flight, so a racing refresh can't flash it back.
tombstoneSessions(removedIds)
beginSessionMutation(removedIds)
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
// doesn't keep claiming the removed row is still on the server.
setSessionsTotal(prev => Math.max(0, prev - 1))
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId && id !== removedPinId))
// Tear down before awaiting so the route effect can't resume the
@ -1330,7 +1326,6 @@ export function useSessionActions({
} catch (err) {
if (removed) {
setSessions(prev => [removed, ...prev])
setSessionsTotal(prev => prev + 1)
}
untombstoneSessions(removedIds)
@ -1393,10 +1388,6 @@ export function useSessionActions({
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
tombstoneSessions(archivedIds)
beginSessionMutation(archivedIds)
// Archived sessions are hidden by the listSessions(min_messages=1) query
// on the next refresh, so they count as "removed" for the load-more
// footer math.
setSessionsTotal(prev => Math.max(0, prev - 1))
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId && id !== archivedPinId))
if (wasSelected) {
@ -1419,7 +1410,6 @@ export function useSessionActions({
} catch (err) {
if (archived) {
setSessions(prev => [archived, ...prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))])
setSessionsTotal(prev => prev + 1)
}
untombstoneSessions(archivedIds)

View file

@ -43,13 +43,13 @@ const row = (id: string, over: Partial<SessionInfo> = {}): SessionInfo =>
// separate listAllProfileSessions calls (each of which reopened every profile
// DB) — #66377-adjacent perf work from the desktop audit canvas.
const sidebar = (
recents: { sessions: SessionInfo[]; total?: number; profile_totals?: Record<string, number> },
recents: { sessions: SessionInfo[]; profiles_truncated?: Record<string, boolean> },
cron: SessionInfo[] = [],
messaging: SessionInfo[] = []
): SidebarSessionsResponse => ({
recents: { sessions: recents.sessions, total: recents.total, profile_totals: recents.profile_totals },
recents: { sessions: recents.sessions, profiles_truncated: recents.profiles_truncated },
cron: { sessions: cron },
messaging: { sessions: messaging, total: messaging.length }
messaging: { sessions: messaging }
})
const listSidebarSessions = vi.fn()
@ -90,7 +90,7 @@ afterEach(() => {
describe('refreshSessions identity + loading hygiene', () => {
it('keeps the previous $sessions array when the refresh is content-identical', async () => {
const rows = [row('a'), row('b')]
listSidebarSessions.mockResolvedValue(sidebar({ sessions: rows, total: 2, profile_totals: { default: 2 } }))
listSidebarSessions.mockResolvedValue(sidebar({ sessions: rows }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
@ -103,7 +103,7 @@ describe('refreshSessions identity + loading hygiene', () => {
// Second refresh returns fresh (but equal) row objects, as the API does.
listSidebarSessions.mockResolvedValue(
sidebar({ sessions: [row('a'), row('b')], total: 2, profile_totals: { default: 2 } })
sidebar({ sessions: [row('a'), row('b')] })
)
await act(async () => {
@ -114,7 +114,7 @@ describe('refreshSessions identity + loading hygiene', () => {
})
it('swaps the array when rows actually changed', async () => {
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')] }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
await act(async () => {
@ -124,7 +124,7 @@ describe('refreshSessions identity + loading hygiene', () => {
const first = $sessions.get()
listSidebarSessions.mockResolvedValue(
sidebar({ sessions: [row('a', { last_active: 2000, title: 'Renamed' })], total: 1, profile_totals: {} })
sidebar({ sessions: [row('a', { last_active: 2000, title: 'Renamed' })] })
)
await act(async () => {
@ -136,7 +136,7 @@ describe('refreshSessions identity + loading hygiene', () => {
})
it('does not flicker the loading flag over a populated list', async () => {
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')] }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
await act(async () => {
@ -162,9 +162,7 @@ describe('refreshSessions identity + loading hygiene', () => {
removed.ids = new Set(['b', 'root-c'])
listSidebarSessions.mockResolvedValue(
sidebar({
sessions: [row('a'), row('b'), row('c', { _lineage_root_id: 'root-c' } as Partial<SessionInfo>)],
total: 3,
profile_totals: {}
sessions: [row('a'), row('b'), row('c', { _lineage_root_id: 'root-c' } as Partial<SessionInfo>)]
})
)
@ -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' }))

View file

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

View file

@ -245,8 +245,12 @@ export function useStatusbarItems({
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
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 ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
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: <Command className="size-3.5" />,
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,

View file

@ -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 (
<footer
className={cn(
'flex h-5 shrink-0 items-stretch justify-between gap-2 border-t border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background) px-1 py-0 text-(--ui-text-tertiary) [-webkit-app-region:no-drag]',
className
)}
data-slot="statusbar"
{...props}
>
{/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item for
example "Connecting…" on a fresh/untitled session can't paint a
horizontal scrollbar across the bottom of the window. Items already
`truncate` their labels, so clipping is the right behavior. */}
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
{leftItems
.filter(item => !item.hidden)
.map(item => (
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
))}
</div>
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
{items
.filter(item => !item.hidden)
.map(item => (
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
))}
</div>
</footer>
<ContextMenu>
<ContextMenuTrigger asChild>
<footer
className={cn(
'flex h-5 shrink-0 items-stretch justify-between gap-2 border-t border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background) px-1 py-0 text-(--ui-text-tertiary) [-webkit-app-region:no-drag]',
className
)}
data-slot="statusbar"
{...props}
>
{/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item for
example "Connecting…" on a fresh/untitled session can't paint a
horizontal scrollbar across the bottom of the window. Items already
`truncate` their labels, so clipping is the right behavior. */}
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
{leftItems.filter(visible).map(item => (
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
))}
</div>
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
{items.filter(visible).map(item => (
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
))}
</div>
</footer>
</ContextMenuTrigger>
<StatusbarVisibilityMenu hiddenIds={hiddenIds} items={items} leftItems={leftItems} />
</ContextMenu>
)
}
/** 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<string>()
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 (
<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>
))}
</ContextMenuContent>
)
}

View file

@ -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> = {}): StatusbarItem => ({
id,
label,
toggleLabel: label,
variant: 'action',
...extra
})
function bar(items: StatusbarItem[]) {
render(
<MemoryRouter>
<StatusbarControls items={items} />
</MemoryRouter>
)
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()
})
})

View file

@ -58,6 +58,30 @@ function ContextMenuItem({
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
data-slot="context-menu-checkbox-item"
{...props}
>
{children}
<ContextMenuPrimitive.ItemIndicator className="ml-auto flex items-center pl-2 text-foreground">
<Codicon name="check" size="0.75rem" />
</ContextMenuPrimitive.ItemIndicator>
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuLabel({
className,
inset,
@ -142,6 +166,7 @@ function ContextMenuSubContent({
export {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,

View file

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

View file

@ -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<string, number>
/** 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<string, boolean>
}
/** 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<string, boolean> {
const counts = new Map<string, number>()
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<S
const errors = [...(recents.errors ?? []), ...(cron.errors ?? []), ...(messaging.errors ?? [])]
return {
recents: { profile_totals: recents.profile_totals, sessions: recents.sessions, total: recents.total },
recents: {
profiles_truncated: profilesTruncatedFrom(recents.sessions, req.recentsLimit),
sessions: recents.sessions
},
cron: { sessions: cron.sessions },
messaging: { sessions: messaging.sessions },
...(errors.length ? { errors } : {})

View file

@ -2403,6 +2403,13 @@ export const en: Translations = {
gatewayOffline: 'offline',
gatewayRestarting: 'restarting…',
gatewayTitle: 'Hermes inference gateway status',
customizeTitle: 'Show in status bar',
toggleApprovalMode: 'Approvals',
toggleBackendVersion: 'Backend version',
toggleCommandCenter: 'Command Center',
toggleTerminal: 'Terminal',
toggleVersion: 'Version & updates',
toggleWorkspace: 'Workspace',
agents: 'Agents',
closeAgents: 'Close agents',
openAgents: 'Open agents',

View file

@ -2009,6 +2009,13 @@ export interface Translations {
gatewayOffline: string
gatewayRestarting: string
gatewayTitle: string
customizeTitle: string
toggleApprovalMode: string
toggleBackendVersion: string
toggleCommandCenter: string
toggleTerminal: string
toggleVersion: string
toggleWorkspace: string
agents: string
closeAgents: string
openAgents: string

View file

@ -2579,6 +2579,13 @@ export const zh: Translations = {
gatewayOffline: '离线',
gatewayRestarting: '重启中…',
gatewayTitle: 'Hermes 推理网关状态',
customizeTitle: '在状态栏中显示',
toggleApprovalMode: '审批',
toggleBackendVersion: '后端版本',
toggleCommandCenter: '命令中心',
toggleTerminal: '终端',
toggleVersion: '版本与更新',
toggleWorkspace: '工作区',
agents: '代理',
closeAgents: '关闭代理',
openAgents: '打开代理',

View file

@ -5,15 +5,15 @@ import {
$cronSessions,
$freshDraftReady,
$messagingSessions,
$sessionProfilesTruncated,
$sessions,
$sessionsLoading,
$sessionsTotal,
setCronSessions,
setFreshDraftReady,
setMessagingSessions,
setSessionProfilesTruncated,
setSessions,
setSessionsLoading,
setSessionsTotal
setSessionsLoading
} from '@/store/session'
import { $stalledSessionIds } from '@/store/session-states'
@ -27,7 +27,7 @@ describe('wipeSessionListsForGatewaySwitch', () => {
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([])

View file

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

View file

@ -270,7 +270,6 @@ export function mergeSessionPage(
export const $connection = atom<HermesConnection | null>(null)
export const $gatewayState = atom<ConnectionState>('idle')
export const $sessions = atom<SessionInfo[]>([])
export const $sessionsTotal = atom<number>(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<Record<string, number>>({})
// 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<boolean>(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<Record<string, number>>({})
// 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<Record<string, boolean>>({})
export const $sessionsLoading = atom(true)
export const $activeSessionId = atom<string | null>(null)
export const $selectedStoredSessionId = atom<string | null>(null)
@ -376,14 +377,13 @@ export const $sessionPickerOpen = atom(false)
export const setConnection = (next: Updater<HermesConnection | null>) => updateAtom($connection, next)
export const setGatewayState = (next: Updater<ConnectionState>) => updateAtom($gatewayState, next)
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)
export const setCronSessions = (next: Updater<SessionInfo[]>) => updateAtom($cronSessions, next)
export const setMessagingSessions = (next: Updater<SessionInfo[]>) => updateAtom($messagingSessions, next)
export const setMessagingPlatformTotals = (next: Updater<Record<string, number>>) =>
updateAtom($messagingPlatformTotals, next)
export const setMessagingTruncated = (next: Updater<boolean>) => updateAtom($messagingTruncated, next)
export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =>
updateAtom($sessionProfileTotals, next)
export const setSessionProfilesTruncated = (next: Updater<Record<string, boolean>>) =>
updateAtom($sessionProfilesTruncated, next)
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)
export const setActiveSessionStoredIdRotation = (next: Updater<ActiveSessionStoredIdRotation | null>) =>

View file

@ -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<string[]>(
STATUSBAR_HIDDEN_STORAGE_KEY,
[...STATUSBAR_HIDDEN_BY_DEFAULT],
Codecs.json<string[]>(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])
}

View file

@ -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": {

View file

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