diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 9a3fbe3a2748..d97986f0ac62 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -128,7 +128,13 @@ import { StartWorkButton, useRepoWorktreeMap } from './projects' -import { SidebarBlankState, SidebarPinnedEmptyState, SidebarSessionSkeletons } from './section-states' +import { + shouldIncludeMessagingSession, + shouldShowSessionSections, + SidebarBlankState, + SidebarPinnedEmptyState, + SidebarSessionSkeletons +} from './section-states' import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section' import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu' @@ -855,6 +861,10 @@ export function ChatSidebar({ const bySource = new Map() for (const session of messagingSessions) { + if (!shouldIncludeMessagingSession(profileScope, session.profile)) { + continue + } + const sourceId = normalizeSessionSource(session.source) if (!sourceId) { @@ -884,7 +894,7 @@ export function ChatSidebar({ } }) .sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0])) - }, [messagingSessions, messagingPlatformTotals, messagingTruncated]) + }, [messagingSessions, messagingPlatformTotals, messagingTruncated, profileScope]) // ALL-profiles view: one collapsible group per profile, color on the header // (not on every row). Default profile floats to the top, the rest alpha. @@ -1043,7 +1053,13 @@ export function ChatSidebar({ const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0 - const showSessionSections = showSessionSkeletons || sortedSessions.length > 0 || projectModel.length > 0 + const showSessionSections = shouldShowSessionSections({ + hasCronJobs: cronJobs.length > 0, + hasMessaging: messagingGroups.length > 0, + hasProjects: projectModel.length > 0, + hasSessions: sortedSessions.length > 0, + loadingSessions: showSessionSkeletons + }) // Each reorderable list reports its OWN new id order; persisting is a direct, // typed write — no id-prefix sniffing to figure out which level moved. diff --git a/apps/desktop/src/app/chat/sidebar/section-states.test.ts b/apps/desktop/src/app/chat/sidebar/section-states.test.ts new file mode 100644 index 000000000000..1d659e24b7cc --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/section-states.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { ALL_PROFILES } from '@/store/profile' + +import { shouldIncludeMessagingSession, shouldShowSessionSections } from './section-states' + +const emptySidebar = { + hasCronJobs: false, + hasMessaging: false, + hasProjects: false, + hasSessions: false, + loadingSessions: false +} + +describe('shouldShowSessionSections', () => { + it('keeps messaging visible without normal sessions', () => { + expect(shouldShowSessionSections({ ...emptySidebar, hasMessaging: true })).toBe(true) + }) + + it('keeps cron jobs visible without normal sessions', () => { + expect(shouldShowSessionSections({ ...emptySidebar, hasCronJobs: true })).toBe(true) + }) + + it('uses the blank state only when every section is empty', () => { + expect(shouldShowSessionSections(emptySidebar)).toBe(false) + }) +}) + +describe('shouldIncludeMessagingSession', () => { + it('keeps rows when a persisted All Profiles scope falls back to one profile', () => { + expect(shouldIncludeMessagingSession(ALL_PROFILES, 'default')).toBe(true) + }) + + it('filters rows outside a concrete profile scope', () => { + expect(shouldIncludeMessagingSession('work', 'default')).toBe(false) + expect(shouldIncludeMessagingSession('work', 'work')).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/section-states.tsx b/apps/desktop/src/app/chat/sidebar/section-states.tsx index d65eda981326..c1c96328413e 100644 --- a/apps/desktop/src/app/chat/sidebar/section-states.tsx +++ b/apps/desktop/src/app/chat/sidebar/section-states.tsx @@ -3,6 +3,29 @@ import { Codicon } from '@/components/ui/codicon' import { Skeleton } from '@/components/ui/skeleton' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' +import { ALL_PROFILES, normalizeProfileKey } from '@/store/profile' + +interface SidebarSectionVisibility { + hasCronJobs: boolean + hasMessaging: boolean + hasProjects: boolean + hasSessions: boolean + loadingSessions: boolean +} + +export function shouldShowSessionSections({ + hasCronJobs, + hasMessaging, + hasProjects, + hasSessions, + loadingSessions +}: SidebarSectionVisibility): boolean { + return loadingSessions || hasSessions || hasProjects || hasMessaging || hasCronJobs +} + +export function shouldIncludeMessagingSession(profileScope: string, sessionProfile?: string): boolean { + return profileScope === ALL_PROFILES || normalizeProfileKey(sessionProfile) === profileScope +} export function SidebarSessionSkeletons() { return ( diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.ts new file mode 100644 index 000000000000..d43cfc9e81ee --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.ts @@ -0,0 +1,189 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { getCronJobs, listAllProfileSessions, type PaginatedSessions, type SessionInfo } from '@/hermes' +import { ALL_PROFILES } from '@/store/profile' +import { + $messagingPlatformTotals, + $messagingSessions, + setMessagingPlatformTotals, + setMessagingSessions +} from '@/store/session' + +import { useSessionListActions } from './use-session-list-actions' + +vi.mock('@/hermes', async importOriginal => ({ + ...(await importOriginal>()), + getCronJobs: vi.fn(), + listAllProfileSessions: vi.fn() +})) + +const emptyPage: PaginatedSessions = { + limit: 50, + offset: 0, + profile_totals: {}, + sessions: [], + total: 0 +} + +function deferred() { + let resolve!: (value: T) => void + + const promise = new Promise(done => { + resolve = done + }) + + return { promise, resolve } +} + +function messagingSession(id: string, profile: string): SessionInfo { + return { + ended_at: null, + id, + input_tokens: 0, + is_active: false, + last_active: 1, + message_count: 1, + model: null, + output_tokens: 0, + preview: null, + profile, + source: 'telegram', + started_at: 1, + title: id, + tool_call_count: 0 + } +} + +describe('useSessionListActions messaging scope', () => { + beforeEach(() => { + setMessagingSessions([]) + setMessagingPlatformTotals({}) + vi.mocked(getCronJobs).mockResolvedValue([]) + vi.mocked(listAllProfileSessions).mockResolvedValue(emptyPage) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('fetches messaging sessions only for the active profile', async () => { + const { result } = renderHook(() => useSessionListActions({ profileScope: 'nolan' })) + + await act(async () => { + await result.current.refreshMessagingSessions() + }) + + expect(listAllProfileSessions).toHaveBeenCalledWith( + expect.any(Number), + 1, + 'exclude', + 'recent', + 'nolan', + expect.objectContaining({ excludeSources: expect.any(Array) }) + ) + }) + + it('keeps messaging global in the explicit all-profiles view', async () => { + const { result } = renderHook(() => useSessionListActions({ profileScope: ALL_PROFILES })) + + await act(async () => { + await result.current.refreshMessagingSessions() + }) + + expect(listAllProfileSessions).toHaveBeenCalledWith( + expect.any(Number), + 1, + 'exclude', + 'recent', + 'all', + expect.any(Object) + ) + }) + + it('pages one messaging platform within the active profile', async () => { + const { result } = renderHook(() => useSessionListActions({ profileScope: 'silas' })) + + await act(async () => { + await result.current.loadMoreMessagingForPlatform('slack') + }) + + expect(listAllProfileSessions).toHaveBeenCalledWith(expect.any(Number), 1, 'exclude', 'recent', 'silas', { + source: 'slack' + }) + }) + + it('ignores an older profile response that resolves after the active profile', async () => { + const first = deferred() + const second = deferred() + + vi.mocked(listAllProfileSessions) + .mockReset() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise) + + const { rerender, result } = renderHook(({ profileScope }) => useSessionListActions({ profileScope }), { + initialProps: { profileScope: 'nolan' } + }) + + let firstRequest!: Promise + act(() => { + firstRequest = result.current.refreshMessagingSessions() + }) + + rerender({ profileScope: 'silas' }) + + let secondRequest!: Promise + act(() => { + secondRequest = result.current.refreshMessagingSessions() + }) + + await act(async () => { + second.resolve({ ...emptyPage, sessions: [messagingSession('silas-session', 'silas')], total: 1 }) + await secondRequest + }) + + await act(async () => { + first.resolve({ ...emptyPage, sessions: [messagingSession('nolan-session', 'nolan')], total: 1 }) + await firstRequest + }) + + expect($messagingSessions.get().map(session => session.id)).toEqual(['silas-session']) + }) + + it('ignores a load-more response after the active profile changes', async () => { + const page = deferred() + + vi.mocked(listAllProfileSessions) + .mockReset() + .mockImplementationOnce(() => page.promise) + setMessagingSessions([messagingSession('nolan-seed', 'nolan')]) + setMessagingPlatformTotals({ telegram: 1 }) + + const { rerender, result } = renderHook(({ profileScope }) => useSessionListActions({ profileScope }), { + initialProps: { profileScope: 'nolan' } + }) + + let loadMoreRequest!: Promise + act(() => { + loadMoreRequest = result.current.loadMoreMessagingForPlatform('telegram') + }) + + rerender({ profileScope: 'silas' }) + setMessagingSessions([messagingSession('silas-session', 'silas')]) + + await act(async () => { + page.resolve({ + ...emptyPage, + sessions: [messagingSession('nolan-more', 'nolan')], + total: 2 + }) + await loadMoreRequest + }) + + expect($messagingSessions.get().map(session => session.id)).toEqual(['silas-session']) + expect($messagingPlatformTotals.get()).toEqual({ telegram: 1 }) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts index 6e4cbb7cc021..6cab33ea3b1d 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -74,8 +74,13 @@ interface UseSessionListActionsArgs { * and the per-platform messaging slices. Returns the callbacks the controller * wires into the sidebar and refresh effects. */ export function useSessionListActions({ profileScope }: UseSessionListActionsArgs) { + const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope + const messagingProfileRef = useRef(sessionProfile) + const refreshMessagingSessionsRequestRef = useRef(0) const refreshSessionsRequestRef = useRef(0) + messagingProfileRef.current = sessionProfile + // Cron-job sessions as their own list (latest N). Independent of the recents // page so the two never compete for slots. Cheap + bounded. Kept (even though // the sidebar now lists cron *jobs*, not run sessions) so a pinned cron run @@ -97,8 +102,11 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg // competes with local chats for the recents page budget. One combined fetch // seeds every platform; the sidebar splits the rows per source. const refreshMessagingSessions = useCallback(async () => { + const requestId = refreshMessagingSessionsRequestRef.current + 1 + refreshMessagingSessionsRequestRef.current = requestId + try { - const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { + const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', sessionProfile, { excludeSources: MESSAGING_EXCLUDED_SOURCES }) @@ -106,36 +114,50 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg // sources) — those stay in local recents, not a platform section. const rows = result.sessions.filter(s => isMessagingSource(s.source)) - setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows)) - // Hit the cap → at least one platform may have more on disk than loaded, - // so platform sections offer their own per-platform "load more". - setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT) + if (messagingProfileRef.current === sessionProfile && refreshMessagingSessionsRequestRef.current === requestId) { + setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows)) + // Hit the cap → at least one platform may have more on disk than loaded, + // so platform sections offer their own per-platform "load more". + setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT) + } } catch { // Non-fatal: the messaging sections just stay empty/stale. } - }, []) + }, [sessionProfile]) // Page a single platform's section independently (mirrors the per-profile // pager): fetch that source's next window and merge it back in place, leaving // every other platform's rows untouched. Resolves the platform's exact total. - const loadMoreMessagingForPlatform = useCallback(async (platform: string) => { - const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform - const loaded = $messagingSessions.get().filter(inPlatform).length - - const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', { - source: platform - }) + const loadMoreMessagingForPlatform = useCallback( + async (platform: string) => { + const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform + const loaded = $messagingSessions.get().filter(inPlatform).length + + const result = await listAllProfileSessions( + loaded + SIDEBAR_SESSIONS_PAGE_SIZE, + 1, + 'exclude', + 'recent', + sessionProfile, + { source: platform } + ) + + if (messagingProfileRef.current !== sessionProfile) { + return + } - const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform) + const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform) - setMessagingSessions(prev => [ - ...prev.filter(s => !inPlatform(s)), - ...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep()) - ]) + setMessagingSessions(prev => [ + ...prev.filter(s => !inPlatform(s)), + ...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep()) + ]) - const total = result.total ?? incoming.length - setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) })) - }, []) + const total = result.total ?? incoming.length + setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) })) + }, + [sessionProfile] + ) // Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created // synchronously (agent tool call or the cron UI), so refreshing here right @@ -170,8 +192,6 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg // Scope the fetch to the active profile (not always 'all') so a profile // with few recent sessions isn't windowed out of the cross-profile // recency page — the empty-history-on-profile-switch bug. - const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope - const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, { excludeSources: SIDEBAR_EXCLUDED_SOURCES }) @@ -190,7 +210,7 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg void refreshCronSessions() void refreshCronJobs() void refreshMessagingSessions() - }, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions]) + }, [refreshCronSessions, refreshCronJobs, refreshMessagingSessions, sessionProfile]) const loadMoreSessions = useCallback(async () => { bumpSessionsLimit()