diff --git a/apps/desktop/pr-assets/session-source-folders.png b/apps/desktop/pr-assets/session-source-folders.png new file mode 100644 index 00000000000..b8d8a969b79 Binary files /dev/null and b/apps/desktop/pr-assets/session-source-folders.png differ diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index dcc516deadc..7b425a1a901 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -19,6 +19,7 @@ import { useStore } from '@nanostores/react' import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { PlatformAvatar } from '@/app/messaging/platform-icon' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { DisclosureCaret } from '@/components/ui/disclosure-caret' @@ -38,6 +39,7 @@ import { Tip } from '@/components/ui/tooltip' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { useI18n } from '@/i18n' import { profileColor } from '@/lib/profile-color' +import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source' import { sessionMatchesSearch } from '@/lib/session-search' import { cn } from '@/lib/utils' import { $cronJobs } from '@/store/cron' @@ -118,6 +120,7 @@ const WORKSPACE_PAGE = 5 // unified list scannable, then reveal/fetch more in N-sized steps on demand. const PROFILE_INITIAL_PAGE = 5 const WS_ID_PREFIX = 'workspace:' +const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui']) const wsId = (id: string) => `${WS_ID_PREFIX}${id}` const parseWsId = (id: string) => (id.startsWith(WS_ID_PREFIX) ? id.slice(WS_ID_PREFIX.length) : null) @@ -208,6 +211,46 @@ function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string): return [...groups.values()] } +function sourceSessionGroupsFor(sessions: SessionInfo[]): { + localSessions: SessionInfo[] + sourceGroups: SidebarSessionGroup[] +} { + const groups = new Map() + const localSessions: SessionInfo[] = [] + + for (const session of sessions) { + const sourceId = normalizeSessionSource(session.source) + + if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) { + localSessions.push(session) + + continue + } + + const label = sessionSourceLabel(sourceId) ?? sourceId + const group = groups.get(sourceId) ?? { + id: `source:${sourceId}`, + label, + mode: 'source', + path: null, + sessions: [], + sourceId + } + + group.sessions.push(session) + groups.set(sourceId, group) + } + + for (const group of groups.values()) { + group.sessions.sort((a, b) => sessionTime(b) - sessionTime(a)) + } + + return { + localSessions, + sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0])) + } +} + function useSortableBindings(id: string) { const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id }) @@ -434,9 +477,14 @@ export function ChatSidebar({ [unpinnedAgentSessions, agentOrderIds] ) + const { localSessions: localAgentSessions, sourceGroups } = useMemo( + () => sourceSessionGroupsFor(agentSessions), + [agentSessions] + ) + const agentGroups = useMemo( - () => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds), - [agentSessions, s.noWorkspace, workspaceOrderIds] + () => orderByIds(workspaceGroupsFor(localAgentSessions, s.noWorkspace), g => g.id, workspaceOrderIds), + [localAgentSessions, s.noWorkspace, workspaceOrderIds] ) const loadMoreForProfileGroup = useCallback( @@ -449,9 +497,7 @@ export function ChatSidebar({ void Promise.resolve(onLoadMoreProfileSessions(profile)) .catch(() => undefined) - .finally(() => - setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest) - ) + .finally(() => setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest)) }, [onLoadMoreProfileSessions] ) @@ -482,15 +528,17 @@ export function ChatSidebar({ groups.set(key, group) } - return [...groups.values()] - .map(group => ({ - ...group, - loadingMore: Boolean(profileLoadMorePending[group.id]), - onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined, - totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0) - })) - // default (root) first, then the rest alphabetically. - .sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label))) + return ( + [...groups.values()] + .map(group => ({ + ...group, + loadingMore: Boolean(profileLoadMorePending[group.id]), + onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined, + totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0) + })) + // default (root) first, then the rest alphabetically. + .sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label))) + ) }, [ showAllProfiles, agentSessions, @@ -500,6 +548,30 @@ export function ChatSidebar({ sessionProfileTotals ]) + const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions + + const displayAgentGroups = useMemo(() => { + if (sourceGroups.length) { + const localGroups = agentsGrouped + ? agentGroups + : localAgentSessions.length + ? [ + { + id: 'local-sessions', + label: 'Local', + mode: 'workspace' as const, + path: null, + sessions: localAgentSessions + } + ] + : [] + + return [...sourceGroups, ...localGroups] + } + + return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined + }, [agentGroups, agentsGrouped, localAgentSessions, profileGroups, showAllProfiles, sourceGroups]) + const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0 const showSessionSections = showSessionSkeletons || sortedSessions.length > 0 @@ -735,7 +807,7 @@ export function ChatSidebar({ ) : null } forceEmptyState={showSessionSkeletons} - groups={showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined} + groups={displayAgentGroups} headerAction={ // Always reserve the icon-xs (size-6) slot so the header keeps the // same height whether or not the toggle renders — otherwise the @@ -744,7 +816,7 @@ export function ChatSidebar({ // the toggle does nothing, and it's irrelevant in the ALL-profiles // view (always grouped by profile), so hide the button (not the slot).
- {!showAllProfiles && agentSessions.length > 0 ? ( + {!showAllProfiles && localAgentSessions.length > 0 ? (
{!isWorking && (