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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user