mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
perf(desktop): gate hot store subscriptions + memoize overlay nav + palette rows
Command Center, Settings, Skills, and Command Palette all subscribed to hot stores unconditionally — ticks on every streaming token (title updates), on every connect/disconnect. Components that only need that data on one tab were re-rendering on every stream delta while sitting on an unrelated tab. Fixes: - CommandCenterView: gate / to the Sessions tab via useStoreSelector returning a stable empty array on other tabs - SkillsView: gate to the MCP tab only - SettingsView: memoize navGroups (was rebuilt inline on every render with fresh onSelect closures for every nav item) - OverlayNavItem: memo() so nav items don't re-render when a sibling's active state changes - CommandPalette: extract memoized PaletteRow so items don't re-render on unrelated parent state changes (open/close, theme, etc) - CommandCenterView: memoize navGroups array (was inline JSX)
This commit is contained in:
parent
7bfbfa3e34
commit
d65b226497
5 changed files with 207 additions and 169 deletions
|
|
@ -1,4 +1,3 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { type MouseEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { LogTail } from '@/components/chat/log-tail'
|
||||
|
|
@ -13,6 +12,7 @@ import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/
|
|||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { compactNumber } from '@/lib/format'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
|
|
@ -48,6 +48,12 @@ const LOG_LEVELS = ['ALL', 'INFO', 'WARNING', 'ERROR'] as const
|
|||
const USAGE_PERIODS = [7, 30, 90] as const
|
||||
type UsagePeriod = (typeof USAGE_PERIODS)[number]
|
||||
|
||||
// Stable empty arrays so the selector returns the same reference when we're
|
||||
// not on the Sessions tab — useStoreSelector bails out on Object.is, so the
|
||||
// component never re-renders from $sessions ticks while on System/Usage/etc.
|
||||
const EMPTY_SESSIONS: readonly never[] = []
|
||||
const EMPTY_PINNED: readonly string[] = []
|
||||
|
||||
interface CommandCenterViewProps {
|
||||
initialSection?: CommandCenterSection
|
||||
onClose: () => void
|
||||
|
|
@ -129,10 +135,12 @@ function EmptyPanel({ action, description, title }: { action?: ReactNode; descri
|
|||
export function CommandCenterView({ initialSection, onClose, onDeleteSession, onOpenSession }: CommandCenterViewProps) {
|
||||
const { t } = useI18n()
|
||||
const cc = t.commandCenter
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
|
||||
// $sessions ticks on every streaming token (title updates, new sessions),
|
||||
// but we only need the data on the Sessions tab. Subscribe conditionally so
|
||||
// the System/Usage/Maintenance tabs don't re-render on every stream delta.
|
||||
const [section, setSection] = useRouteEnumParam('section', SECTIONS, initialSection ?? 'sessions')
|
||||
const sessions = useStoreSelector($sessions, s => (section === 'sessions' ? s : EMPTY_SESSIONS))
|
||||
const pinnedSessionIds = useStoreSelector($pinnedSessionIds, s => (section === 'sessions' ? s : EMPTY_PINNED))
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null)
|
||||
|
|
@ -294,25 +302,29 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
|||
[cc, refreshSystem]
|
||||
)
|
||||
|
||||
const navGroups = useMemo(
|
||||
() =>
|
||||
SECTIONS.map(value => ({
|
||||
active: section === value,
|
||||
icon:
|
||||
value === 'sessions'
|
||||
? MessageCircle
|
||||
: value === 'system'
|
||||
? Activity
|
||||
: value === 'maintenance'
|
||||
? Wrench
|
||||
: BarChart3,
|
||||
id: value,
|
||||
label: cc.sections[value],
|
||||
onSelect: () => setSection(value)
|
||||
})),
|
||||
[cc, section]
|
||||
)
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel={cc.close} onClose={onClose}>
|
||||
<OverlaySplitLayout>
|
||||
<OverlayNav
|
||||
groups={SECTIONS.map(value => ({
|
||||
active: section === value,
|
||||
icon:
|
||||
value === 'sessions'
|
||||
? MessageCircle
|
||||
: value === 'system'
|
||||
? Activity
|
||||
: value === 'maintenance'
|
||||
? Wrench
|
||||
: BarChart3,
|
||||
id: value,
|
||||
label: cc.sections[value],
|
||||
onSelect: () => setSection(value)
|
||||
}))}
|
||||
/>
|
||||
<OverlayNav groups={navGroups} />
|
||||
|
||||
<OverlayMain>
|
||||
<header className="mb-4 flex items-center justify-between gap-3 max-[47.5rem]:mb-2">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, memo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
|
||||
|
|
@ -218,6 +218,41 @@ const rankGroups = (groups: PaletteGroup[], search: string): PaletteGroup[] => {
|
|||
// theme lists under both Light and Dark). The id suffix disambiguates.
|
||||
const paletteValue = (item: PaletteItem): string => `${item.label}\u0001${item.id}`
|
||||
|
||||
const PaletteRow = memo(function PaletteRow({
|
||||
bindings,
|
||||
item,
|
||||
onSelectMods,
|
||||
onSelectItem
|
||||
}: {
|
||||
bindings: Record<string, string[]>
|
||||
item: PaletteItem
|
||||
onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void
|
||||
onSelectItem: (item: PaletteItem) => void
|
||||
}) {
|
||||
const Icon = item.icon
|
||||
const combo = item.action ? bindings[item.action]?.[0] : undefined
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className={cn(HUD_ITEM, HUD_TEXT)}
|
||||
keywords={item.keywords}
|
||||
onMouseDown={onSelectMods}
|
||||
onSelect={() => onSelectItem(item)}
|
||||
value={paletteValue(item)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
|
||||
{item.to && (
|
||||
<ChevronRight className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')} />
|
||||
)}
|
||||
{item.active && (
|
||||
<Check className={cn('size-3.5 shrink-0 text-primary', !combo && !item.to && 'ml-auto')} />
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})
|
||||
|
||||
// Hermes session ids: <YYYYMMDD>_<HHMMSS>_<6 hex>. Used to offer a direct
|
||||
// "Go to session ‹id›" jump for ids that aren't in the recent-200 list.
|
||||
const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/
|
||||
|
|
@ -996,35 +1031,15 @@ export function CommandPalette() {
|
|||
heading={group.heading}
|
||||
key={group.heading ?? `palette-group-${index}`}
|
||||
>
|
||||
{group.items.map(item => {
|
||||
const Icon = item.icon
|
||||
const combo = item.action ? bindings[item.action]?.[0] : undefined
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className={cn(HUD_ITEM, HUD_TEXT)}
|
||||
key={item.id}
|
||||
keywords={item.keywords}
|
||||
onMouseDown={noteSelectMods}
|
||||
onSelect={() => handleSelect(item)}
|
||||
value={paletteValue(item)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
|
||||
{item.to && (
|
||||
<ChevronRight
|
||||
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
|
||||
/>
|
||||
)}
|
||||
{item.active && (
|
||||
<Check
|
||||
className={cn('size-3.5 shrink-0 text-primary', !combo && !item.to && 'ml-auto')}
|
||||
/>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
{group.items.map(item => (
|
||||
<PaletteRow
|
||||
bindings={bindings}
|
||||
item={item}
|
||||
key={item.id}
|
||||
onSelectItem={handleSelect}
|
||||
onSelectMods={noteSelectMods}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Fragment, type ReactNode } from 'react'
|
||||
import { Fragment, memo, type ReactNode } from 'react'
|
||||
|
||||
import { TabDropdown } from '@/components/ui/tab-dropdown'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
|
|
@ -94,7 +94,14 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
|
|||
)
|
||||
}
|
||||
|
||||
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
|
||||
export const OverlayNavItem = memo(function OverlayNavItem({
|
||||
active,
|
||||
icon: Icon,
|
||||
label,
|
||||
nested,
|
||||
onClick,
|
||||
trailing
|
||||
}: OverlayNavItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
|
|
@ -121,7 +128,7 @@ export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, tra
|
|||
{trailing}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export interface OverlayNavLink {
|
||||
active: boolean
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { codiconIcon } from '@/components/ui/codicon'
|
||||
|
|
@ -134,123 +134,124 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||
}
|
||||
}
|
||||
|
||||
const navGroups: OverlayNavGroup[] = [
|
||||
const navGroups: OverlayNavGroup[] = useMemo(() => [
|
||||
...SECTIONS.map(s => {
|
||||
const view = `config:${s.id}` as SettingsViewId
|
||||
const view = `config:${s.id}` as SettingsViewId
|
||||
|
||||
return {
|
||||
active: activeView === view,
|
||||
icon: s.icon,
|
||||
id: view,
|
||||
label: t.settings.sections[s.id] ?? s.label,
|
||||
onSelect: () => setActiveView(view)
|
||||
return {
|
||||
active: activeView === view,
|
||||
icon: s.icon,
|
||||
id: view,
|
||||
label: t.settings.sections[s.id] ?? s.label,
|
||||
onSelect: () => setActiveView(view)
|
||||
}
|
||||
}),
|
||||
{
|
||||
active: activeView === 'notifications',
|
||||
icon: Bell,
|
||||
id: 'notifications',
|
||||
label: t.settings.nav.notifications,
|
||||
onSelect: () => setActiveView('notifications')
|
||||
},
|
||||
{
|
||||
active: activeView === 'billing',
|
||||
icon: BarChart3,
|
||||
id: 'billing',
|
||||
label: t.settings.nav.billing,
|
||||
onSelect: () => setActiveView('billing')
|
||||
},
|
||||
{
|
||||
active: activeView === 'providers',
|
||||
children: [
|
||||
{
|
||||
active: activeView === 'providers' && providerView === 'accounts',
|
||||
icon: codiconIcon('account'),
|
||||
id: 'pview:accounts',
|
||||
label: t.settings.nav.providerAccounts,
|
||||
onSelect: () => openProviderView('accounts')
|
||||
},
|
||||
{
|
||||
active: activeView === 'providers' && providerView === 'keys',
|
||||
icon: KeyRound,
|
||||
id: 'pview:keys',
|
||||
label: t.settings.nav.providerApiKeys,
|
||||
onSelect: () => openProviderView('keys')
|
||||
},
|
||||
{
|
||||
active: activeView === 'providers' && providerView === 'custom-endpoints',
|
||||
icon: Globe,
|
||||
id: 'pview:custom-endpoints',
|
||||
label: t.settings.nav.providerCustomEndpoints,
|
||||
onSelect: () => openProviderView('custom-endpoints')
|
||||
}
|
||||
],
|
||||
gapBefore: true,
|
||||
icon: Zap,
|
||||
id: 'providers',
|
||||
label: t.settings.nav.providers,
|
||||
onSelect: () => setActiveView('providers')
|
||||
},
|
||||
{
|
||||
active: activeView === 'gateway',
|
||||
icon: Globe,
|
||||
id: 'gateway',
|
||||
label: t.settings.nav.gateway,
|
||||
onSelect: () => setActiveView('gateway')
|
||||
},
|
||||
{
|
||||
active: activeView === 'keybinds',
|
||||
icon: Keyboard,
|
||||
id: 'keybinds',
|
||||
label: t.settings.nav.keybinds,
|
||||
onSelect: () => setActiveView('keybinds')
|
||||
},
|
||||
{
|
||||
active: activeView === 'keys',
|
||||
children: [
|
||||
{
|
||||
active: activeView === 'keys' && keysView === 'tools',
|
||||
icon: Wrench,
|
||||
id: 'kview:tools',
|
||||
label: t.settings.nav.keysTools,
|
||||
onSelect: () => openKeysView('tools')
|
||||
},
|
||||
{
|
||||
active: activeView === 'keys' && keysView === 'settings',
|
||||
icon: Settings2,
|
||||
id: 'kview:settings',
|
||||
label: t.settings.nav.keysSettings,
|
||||
onSelect: () => openKeysView('settings')
|
||||
}
|
||||
],
|
||||
icon: KeyRound,
|
||||
id: 'keys',
|
||||
label: t.settings.nav.apiKeys,
|
||||
onSelect: () => setActiveView('keys')
|
||||
},
|
||||
{
|
||||
active: activeView === 'plugins',
|
||||
icon: Package,
|
||||
id: 'plugins',
|
||||
label: t.settings.nav.plugins,
|
||||
onSelect: () => setActiveView('plugins')
|
||||
},
|
||||
{
|
||||
active: activeView === 'sessions',
|
||||
icon: Archive,
|
||||
id: 'sessions',
|
||||
label: t.settings.nav.archivedChats,
|
||||
onSelect: () => setActiveView('sessions')
|
||||
},
|
||||
{
|
||||
active: activeView === 'about',
|
||||
gapBefore: true,
|
||||
icon: Info,
|
||||
id: 'about',
|
||||
label: t.settings.nav.about,
|
||||
onSelect: () => setActiveView('about')
|
||||
}
|
||||
}),
|
||||
{
|
||||
active: activeView === 'notifications',
|
||||
icon: Bell,
|
||||
id: 'notifications',
|
||||
label: t.settings.nav.notifications,
|
||||
onSelect: () => setActiveView('notifications')
|
||||
},
|
||||
{
|
||||
active: activeView === 'billing',
|
||||
icon: BarChart3,
|
||||
id: 'billing',
|
||||
label: t.settings.nav.billing,
|
||||
onSelect: () => setActiveView('billing')
|
||||
},
|
||||
{
|
||||
active: activeView === 'providers',
|
||||
children: [
|
||||
{
|
||||
active: activeView === 'providers' && providerView === 'accounts',
|
||||
icon: codiconIcon('account'),
|
||||
id: 'pview:accounts',
|
||||
label: t.settings.nav.providerAccounts,
|
||||
onSelect: () => openProviderView('accounts')
|
||||
},
|
||||
{
|
||||
active: activeView === 'providers' && providerView === 'keys',
|
||||
icon: KeyRound,
|
||||
id: 'pview:keys',
|
||||
label: t.settings.nav.providerApiKeys,
|
||||
onSelect: () => openProviderView('keys')
|
||||
},
|
||||
{
|
||||
active: activeView === 'providers' && providerView === 'custom-endpoints',
|
||||
icon: Globe,
|
||||
id: 'pview:custom-endpoints',
|
||||
label: t.settings.nav.providerCustomEndpoints,
|
||||
onSelect: () => openProviderView('custom-endpoints')
|
||||
}
|
||||
],
|
||||
gapBefore: true,
|
||||
icon: Zap,
|
||||
id: 'providers',
|
||||
label: t.settings.nav.providers,
|
||||
onSelect: () => setActiveView('providers')
|
||||
},
|
||||
{
|
||||
active: activeView === 'gateway',
|
||||
icon: Globe,
|
||||
id: 'gateway',
|
||||
label: t.settings.nav.gateway,
|
||||
onSelect: () => setActiveView('gateway')
|
||||
},
|
||||
{
|
||||
active: activeView === 'keybinds',
|
||||
icon: Keyboard,
|
||||
id: 'keybinds',
|
||||
label: t.settings.nav.keybinds,
|
||||
onSelect: () => setActiveView('keybinds')
|
||||
},
|
||||
{
|
||||
active: activeView === 'keys',
|
||||
children: [
|
||||
{
|
||||
active: activeView === 'keys' && keysView === 'tools',
|
||||
icon: Wrench,
|
||||
id: 'kview:tools',
|
||||
label: t.settings.nav.keysTools,
|
||||
onSelect: () => openKeysView('tools')
|
||||
},
|
||||
{
|
||||
active: activeView === 'keys' && keysView === 'settings',
|
||||
icon: Settings2,
|
||||
id: 'kview:settings',
|
||||
label: t.settings.nav.keysSettings,
|
||||
onSelect: () => openKeysView('settings')
|
||||
}
|
||||
],
|
||||
icon: KeyRound,
|
||||
id: 'keys',
|
||||
label: t.settings.nav.apiKeys,
|
||||
onSelect: () => setActiveView('keys')
|
||||
},
|
||||
{
|
||||
active: activeView === 'plugins',
|
||||
icon: Package,
|
||||
id: 'plugins',
|
||||
label: t.settings.nav.plugins,
|
||||
onSelect: () => setActiveView('plugins')
|
||||
},
|
||||
{
|
||||
active: activeView === 'sessions',
|
||||
icon: Archive,
|
||||
id: 'sessions',
|
||||
label: t.settings.nav.archivedChats,
|
||||
onSelect: () => setActiveView('sessions')
|
||||
},
|
||||
{
|
||||
active: activeView === 'about',
|
||||
gapBefore: true,
|
||||
icon: Info,
|
||||
id: 'about',
|
||||
label: t.settings.nav.about,
|
||||
onSelect: () => setActiveView('about')
|
||||
}
|
||||
]
|
||||
]
|
||||
, [activeView, keysView, providerView, t])
|
||||
|
||||
const navFooter = (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
import { useI18n } from '@/i18n'
|
||||
import { isDesktopToolsetVisible } from '@/lib/desktop-toolsets'
|
||||
import { compactNumber } from '@/lib/format'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { queryClient, writeCache } from '@/lib/query-client'
|
||||
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
|
||||
import { normalize } from '@/lib/text'
|
||||
|
|
@ -184,8 +185,10 @@ interface SkillsViewProps extends React.ComponentProps<'section'> {
|
|||
|
||||
export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: SkillsViewProps) {
|
||||
const { t } = useI18n()
|
||||
const gateway = useStore($gateway) as HermesGateway | null
|
||||
const [mode, setMode] = useRouteEnumParam('tab', SKILLS_MODES, 'skills')
|
||||
// $gateway only feeds the MCP tab — gate the subscription so Skills/Toolsets/Hub
|
||||
// tabs don't re-render on connect/disconnect/reconnect.
|
||||
const gateway = useStoreSelector($gateway, g => (mode === 'mcp' ? g : null))
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue