diff --git a/apps/desktop/src/app/chat/sidebar/chrome.tsx b/apps/desktop/src/app/chat/sidebar/chrome.tsx index 2a0f728503a2..36d97e03f2c5 100644 --- a/apps/desktop/src/app/chat/sidebar/chrome.tsx +++ b/apps/desktop/src/app/chat/sidebar/chrome.tsx @@ -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 {children} diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 91dec1e2d58f..c1f00e5777c9 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -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 ? ( ) : 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} diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx index bcbefafa672d..bb0ae30cff12 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx @@ -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 ) } - count={isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length} + count={visibleSessions.length} icon={leadingIcon} label={group.label} onToggle={toggleOpen} diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts index 17a085113ddb..a55fdce81911 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -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`). */ diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index b0202e722a4d..4dbcfa6000ef 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -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) diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx index b5c3ef07217f..7ca89984ddf0 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx @@ -43,13 +43,13 @@ const row = (id: string, over: Partial = {}): 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 }, + recents: { sessions: SessionInfo[]; profiles_truncated?: Record }, 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)], - total: 3, - profile_totals: {} + sessions: [row('a'), row('b'), row('c', { _lineage_root_id: 'root-c' } as Partial)] }) ) @@ -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 ( -
- {/* `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. */} -
- {leftItems - .filter(item => !item.hidden) - .map(item => ( - - ))} -
-
- {items - .filter(item => !item.hidden) - .map(item => ( - - ))} -
-
+ + +
+ {/* `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. */} +
+ {leftItems.filter(visible).map(item => ( + + ))} +
+
+ {items.filter(visible).map(item => ( + + ))} +
+
+
+ +
+ ) +} + +/** 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