From 0f7492f43aa30b6e67d1ffb0cc2c61e62c3794d5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 19:36:08 -0500 Subject: [PATCH 1/2] refactor(sidebar): drop session counts and the COUNT(*) that fed them The sidebar labelled sections and workspace lanes `loaded/total`, which read as a progress bar people expected to fill up rather than a count of loaded rows. Pricing that label cost a COUNT(*) per profile database on every sidebar refresh, purely so the numerator and denominator could differ. Pagination only needs to know whether another page exists, and that comes free from the rows the query already returned: a window that comes back full means more remain on disk. Sections now show the loaded count alone, and the backend reports per-profile `profiles_truncated` flags in place of `total` / `profile_totals`. --- apps/desktop/src/app/chat/sidebar/chrome.tsx | 4 -- apps/desktop/src/app/chat/sidebar/index.tsx | 37 +++++++------------ .../chat/sidebar/projects/workspace-group.tsx | 16 ++++---- .../chat/sidebar/projects/workspace-groups.ts | 5 ++- .../hooks/use-session-actions/index.ts | 10 ----- .../hooks/use-session-list-actions.test.tsx | 28 +++++++------- .../session/hooks/use-session-list-actions.ts | 20 ++++++---- apps/desktop/src/hermes.test.ts | 5 ++- apps/desktop/src/hermes.ts | 25 +++++++++++-- apps/desktop/src/store/gateway-switch.test.ts | 8 ++-- apps/desktop/src/store/gateway-switch.ts | 8 ++-- apps/desktop/src/store/session.ts | 18 ++++----- hermes_cli/web_server.py | 25 +++++-------- tests/hermes_cli/test_web_server.py | 4 +- 14 files changed, 104 insertions(+), 109 deletions(-) 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/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 291b803283a9..7b1a9c338c85 100644 --- a/apps/desktop/src/store/gateway-switch.ts +++ b/apps/desktop/src/store/gateway-switch.ts @@ -13,10 +13,9 @@ import { setMessagingSessions, setMessagingTruncated, setSelectedStoredSessionId, - setSessionProfileTotals, setSessions, - setSessionsLoading, - setSessionsTotal + setSessionProfilesTruncated, + setSessionsLoading } from '@/store/session' import { clearAllSessionStates } from '@/store/session-states' @@ -42,8 +41,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/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 From c5336b472e4a1876af8410578ad45d4d1ddfc313 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 19:36:18 -0500 Subject: [PATCH 2/2] feat(statusbar): right-click to choose what the bar shows The status bar shipped every affordance it had, so approvals, the terminal toggle, agents, cron and webhooks sat there permanently for users who never touched them. Those five now start hidden and the bar owns a context menu that turns them back on, persisted per install. Items opt in by naming themselves with `toggleLabel`, so a plugin contribution that doesn't opt in always shows; the system icon and the version/update pills are listed but locked on, since hiding the way back into settings strands the user. Preferences store the hidden set rather than the visible one, so an item added to the bar in a later version appears for existing users instead of staying silently off. --- .../app/shell/hooks/use-statusbar-items.tsx | 19 ++- .../src/app/shell/statusbar-controls.tsx | 138 ++++++++++++++---- .../app/shell/statusbar-visibility.test.tsx | 99 +++++++++++++ .../src/components/ui/context-menu.tsx | 25 ++++ apps/desktop/src/i18n/en.ts | 7 + apps/desktop/src/i18n/types.ts | 7 + apps/desktop/src/i18n/zh.ts | 7 + apps/desktop/src/store/gateway-switch.test.ts | 4 +- apps/desktop/src/store/gateway-switch.ts | 2 +- apps/desktop/src/store/statusbar-prefs.ts | 38 +++++ 10 files changed, 314 insertions(+), 32 deletions(-) create mode 100644 apps/desktop/src/app/shell/statusbar-visibility.test.tsx create mode 100644 apps/desktop/src/store/statusbar-prefs.ts 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/i18n/en.ts b/apps/desktop/src/i18n/en.ts index c1d59a013b85..e651746789a1 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2381,6 +2381,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', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 90506fe92378..a572bbd8b6e9 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1990,6 +1990,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 diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index a7a9ec409e79..a233c98d32b3 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2558,6 +2558,13 @@ export const zh: Translations = { gatewayOffline: '离线', gatewayRestarting: '重启中…', gatewayTitle: 'Hermes 推理网关状态', + customizeTitle: '在状态栏中显示', + toggleApprovalMode: '审批', + toggleBackendVersion: '后端版本', + toggleCommandCenter: '命令中心', + toggleTerminal: '终端', + toggleVersion: '版本与更新', + toggleWorkspace: '工作区', agents: '代理', closeAgents: '关闭代理', openAgents: '打开代理', diff --git a/apps/desktop/src/store/gateway-switch.test.ts b/apps/desktop/src/store/gateway-switch.test.ts index 5473287c9fcb..93f004834f85 100644 --- a/apps/desktop/src/store/gateway-switch.test.ts +++ b/apps/desktop/src/store/gateway-switch.test.ts @@ -11,9 +11,9 @@ import { setCronSessions, setFreshDraftReady, setMessagingSessions, + setSessionProfilesTruncated, setSessions, - setSessionsLoading, - setSessionProfilesTruncated + setSessionsLoading } from '@/store/session' import { $stalledSessionIds } from '@/store/session-states' diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts index 7b1a9c338c85..7dc677b9ca88 100644 --- a/apps/desktop/src/store/gateway-switch.ts +++ b/apps/desktop/src/store/gateway-switch.ts @@ -13,8 +13,8 @@ import { setMessagingSessions, setMessagingTruncated, setSelectedStoredSessionId, - setSessions, setSessionProfilesTruncated, + setSessions, setSessionsLoading } from '@/store/session' import { clearAllSessionStates } from '@/store/session-states' 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]) +}