mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
Merge pull request #72336 from NousResearch/bb/statusbar-prefs
Quieter status bar and sidebar counts
This commit is contained in:
commit
e1ace0ac98
22 changed files with 416 additions and 139 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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`). */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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' }))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
99
apps/desktop/src/app/shell/statusbar-visibility.test.tsx
Normal file
99
apps/desktop/src/app/shell/statusbar-visibility.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2579,6 +2579,13 @@ export const zh: Translations = {
|
|||
gatewayOffline: '离线',
|
||||
gatewayRestarting: '重启中…',
|
||||
gatewayTitle: 'Hermes 推理网关状态',
|
||||
customizeTitle: '在状态栏中显示',
|
||||
toggleApprovalMode: '审批',
|
||||
toggleBackendVersion: '后端版本',
|
||||
toggleCommandCenter: '命令中心',
|
||||
toggleTerminal: '终端',
|
||||
toggleVersion: '版本与更新',
|
||||
toggleWorkspace: '工作区',
|
||||
agents: '代理',
|
||||
closeAgents: '关闭代理',
|
||||
openAgents: '打开代理',
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
|
|
|
|||
|
|
@ -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>) =>
|
||||
|
|
|
|||
38
apps/desktop/src/store/statusbar-prefs.ts
Normal file
38
apps/desktop/src/store/statusbar-prefs.ts
Normal 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])
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue