fix(desktop): group messaging platforms by gateway/profile owner in all-profiles view

With "Show all profiles" on, each messaging platform section listed rows by
source only, so a Telegram thread gave no hint which profile it belonged to.

Rework the cherry-picked idea onto the current owner model:

- Extract buildGatewaySessionGroups() from useGatewaySessionGroups (now a thin
  useMemo wrapper) so recents and messaging share one resolver keyed by
  [connectionId, profile]. Drop the profile-only profile-groups.ts.
- Under profile grouping, group each platform's rows after the visible cap, so
  the platform's load-more count and footer stay coherent. Render through
  GatewayProfileGroups in a new embedded mode: flat owner groups, no nested
  paging, no new-session button, header slot or profile-wide totals.
- scopeGatewaySessionGroups() namespaces group ids per platform so collapse,
  alias and order preferences never collide with the recents groups, and keeps
  the gateway in the label only when a platform mixes gateways.
- Under any other grouping, messaging rows carry profile tags like the pinned
  and search sections.

Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
This commit is contained in:
brooklyn!
2026-09-24 02:40:59 -05:00
parent 3fa2123c88
commit bbba4d4f77
8 changed files with 281 additions and 208 deletions

View File

@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { act, cleanup, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -7,14 +7,25 @@ import { group, split } from '@/components/pane-shell/tree/model'
import { $layoutTree, noteActiveTreeGroup } from '@/components/pane-shell/tree/store'
import { SidebarProvider } from '@/components/ui/sidebar'
import { registry } from '@/contrib/registry'
import { setSidebarAgentsGrouped } from '@/store/layout'
import { $connectionsRegistry } from '@/store/connection-registry-state'
import { $sidebarMessagingOpenIds, setSidebarAgentsGrouped, setSidebarGrouping } from '@/store/layout'
import { $activeGatewayProfile, $profiles, setShowAllProfiles } from '@/store/profile'
import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects'
import { $currentCwd, $selectedStoredSessionId, $sessions, $workspaceCwdOwner } from '@/store/session'
import {
$currentCwd,
$messagingSessions,
$messagingTruncated,
$selectedStoredSessionId,
$sessions,
$workspaceCwdOwner
} from '@/store/session'
import { $removedSessionIds } from '@/store/session-removal'
import { makeSessionInfo } from '@/test/session-info'
import { type AppView, ROUTES_AREA, SIDEBAR_NAV_AREA } from '../../routes'
import { $gatewayGroupCollapsed } from './gateway-group-preferences'
import { ChatSidebar } from './index'
const noop = () => {}
@@ -214,3 +225,108 @@ describe('ChatSidebar project entry', () => {
expect($currentCwd.get()).toBe(project.path)
})
})
// Messaging platforms group rows by owner the same way recents does once every
// profile is on screen, so a Telegram thread is attributable to its profile
// (#87715). The platform's row cap and load-more stay the section's.
describe('ChatSidebar messaging owners', () => {
const telegram = (id: string, profile: string, last_active: number) =>
makeSessionInfo({ connection_id: 'local', id, last_active, profile, source: 'telegram', title: id })
// Newest first: the 3-row cap lets default-1, work-1 and default-2 through.
const threads = [
telegram('default-1', 'default', 50),
telegram('work-1', 'work', 40),
telegram('default-2', 'default', 30),
telegram('work-2', 'work', 20),
telegram('default-3', 'default', 10)
]
let root: HTMLElement
const mount = () => {
root = renderSidebar('/', 'chat').container
}
const telegramGroups = () =>
[...root.querySelectorAll<HTMLElement>('[data-gateway-group]')].filter(node =>
node.dataset.gatewayGroup!.startsWith(JSON.stringify(['messaging:telegram']).slice(0, -1))
)
const titlesIn = (node: HTMLElement) =>
threads.map(thread => thread.id).filter(title => within(node).queryByText(title))
beforeEach(() => {
$connectionsRegistry.set({
version: 2,
primary: 'local',
secureTokenStorage: true,
connections: [{ id: 'local', label: 'This computer', kind: 'local', tokenSet: false, tokenPreview: null }]
} as NonNullable<typeof $connectionsRegistry.value>)
$profiles.set([
{ name: 'default', is_default: true },
{ name: 'work', is_default: false }
] as typeof $profiles.value)
$sessions.set([
makeSessionInfo({ connection_id: 'local', id: 'desk', last_active: 60, profile: 'default', title: 'desk' })
])
$messagingSessions.set(threads)
$messagingTruncated.set(false)
$sidebarMessagingOpenIds.set(['telegram'])
})
afterEach(() => {
cleanup()
setSidebarGrouping('date')
setShowAllProfiles(false)
$activeGatewayProfile.set('default')
$gatewayGroupCollapsed.set([])
$sidebarMessagingOpenIds.set([])
$messagingSessions.set([])
$sessions.set([])
$profiles.set([])
$connectionsRegistry.set(null)
})
it('groups the capped rows by owner, pages the platform as a whole, and keeps its own collapse keys', () => {
setSidebarGrouping('profile')
mount()
const [defaultGroup, workGroup] = telegramGroups()
expect(telegramGroups()).toHaveLength(2)
expect(titlesIn(defaultGroup)).toEqual(['default-1', 'default-2'])
expect(titlesIn(workGroup)).toEqual(['work-1'])
fireEvent.click(screen.getByRole('button', { name: 'Load 2 more' }))
expect(titlesIn(telegramGroups()[0])).toEqual(['default-1', 'default-2', 'default-3'])
expect(titlesIn(telegramGroups()[1])).toEqual(['work-1', 'work-2'])
expect(screen.queryByRole('button', { name: /^Load \d+ more$/ })).toBeNull()
fireEvent.click(within(telegramGroups()[0]).getByRole('button', { name: 'Hide default sessions' }))
expect(screen.queryByText('default-1')).toBeNull()
expect(screen.getByText('desk')).toBeTruthy()
})
it('tags rows with their profile under other groupings and stays flat when scoped to one profile', () => {
setShowAllProfiles(true)
setSidebarGrouping('date')
mount()
const row = (title: string) => screen.getByText(title).closest('.group.row-hover') as HTMLElement
expect(telegramGroups()).toHaveLength(0)
expect(within(row('work-1')).getByRole('img', { name: 'Profile: work' })).toBeTruthy()
cleanup()
setSidebarGrouping('profile')
setShowAllProfiles(false)
mount()
expect(telegramGroups()).toHaveLength(0)
expect(screen.queryByText('work-1')).toBeNull()
expect(within(row('default-1')).queryByRole('img', { name: /^Profile:/ })).toBeNull()
})
})

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import type { DesktopConnectionsRegistry } from '@/global'
import { makeSessionInfo } from '@/test/session-info'
import { buildGatewaySessionGroups, scopeGatewaySessionGroups } from './gateway-group-model'
const registry = {
version: 2,
primary: 'local',
secureTokenStorage: true,
connections: [
{ id: 'local', label: 'This computer', kind: 'local', tokenSet: false, tokenPreview: null },
{ id: 'remote-1', label: 'Homelab', kind: 'remote', tokenSet: false, tokenPreview: null }
]
} as DesktopConnectionsRegistry
const rows = [
makeSessionInfo({ id: 'a', connection_id: 'local', profile: 'default' }),
makeSessionInfo({ id: 'b', connection_id: 'remote-1', profile: 'default' }),
makeSessionInfo({ id: 'c', profile: 'default' }),
makeSessionInfo({ id: 'd', connection_id: 'local', profile: undefined })
]
const members = (groups: ReturnType<typeof buildGatewaySessionGroups>) =>
Object.fromEntries(groups.map(group => [group.id, group.sessions.map(session => session.id)]))
describe('buildGatewaySessionGroups', () => {
it('keys groups by exact owner, so one profile name on two gateways stays two groups', () => {
const groups = buildGatewaySessionGroups(rows, registry, {})
expect(members(groups)).toEqual({
[JSON.stringify(['local', 'default'])]: ['a', 'd'],
[JSON.stringify(['remote-1', 'default'])]: ['b'],
[JSON.stringify([null, 'default'])]: ['c']
})
for (const group of groups) {
expect(group.sessions.every(session => (session.connection_id || null) === group.connectionId)).toBe(true)
}
})
it('leaves legacy rows without a connection unassigned instead of guessing a gateway', () => {
const legacy = buildGatewaySessionGroups(rows, registry, {}).find(group => group.connectionId === null)!
expect(legacy.label).toBe(legacy.profile)
expect(registry.connections.some(connection => legacy.label.includes(connection.label))).toBe(false)
})
})
describe('scopeGatewaySessionGroups', () => {
it('namespaces preference ids and keeps the gateway in labels only when owners mix gateways', () => {
const recents = buildGatewaySessionGroups(rows, registry, {})
const mixed = scopeGatewaySessionGroups(recents, 'messaging:telegram')
const single = scopeGatewaySessionGroups(recents.slice(0, 1), 'messaging:telegram')
expect(mixed.map(group => group.id).filter(id => recents.some(group => group.id === id))).toEqual([])
expect(new Set(mixed.map(group => group.label)).size).toBe(mixed.length)
expect(single[0].label).toBe(single[0].profile)
expect(mixed.map(group => group.sessions)).toEqual(recents.map(group => group.sessions))
})
})

View File

@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { useMemo } from 'react'
import type { DesktopConnectionsRegistry } from '@/global'
import type { SessionInfo } from '@/hermes'
import { resolveProfileColor } from '@/lib/profile-color'
import { $connectionsRegistry } from '@/store/connection-registry-state'
@@ -8,40 +9,60 @@ import { $profileColors, normalizeProfileKey } from '@/store/profile'
import type { SidebarSessionGroup } from './projects/workspace-groups'
/** Group identity never depends on a mutable label, URL, or the active gateway. */
/** One group per exact owner `[connectionId, profile]`. Group identity never
* depends on a mutable label, URL, or the active gateway. */
export function buildGatewaySessionGroups(
sessions: SessionInfo[],
registry: DesktopConnectionsRegistry | null,
colors: Record<string, string>
): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>()
for (const session of sessions) {
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: []
}
group.sessions.push(session)
groups.set(id, group)
}
return [...groups.values()].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
* their collapse/alias/order preferences never touch the recents groups. No
* gateway header sits above them there, so the gateway stays in the label only
* when the section mixes gateways. */
export function scopeGatewaySessionGroups(groups: SidebarSessionGroup[], scope: string): SidebarSessionGroup[] {
const mixed = new Set(groups.map(group => group.connectionId)).size > 1
return groups.map(group => ({
...group,
id: JSON.stringify([scope, group.id]),
label: mixed ? group.label : group.profile!
}))
}
export function useGatewaySessionGroups(sessions: SessionInfo[], enabled: boolean) {
const registry = useStore($connectionsRegistry)
const colors = useStore($profileColors)
return useMemo<SidebarSessionGroup[] | undefined>(() => {
if (!enabled) {
return undefined
}
const groups = new Map<string, SidebarSessionGroup>()
for (const session of sessions) {
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: []
}
group.sessions.push(session)
groups.set(id, group)
}
return [...groups.values()].sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id))
}, [sessions, enabled, registry, colors])
return useMemo(
() => (enabled ? buildGatewaySessionGroups(sessions, registry, colors) : undefined),
[sessions, enabled, registry, colors]
)
}

View File

@@ -51,6 +51,9 @@ interface GatewayProfileGroupsProps {
sensors?: ReturnType<typeof useSensors>
onNewSessionSplit?: NewSessionSplitHandler
nested?: boolean
// Inside another section (a messaging platform) that owns paging and has no
// business starting desktop sessions: flat owner groups, headers only.
embedded?: boolean
}
export function GatewayProfileGroups({
@@ -58,7 +61,8 @@ export function GatewayProfileGroups({
renderRows,
sensors,
onNewSessionSplit,
nested = false
nested = false,
embedded = false
}: GatewayProfileGroupsProps) {
const registry = useStore($connectionsRegistry)
const order = useStore($gatewayGroupOrder)
@@ -67,8 +71,8 @@ export function GatewayProfileGroups({
for (const group of groups) {
// Unknown legacy ownership stays unassigned; never guess a local gateway.
if (nested || !group.connectionId) {
sections.push(nested ? { ...group, label: group.profile! } : group)
if (nested || embedded || !group.connectionId) {
sections.push(group)
continue
}
@@ -105,6 +109,7 @@ export function GatewayProfileGroups({
<ReorderableList ids={ids} onReorder={reorderGatewayGroups} sensors={sensors}>
{ordered.map((group, index) => (
<GatewayProfileGroup
embedded={embedded}
first={index === 0}
group={group}
key={group.id}
@@ -116,7 +121,7 @@ export function GatewayProfileGroups({
{gatewayProfiles.has(group.id) && (
<div className="ml-3 border-l border-border/50 pl-1">
<GatewayProfileGroups
groups={gatewayProfiles.get(group.id)!}
groups={gatewayProfiles.get(group.id)!.map(profile => ({ ...profile, label: profile.profile! }))}
nested
onNewSessionSplit={onNewSessionSplit}
renderRows={renderRows}
@@ -137,6 +142,7 @@ interface GatewayProfileGroupProps {
onNewSessionSplit?: NewSessionSplitHandler
first: boolean
last: boolean
embedded: boolean
children?: ReactNode
}
@@ -146,6 +152,7 @@ function GatewayProfileGroup({
onMove,
first,
last,
embedded,
onNewSessionSplit,
children
}: GatewayProfileGroupProps) {
@@ -155,9 +162,14 @@ function GatewayProfileGroup({
const aliases = useStore($gatewayGroupAliases)
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.
const usage = useStoreSelector($sessionProfilesUsage, all => (group.connectionId ? undefined : all[group.profile!]))
// a registry gateway that happens to expose the same profile name, or to
// one messaging platform.
const usage = useStoreSelector($sessionProfilesUsage, all =>
group.connectionId || embedded ? undefined : all[group.profile!]
)
const [renaming, setRenaming] = useState(false)
const [draft, setDraft] = useState('')
const [visibleCount, setVisibleCount] = useState(SIDEBAR_GROUP_PAGE)
@@ -165,7 +177,7 @@ function GatewayProfileGroup({
const label = aliases[group.id] || group.label
const open = !collapsed.includes(group.id)
const sessions = rankSessions(group.sessions, rankIds)
const hiddenCount = Math.max(0, sessions.length - visibleCount)
const hiddenCount = embedded ? 0 : Math.max(0, sessions.length - visibleCount)
const route = group.connectionId ? { connectionId: group.connectionId, profile: group.profile! } : undefined
const startSession = () => {
@@ -199,7 +211,7 @@ function GatewayProfileGroup({
// below); the full handle stays on the grabber (see useSortableBindings).
actions={
<div className="flex items-center">
{group.profile && (
{group.profile && !embedded && (
<WorkspaceAddButton
label={s.newSessionIn(label)}
onClick={startSession}
@@ -298,10 +310,10 @@ function GatewayProfileGroup({
{open && (
<>
{children}
{group.profile ? (
{group.profile && !embedded ? (
<ProfileGroupHeaderSlot connectionId={group.connectionId ?? null} profile={group.profile} />
) : null}
{renderRows(sessions.slice(0, visibleCount))}
{renderRows(embedded ? sessions : sessions.slice(0, visibleCount))}
{hiddenCount > 0 && (
<WorkspaceShowMoreButton
count={Math.min(SIDEBAR_GROUP_PAGE, hiddenCount)}

View File

@@ -29,6 +29,7 @@ import { comboTokens } from '@/lib/keybinds/combo'
import { sessionMatchesSearch } from '@/lib/session-search'
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $connectionsRegistry } from '@/store/connection-registry-state'
import { $activeConnectionId } from '@/store/connections'
import { $cronJobs } from '@/store/cron'
import { $interfaceMode, $showsAdvancedChrome, shownInMode } from '@/store/interface-mode'
@@ -150,10 +151,9 @@ import { type NewSessionSplitHandler, startNewSessionDrag } from '../new-session
import { SidebarSectionAddButton } from './chrome'
import { SidebarCronJobsSection } from './cron-jobs-section'
import { SidebarFilterMenu } from './filter-menu'
import { useGatewaySessionGroups } from './gateway-group-model'
import { buildGatewaySessionGroups, scopeGatewaySessionGroups, useGatewaySessionGroups } from './gateway-group-model'
import { SidebarLoadMoreRow } from './load-more-row'
import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds } from './order'
import { buildProfileGroups } from './profile-groups'
import { filterSessionsByProfileScope } from './profile-scope'
import { ProfileRail } from './profile-switcher'
import { ProjectDialog } from './project-dialog'
@@ -172,7 +172,6 @@ import {
sessionMatchesProjectFilter,
sessionRecency as sessionTime,
type SidebarProjectTree,
type SidebarSessionGroup,
type SidebarWorkspaceTree,
sortProjectsForOverview,
StartWorkButton,
@@ -1308,13 +1307,6 @@ export function ChatSidebar({
}
}
// Grouping by profile: one collapsible group per profile, color on the header
// (not on every row). Default profile floats to the top, the rest alpha.
// Only reachable while the sidebar is showing every profile — scoped to one,
// it would draw a single group around the whole list.
const profileGrouped = showAllProfiles && grouping === 'profile'
const profileColors = useStore($profileColors)
// Each messaging platform is its own self-managed section: split the
// separately-fetched messaging slice by source, newest platform first, rows
// within a platform by recency. Per-platform totals (when a "load more" has
@@ -1348,12 +1340,6 @@ export function ChatSidebar({
bySource.set(sourceId, list)
}
// When profileGrouped is true, also group by profile within each platform.
// Same helper (and therefore same keying/colors/order) as the recents
// profileGroups, so a platform's sub-groups line up with the recents view.
const buildProfileGroupsForPlatform = (sessions: SessionInfo[]): SidebarSessionGroup[] =>
buildProfileGroups(sessions, profileColors)
return [...bySource.entries()]
.map(([sourceId, list]) => {
const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a))
@@ -1361,7 +1347,7 @@ export function ChatSidebar({
const unpinnedKnown = known == null ? null : Math.max(0, known - (pinnedBySource.get(sourceId) ?? 0))
const total = Math.max(ordered.length, unpinnedKnown ?? 0)
const section: MessagingSection = {
return {
// Known exact total → more exist iff total exceeds loaded; otherwise
// the seed fetch was capped, so assume more until a per-platform load
// resolves the count.
@@ -1371,18 +1357,16 @@ export function ChatSidebar({
sourceId,
total
}
// When profileGrouped is true, split sessions into per-profile groups.
if (profileGrouped) {
section.profileGroups = buildProfileGroupsForPlatform(ordered)
}
return section
})
.sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}, [visibleMessagingSessions, messagingPlatformTotals, messagingTruncated, isPinnedSession, messagingProfile, profileGrouped, profileColors])
}, [visibleMessagingSessions, messagingPlatformTotals, messagingTruncated, isPinnedSession, messagingProfile])
const profileGroups = useGatewaySessionGroups(agentSessions, profileScope === ALL_PROFILES && grouping === 'profile')
// 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 connectionsRegistry = useStore($connectionsRegistry)
const profileColors = useStore($profileColors)
// The flat Sessions list always shows ALL recent sessions; Projects is a
// parallel grouped view, not a filter on this one — nothing is hidden here.
@@ -1989,51 +1973,20 @@ export function ChatSidebar({
// still has older threads on disk.
const canRevealMore = visible < group.sessions.length || group.hasMore
// When profileGrouped is true, render profile groups instead of flat sessions.
if (profileGrouped && group.profileGroups && group.profileGroups.length > 0) {
return (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn('flex max-h-56 flex-col gap-px pb-1.75', GROUP_BODY)}
emptyState={null}
footer={
canRevealMore ? (
<SidebarLoadMoreRow
loading={Boolean(messagingLoadMorePending[group.sourceId])}
onClick={() => revealMoreMessaging(group.sourceId, group.sessions.length, group.hasMore)}
step={Math.min(NON_SESSION_LOAD_STEP, Math.max(0, group.total - shownSessions.length))}
/>
) : null
}
groups={group.profileGroups}
key={group.sourceId}
label={group.label}
labelIcon={
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5625rem] [&_svg]:size-3"
platformId={group.sourceId}
platformName={group.label}
/>
}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() => toggleSidebarMessagingOpen(group.sourceId)}
onTogglePin={pinSession}
onToggleUnread={toggleUnread}
open={messagingOpenIds.includes(group.sourceId)}
pinned={false}
rootClassName="shrink-0 p-0"
sessions={[]}
showProfileTags={false}
/>
)
}
// Group only what the cap lets through, so the footer's count
// and load-more stay about the platform, not one of its groups.
const ownerGroups = ownerGrouped
? scopeGatewaySessionGroups(
buildGatewaySessionGroups(shownSessions, connectionsRegistry, profileColors),
`messaging:${group.sourceId}`
)
: undefined
return (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn('flex max-h-56 flex-col gap-px pb-1.75', GROUP_BODY)}
embeddedGroups
emptyState={null}
footer={
canRevealMore ? (
@@ -2044,6 +1997,7 @@ export function ChatSidebar({
/>
) : null
}
groups={ownerGroups}
key={group.sourceId}
label={group.label}
labelIcon={
@@ -2063,6 +2017,7 @@ export function ChatSidebar({
pinned={false}
rootClassName="shrink-0 p-0"
sessions={shownSessions}
showProfileTags={showAllProfiles && !ownerGrouped}
/>
)
})}
@@ -2104,7 +2059,4 @@ interface MessagingSection {
sessions: SessionInfo[]
total: number
hasMore: boolean
// When profileGrouped is true, sessions are split into per-profile groups.
// Each group is a SidebarSessionGroup with mode='profile' and the profile's color.
profileGroups?: SidebarSessionGroup[]
}

View File

@@ -1,53 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import { buildProfileGroups } from './profile-groups'
/** Build the smallest session row needed by the profile-groups tests. */
const row = (id: string, profile?: string): SessionInfo =>
({ id, message_count: 1, profile, source: 'signal', started_at: 0, title: id }) as SessionInfo
const NO_COLORS = {}
describe('buildProfileGroups', () => {
it('groups sessions by profile key', () => {
const groups = buildProfileGroups(
[row('default-row', 'default'), row('work-row', 'work'), row('other-row', 'work')],
NO_COLORS
)
expect(groups.map(group => [group.id, group.mode, group.sessions.length])).toEqual([
['default', 'profile', 1],
['work', 'profile', 2]
])
})
it('floats the default profile first, then sorts the rest alphabetically', () => {
const groups = buildProfileGroups(
[row('zeta-row', 'zeta'), row('alpha-row', 'alpha'), row('default-row', 'default')],
NO_COLORS
)
expect(groups.map(group => group.id)).toEqual(['default', 'alpha', 'zeta'])
})
it('treats legacy rows without a profile as default', () => {
const groups = buildProfileGroups([row('legacy-row'), row('work-row', 'work')], NO_COLORS)
expect(groups.map(group => group.id)).toEqual(['default', 'work'])
expect(groups[0].sessions.map(session => session.id)).toEqual(['legacy-row'])
})
it('carries the profile color from the overrides map', () => {
const groups = buildProfileGroups([row('work-row', 'work')], { work: '#aabbcc' })
expect(groups[0].color).toBe('#aabbcc')
})
it('keeps the default profile colorless', () => {
const groups = buildProfileGroups([row('default-row', 'default')], { default: '#aabbcc' })
expect(groups[0].color).toBeNull()
})
})

View File

@@ -1,41 +0,0 @@
import { resolveProfileColor } from '@/lib/profile-color'
import { normalizeProfileKey } from '@/store/profile'
import type { SessionInfo } from '@/types/hermes'
import type { SidebarSessionGroup } from './projects'
/**
* Build one collapsible group per profile over `sessions`, each carrying the
* profile's color on the header. Default (root) profile floats to the top, the
* rest sort alphabetically — the same ordering the recents list uses, so a
* messaging platform's profile sub-groups line up with the recents groups.
*
* Shared by the recents list (profileGrouped view) and the messaging platform
* sections (which group by profile only when the whole sidebar is in the
* profile-grouped view), so both surfaces attribute rows to the same profile
* key and paint them with the same color.
*/
export function buildProfileGroups(sessions: SessionInfo[], profileColors: Record<string, string>): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>()
for (const session of sessions) {
const key = normalizeProfileKey(session.profile)
const group = groups.get(key) ?? {
color: resolveProfileColor(key, profileColors),
id: key,
label: key,
mode: 'profile',
path: null,
sessions: []
}
group.sessions.push(session)
groups.set(key, group)
}
return [...groups.values()].sort((a, b) =>
a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)
)
}

View File

@@ -126,6 +126,8 @@ interface SidebarSessionsSectionProps {
headerAction?: React.ReactNode
footer?: React.ReactNode
groups?: SidebarSessionGroup[]
// Owner groups inside a messaging platform: the section's footer pages them.
embeddedGroups?: boolean
tree?: SidebarWorkspaceTree[]
// Project overview: when present, render a drill-in list of project rows
// instead of sessions. Clicking a row enters that project (onEnterProject),
@@ -214,6 +216,7 @@ export function SidebarSessionsSection({
headerAction,
footer,
groups,
embeddedGroups = false,
projectOverview,
projectOverviewPreviews,
projectOverviewHidden,
@@ -559,6 +562,7 @@ export function SidebarSessionsSection({
} else if (groups?.length && groups.every(group => group.mode === 'profile' && group.profile)) {
inner = (
<GatewayProfileGroups
embedded={embeddedGroups}
groups={groups}
onNewSessionSplit={onNewSessionSplit}
renderRows={renderRows}