mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
feat(desktop): show every profile as a sidebar section, chats or not
Grouped by profile (All profiles), a profile with no chats had no section, so a new or quiet profile vanished from the sidebar. Profile groups now also come from the profile list the profile picker reads ($profiles), added empty under the owner that list was read from (the request connection, `local` in a Webapp tab, the untagged primary pool natively). An empty section shows its header, new-chat action and a muted "No sessions yet" row. Filtered, archived and failed-load views stay as they were. Shared by the Webapp and native Desktop sidebar.
This commit is contained in:
1 parent
f2703ac578
commit
47f0fc99d2
5 files changed
+178
-34
No files matched your search
@@ -2,11 +2,15 @@ import { LOCAL_CONNECTION_ID } from '@hermes/shared'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { $apiRequestScope } from '@/api/client'
|
||||
import type { DesktopConnectionsRegistry } from '@/global'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { isBrowserHostedDesktop } from '@/lib/platform'
|
||||
import { resolveProfileColor } from '@/lib/profile-color'
|
||||
import { $connectionsRegistry } from '@/store/connection-registry-state'
|
||||
import { $profileColors, normalizeProfileKey } from '@/store/profile'
|
||||
import { $sidebarShowArchived } from '@/store/layout'
|
||||
import { $profileColors, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
import { $sessionsLoadError } from '@/store/session'
|
||||
|
||||
import type { SidebarSessionGroup } from './projects/workspace-groups'
|
||||
|
||||
@@ -22,6 +26,18 @@ export function gatewayLabel(
|
||||
return registered || (connectionId === LOCAL_CONNECTION_ID ? localLabel : connectionId)
|
||||
}
|
||||
|
||||
/** Whether a profile group header shows the profile-keyed usage totals. Those totals are keyed only
|
||||
* by profile, so never attribute them to a registry gateway that happens to expose the same profile
|
||||
* name, or to one messaging platform (`embedded`). A Webapp tab's `local` rows are the serving
|
||||
* host's own, the gateway those totals were read from. */
|
||||
export function showsProfileTotals(connectionId: string | null | undefined, embedded: boolean): boolean {
|
||||
if (embedded) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !connectionId || (connectionId === LOCAL_CONNECTION_ID && isBrowserHostedDesktop())
|
||||
}
|
||||
|
||||
/** One group per exact owner `[connectionId, profile]`. Group identity never
|
||||
* depends on a mutable label, URL, or the active gateway. */
|
||||
export function buildGatewaySessionGroups(
|
||||
@@ -35,25 +51,37 @@ export function buildGatewaySessionGroups(
|
||||
const profile = normalizeProfileKey(session.profile)
|
||||
const connectionId = session.connection_id || null
|
||||
const id = JSON.stringify([connectionId, profile])
|
||||
const gateway = registry?.connections.find(connection => connection.id === connectionId)
|
||||
const label = connectionId ? `${gateway?.label || connectionId} · ${profile}` : profile
|
||||
|
||||
const group: SidebarSessionGroup = groups.get(id) ?? {
|
||||
id,
|
||||
label,
|
||||
connectionId,
|
||||
profile,
|
||||
mode: 'profile',
|
||||
path: null,
|
||||
color: resolveProfileColor(profile, colors),
|
||||
sessions: []
|
||||
}
|
||||
const group = groups.get(id) ?? ownerGroup(connectionId, profile, registry, colors)
|
||||
|
||||
group.sessions.push(session)
|
||||
groups.set(id, group)
|
||||
}
|
||||
|
||||
return [...groups.values()].sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id))
|
||||
return sortGroups([...groups.values()])
|
||||
}
|
||||
|
||||
function ownerGroup(
|
||||
connectionId: null | string,
|
||||
profile: string,
|
||||
registry: DesktopConnectionsRegistry | null,
|
||||
colors: Record<string, string>
|
||||
): SidebarSessionGroup {
|
||||
const gateway = registry?.connections.find(connection => connection.id === connectionId)
|
||||
|
||||
return {
|
||||
id: JSON.stringify([connectionId, profile]),
|
||||
label: connectionId ? `${gateway?.label || connectionId} · ${profile}` : profile,
|
||||
connectionId,
|
||||
profile,
|
||||
mode: 'profile',
|
||||
path: null,
|
||||
color: resolveProfileColor(profile, colors),
|
||||
sessions: []
|
||||
}
|
||||
}
|
||||
|
||||
function sortGroups(groups: SidebarSessionGroup[]): SidebarSessionGroup[] {
|
||||
return groups.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
/** Re-key owner groups shown inside another section (a messaging platform) so
|
||||
@@ -70,12 +98,70 @@ export function scopeGatewaySessionGroups(groups: SidebarSessionGroup[], scope:
|
||||
}))
|
||||
}
|
||||
|
||||
export function useGatewaySessionGroups(sessions: SessionInfo[], enabled: boolean) {
|
||||
/** The owner id the profile list belongs to, in the same vocabulary rows are tagged with: a
|
||||
* non-local request connection stamps its rows with its id; the primary pool stays untagged
|
||||
* natively, and a Webapp tab tags every row with `local` (its one gateway). */
|
||||
export function profileListConnectionId(apiConnectionId: null | string, browserHosted: boolean): null | string {
|
||||
const owner = apiConnectionId?.trim()
|
||||
|
||||
if (owner && owner !== LOCAL_CONNECTION_ID) {
|
||||
return owner
|
||||
}
|
||||
|
||||
return browserHosted ? LOCAL_CONNECTION_ID : null
|
||||
}
|
||||
|
||||
/** Give every listed profile its own section, chats or not, so a new or quiet profile stays one
|
||||
* click away. Groups already present (by exact owner) keep their rows; missing ones are added
|
||||
* empty under `connectionId`, the owner the profile list was read from. */
|
||||
export function withEmptyProfileGroups(
|
||||
groups: SidebarSessionGroup[],
|
||||
profiles: readonly string[],
|
||||
connectionId: null | string,
|
||||
registry: DesktopConnectionsRegistry | null,
|
||||
colors: Record<string, string>
|
||||
): SidebarSessionGroup[] {
|
||||
const present = new Set(groups.map(group => group.id))
|
||||
|
||||
const missing = profiles
|
||||
.map(normalizeProfileKey)
|
||||
.filter((profile, index, all) => all.indexOf(profile) === index)
|
||||
.filter(profile => !present.has(JSON.stringify([connectionId, profile])))
|
||||
.map(profile => ownerGroup(connectionId, profile, registry, colors))
|
||||
|
||||
return missing.length ? sortGroups([...groups, ...missing]) : groups
|
||||
}
|
||||
|
||||
/** Owner groups for the recents list. Every listed profile keeps its section even with no chats,
|
||||
* unless the list is a narrowed view (filters, archive) or failed to load. */
|
||||
export function useGatewaySessionGroups(sessions: SessionInfo[], enabled: boolean, narrowed = true) {
|
||||
const registry = useStore($connectionsRegistry)
|
||||
const colors = useStore($profileColors)
|
||||
const profiles = useStore($profiles)
|
||||
const { connectionId: apiConnectionId } = useStore($apiRequestScope)
|
||||
const showArchived = useStore($sidebarShowArchived)
|
||||
const loadError = useStore($sessionsLoadError)
|
||||
const includeEmptyProfiles = !narrowed && !showArchived && !loadError
|
||||
|
||||
return useMemo(
|
||||
() => (enabled ? buildGatewaySessionGroups(sessions, registry, colors) : undefined),
|
||||
[sessions, enabled, registry, colors]
|
||||
)
|
||||
return useMemo(() => {
|
||||
if (!enabled) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const groups = buildGatewaySessionGroups(sessions, registry, colors)
|
||||
|
||||
if (!includeEmptyProfiles) {
|
||||
return groups
|
||||
}
|
||||
|
||||
const owner = profileListConnectionId(apiConnectionId, isBrowserHostedDesktop())
|
||||
|
||||
return withEmptyProfileGroups(
|
||||
groups,
|
||||
profiles.map(profile => profile.name),
|
||||
owner,
|
||||
registry,
|
||||
colors
|
||||
)
|
||||
}, [sessions, enabled, includeEmptyProfiles, registry, colors, profiles, apiConnectionId])
|
||||
}
|
||||
@@ -89,3 +89,44 @@ it('names the Webapp gateway section after the host instead of its raw `local` i
|
||||
expect(groupOf(/\$3\.00/)).toBe(JSON.stringify(['local', 'default']))
|
||||
expect(groupOf(/\$1\.25/)).toBe(JSON.stringify(['local', 'work']))
|
||||
})
|
||||
|
||||
// A profile with no chats still gets its own section, sourced from the profile list, on both hosts.
|
||||
it.each([
|
||||
{ host: 'Webapp', browser: true, connectionId: 'local' },
|
||||
{ host: 'native Desktop', browser: false, connectionId: null }
|
||||
])('shows a profile with no chats as its own section ($host)', ({ browser, connectionId }) => {
|
||||
if (browser) {
|
||||
window.document.documentElement.dataset.hermesDesktopHost = 'browser'
|
||||
}
|
||||
|
||||
mount()
|
||||
act(() => {
|
||||
$connectionsRegistry.set(null)
|
||||
$profiles.set([
|
||||
{ name: 'default', is_default: true },
|
||||
{ name: 'quiet', is_default: false }
|
||||
] as typeof $profiles.value)
|
||||
$showAllProfiles.set(true)
|
||||
setSidebarGrouping('profile')
|
||||
$sessions.set([
|
||||
makeSessionInfo({
|
||||
id: 'one',
|
||||
...(connectionId ? { connection_id: connectionId } : {}),
|
||||
profile: 'default',
|
||||
title: 'only chat',
|
||||
last_active: Date.now() / 1000
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
const quiet = window.document.querySelector(
|
||||
`[data-gateway-group='${JSON.stringify([connectionId, 'quiet'])}']`
|
||||
) as HTMLElement | null
|
||||
|
||||
expect(quiet).toBeTruthy()
|
||||
expect(within(quiet!).getByText('quiet')).toBeTruthy()
|
||||
expect(within(quiet!).getByText('No sessions yet')).toBeTruthy()
|
||||
expect(window.document.querySelectorAll('[data-gateway-group-empty]')).toHaveLength(1)
|
||||
expect(within(quiet!).getByRole('button', { name: 'New session in quiet' })).toBeTruthy()
|
||||
expect(screen.getByText('only chat')).toBeTruthy()
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { useSensors } from '@dnd-kit/core'
|
||||
import { arrayMove } from '@dnd-kit/sortable'
|
||||
import { LOCAL_CONNECTION_ID } from '@hermes/shared'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
@@ -24,7 +23,6 @@ import { useContributions } from '@/contrib'
|
||||
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { isBrowserHostedDesktop } from '@/lib/platform'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $connectionsRegistry } from '@/store/connection-registry-state'
|
||||
@@ -33,7 +31,7 @@ import { $sessionProfilesUsage } from '@/store/session'
|
||||
import { $sidebarSessionRankIds } from '@/store/sidebar-sort'
|
||||
|
||||
import { SidebarGroupRow, SidebarRowGrab, SidebarRowLink, SidebarRowStack } from './chrome'
|
||||
import { gatewayLabel } from './gateway-group-model'
|
||||
import { gatewayLabel, showsProfileTotals } from './gateway-group-model'
|
||||
import {
|
||||
$gatewayGroupAliases,
|
||||
$gatewayGroupCollapsed,
|
||||
@@ -166,15 +164,8 @@ function GatewayProfileGroup({
|
||||
const collapsed = useStore($gatewayGroupCollapsed)
|
||||
const rankIds = useStore($sidebarSessionRankIds)
|
||||
|
||||
// Legacy totals are keyed only by profile. Never attribute those figures to
|
||||
// a registry gateway that happens to expose the same profile name, or to
|
||||
// one messaging platform. A Webapp tab's `local` rows are the serving host's
|
||||
// own, the gateway those totals were read from.
|
||||
const ownsProfileTotals =
|
||||
!group.connectionId || (isBrowserHostedDesktop() && group.connectionId === LOCAL_CONNECTION_ID)
|
||||
|
||||
const usage = useStoreSelector($sessionProfilesUsage, all =>
|
||||
ownsProfileTotals && !embedded ? all[group.profile!] : undefined
|
||||
showsProfileTotals(group.connectionId, embedded) ? all[group.profile!] : undefined
|
||||
)
|
||||
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
@@ -320,7 +311,12 @@ function GatewayProfileGroup({
|
||||
{group.profile && !embedded ? (
|
||||
<ProfileGroupHeaderSlot connectionId={group.connectionId ?? null} profile={group.profile} />
|
||||
) : null}
|
||||
{renderRows(embedded ? sessions : sessions.slice(0, visibleCount))}
|
||||
<ProfileGroupRows
|
||||
emptyLabel={s.noSessions}
|
||||
profile={group.profile}
|
||||
renderRows={renderRows}
|
||||
sessions={embedded ? sessions : sessions.slice(0, visibleCount)}
|
||||
/>
|
||||
{hiddenCount > 0 && (
|
||||
<WorkspaceShowMoreButton
|
||||
count={Math.min(SIDEBAR_GROUP_PAGE, hiddenCount)}
|
||||
@@ -408,3 +404,24 @@ function ProfileGroupHeaderItem({
|
||||
|
||||
return <ContribRender render={Row} />
|
||||
}
|
||||
|
||||
interface ProfileGroupRowsProps {
|
||||
profile?: string
|
||||
sessions: SessionInfo[]
|
||||
renderRows: (sessions: SessionInfo[]) => ReactNode
|
||||
emptyLabel: string
|
||||
}
|
||||
|
||||
// A profile with no chats keeps its section: a muted line under the header says so. A gateway
|
||||
// section holds its profiles as children, not rows, so it never shows the line.
|
||||
function ProfileGroupRows({ profile, sessions, renderRows, emptyLabel }: ProfileGroupRowsProps) {
|
||||
if (!sessions.length && profile) {
|
||||
return (
|
||||
<div className="px-2 py-1 text-xs text-(--ui-text-tertiary)" data-gateway-group-empty>
|
||||
{emptyLabel}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{renderRows(sessions)}</>
|
||||
}
|
||||
@@ -1370,7 +1370,7 @@ export function ChatSidebar({
|
||||
// Recents and every messaging platform resolve owner groups the same way
|
||||
// ([connectionId, profile]), so a platform's groups line up with recents.
|
||||
const ownerGrouped = profileScope === ALL_PROFILES && grouping === 'profile'
|
||||
const profileGroups = useGatewaySessionGroups(agentSessions, ownerGrouped)
|
||||
const profileGroups = useGatewaySessionGroups(agentSessions, ownerGrouped, filtersNarrow)
|
||||
const connectionsRegistry = useStore($connectionsRegistry)
|
||||
const profileColors = useStore($profileColors)
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ export function SidebarSessionsSection({
|
||||
const nodeOpen = useStore($sidebarWorkspaceNodeOpen)
|
||||
const isListGroupOpen = useCallback((key: string) => nodeOpen[listGroupNodeId(key)] ?? true, [nodeOpen])
|
||||
const sectionOpen = collapsible ? open : true
|
||||
const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0))
|
||||
const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0 || group.mode === 'profile'))
|
||||
// A defined project list is itself content (even an empty project should
|
||||
// render as a drill-in row so the user can see it exists).
|
||||
const hasProjectOverview = Boolean(projectOverview?.length)
|
||||
|
||||
Reference in new issue
Block a user