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:
Teknium committed 2026-10-28 20:09:13 -07:00
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)}</>
}
+1 -1
View File
@@ -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)