From bbba4d4f772bd635ec6f0ccf94258cd84f45bcaf Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Thu, 24 Sep 2026 02:40:59 -0500 Subject: [PATCH] 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> --- .../sidebar/chat-sidebar.integration.test.tsx | 122 +++++++++++++++++- .../chat/sidebar/gateway-group-model.test.ts | 62 +++++++++ .../app/chat/sidebar/gateway-group-model.ts | 85 +++++++----- .../src/app/chat/sidebar/gateway-groups.tsx | 32 +++-- apps/desktop/src/app/chat/sidebar/index.tsx | 90 +++---------- .../app/chat/sidebar/profile-groups.test.ts | 53 -------- .../src/app/chat/sidebar/profile-groups.ts | 41 ------ .../src/app/chat/sidebar/sessions-section.tsx | 4 + 8 files changed, 281 insertions(+), 208 deletions(-) create mode 100644 apps/desktop/src/app/chat/sidebar/gateway-group-model.test.ts delete mode 100644 apps/desktop/src/app/chat/sidebar/profile-groups.test.ts delete mode 100644 apps/desktop/src/app/chat/sidebar/profile-groups.ts diff --git a/apps/desktop/src/app/chat/sidebar/chat-sidebar.integration.test.tsx b/apps/desktop/src/app/chat/sidebar/chat-sidebar.integration.test.tsx index 9778efe589..b5a0b76a92 100644 --- a/apps/desktop/src/app/chat/sidebar/chat-sidebar.integration.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/chat-sidebar.integration.test.tsx @@ -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('[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) + $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() + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/gateway-group-model.test.ts b/apps/desktop/src/app/chat/sidebar/gateway-group-model.test.ts new file mode 100644 index 0000000000..f4a3738dae --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/gateway-group-model.test.ts @@ -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) => + 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)) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/gateway-group-model.ts b/apps/desktop/src/app/chat/sidebar/gateway-group-model.ts index 9bf36e1df0..f9cdf1fc8e 100644 --- a/apps/desktop/src/app/chat/sidebar/gateway-group-model.ts +++ b/apps/desktop/src/app/chat/sidebar/gateway-group-model.ts @@ -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 +): SidebarSessionGroup[] { + const groups = new Map() + + 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(() => { - if (!enabled) { - return undefined - } - - const groups = new Map() - - 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] + ) } diff --git a/apps/desktop/src/app/chat/sidebar/gateway-groups.tsx b/apps/desktop/src/app/chat/sidebar/gateway-groups.tsx index 17c47a5233..fce094a4a7 100644 --- a/apps/desktop/src/app/chat/sidebar/gateway-groups.tsx +++ b/apps/desktop/src/app/chat/sidebar/gateway-groups.tsx @@ -51,6 +51,9 @@ interface GatewayProfileGroupsProps { sensors?: ReturnType 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({ {ordered.map((group, index) => ( ({ ...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={
- {group.profile && ( + {group.profile && !embedded && ( {children} - {group.profile ? ( + {group.profile && !embedded ? ( ) : null} - {renderRows(sessions.slice(0, visibleCount))} + {renderRows(embedded ? sessions : sessions.slice(0, visibleCount))} {hiddenCount > 0 && ( - 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 ( - 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={ - - } - 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 ( ) : 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[] } diff --git a/apps/desktop/src/app/chat/sidebar/profile-groups.test.ts b/apps/desktop/src/app/chat/sidebar/profile-groups.test.ts deleted file mode 100644 index f2bed6e200..0000000000 --- a/apps/desktop/src/app/chat/sidebar/profile-groups.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/apps/desktop/src/app/chat/sidebar/profile-groups.ts b/apps/desktop/src/app/chat/sidebar/profile-groups.ts deleted file mode 100644 index 47e2efd41e..0000000000 --- a/apps/desktop/src/app/chat/sidebar/profile-groups.ts +++ /dev/null @@ -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): SidebarSessionGroup[] { - const groups = new Map() - - 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) - ) -} diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx index fe9310622a..cc310f655a 100644 --- a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx @@ -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 = (