diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 416483dde427..0a6df7adad87 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -102,6 +102,7 @@ import { countLabel } from './chrome' import { SidebarCronJobsSection } from './cron-jobs-section' import { SidebarLoadMoreRow } from './load-more-row' import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds } from './order' +import { filterSessionsByProfileScope } from './profile-scope' import { ProfileRail } from './profile-switcher' import { ProjectDialog } from './project-dialog' import { @@ -792,6 +793,11 @@ export function ChatSidebar({ [onLoadMoreMessaging, runKeyedLoad] ) + const visibleMessagingSessions = useMemo( + () => filterSessionsByProfileScope(messagingSessions, profileScope), + [messagingSessions, profileScope] + ) + // Reveal another batch of a platform's rows; fetch from the backend too if we // run past what's loaded and more remain on disk. const revealMoreMessaging = (platform: string, loaded: number, hasMore: boolean) => { @@ -809,13 +815,13 @@ export function ChatSidebar({ // within a platform by recency. Per-platform totals (when a "load more" has // resolved them) drive the count + whether more remain on disk. const messagingGroups = useMemo(() => { - if (!messagingSessions.length) { + if (!visibleMessagingSessions.length) { return [] } const bySource = new Map() - for (const session of messagingSessions) { + for (const session of visibleMessagingSessions) { const sourceId = normalizeSessionSource(session.source) if (!sourceId) { @@ -845,7 +851,7 @@ export function ChatSidebar({ } }) .sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0])) - }, [messagingSessions, messagingPlatformTotals, messagingTruncated]) + }, [visibleMessagingSessions, messagingPlatformTotals, messagingTruncated]) // 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. diff --git a/apps/desktop/src/app/chat/sidebar/profile-scope.test.ts b/apps/desktop/src/app/chat/sidebar/profile-scope.test.ts new file mode 100644 index 000000000000..ec4345eab561 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/profile-scope.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { ALL_PROFILES } from '@/store/profile' +import type { SessionInfo } from '@/types/hermes' + +import { filterSessionsByProfileScope } from './profile-scope' + +const session = (id: string, profile?: string): SessionInfo => ({ + archived: false, + cwd: null, + 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: 'feishu', + started_at: 1, + title: null, + tool_call_count: 0 +}) + +describe('filterSessionsByProfileScope', () => { + it('keeps only messaging rows from the selected profile', () => { + const rows = [session('default-row', 'default'), session('research-row', 'research')] + + expect(filterSessionsByProfileScope(rows, 'research').map(s => s.id)).toEqual(['research-row']) + }) + + it('treats missing profiles as default', () => { + const rows = [session('legacy-row'), session('research-row', 'research')] + + expect(filterSessionsByProfileScope(rows, 'default').map(s => s.id)).toEqual(['legacy-row']) + }) + + it('keeps every messaging row when persisted All profiles mode outlives the profile switcher', () => { + const rows = [session('default-row', 'default'), session('research-row', 'research')] + + expect(filterSessionsByProfileScope(rows, ALL_PROFILES).map(s => s.id)).toEqual([ + 'default-row', + 'research-row' + ]) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/profile-scope.ts b/apps/desktop/src/app/chat/sidebar/profile-scope.ts new file mode 100644 index 000000000000..1e59fe37436b --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/profile-scope.ts @@ -0,0 +1,12 @@ +import { ALL_PROFILES, normalizeProfileKey } from '@/store/profile' +import type { SessionInfo } from '@/types/hermes' + +export function filterSessionsByProfileScope(sessions: SessionInfo[], profileScope: string): SessionInfo[] { + if (profileScope === ALL_PROFILES) { + return sessions + } + + const scope = normalizeProfileKey(profileScope) + + return sessions.filter(session => normalizeProfileKey(session.profile) === scope) +} diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx new file mode 100644 index 000000000000..84e6e55a8b53 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx @@ -0,0 +1,104 @@ +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { listAllProfileSessions, type SessionInfo } from '@/hermes' +import { + $messagingPlatformTotals, + $messagingSessions, + setMessagingPlatformTotals, + setMessagingSessions, + setMessagingTruncated +} from '@/store/session' + +import { useSessionListActions } from './use-session-list-actions' + +vi.mock('@/hermes', async importOriginal => ({ + ...(await importOriginal>()), + getCronJobs: vi.fn(), + listAllProfileSessions: vi.fn() +})) + +interface Deferred { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + + const promise = new Promise(done => { + resolve = done + }) + + return { promise, resolve } +} + +function session(id: string, profile: string): SessionInfo { + return { + archived: false, + cwd: null, + 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: null, + tool_call_count: 0 + } +} + +describe('useSessionListActions', () => { + beforeEach(() => { + vi.clearAllMocks() + setMessagingSessions([]) + setMessagingPlatformTotals({ telegram: 3 }) + setMessagingTruncated(false) + }) + + afterEach(() => { + cleanup() + setMessagingSessions([]) + setMessagingPlatformTotals({}) + setMessagingTruncated(false) + }) + + it('ignores a stale scope callback before it can invalidate the current messaging request', async () => { + const alpha = deferred>>() + const beta = deferred>>() + const alphaRow = session('alpha-row', 'alpha') + const betaRow = session('beta-row', 'beta') + + vi.mocked(listAllProfileSessions).mockImplementation((_limit, _min, _archived, _order, profile) => + profile === 'alpha' ? alpha.promise : beta.promise + ) + + const { rerender, result } = renderHook(({ profileScope }) => useSessionListActions({ profileScope }), { + initialProps: { profileScope: 'alpha' } + }) + + const staleAlphaRefresh = result.current.refreshMessagingSessions + + rerender({ profileScope: 'beta' }) + + const betaRefresh = result.current.refreshMessagingSessions() + const staleAlphaRequest = staleAlphaRefresh() + + await act(async () => { + beta.resolve({ limit: 100, offset: 0, sessions: [betaRow], total: 1 }) + await betaRefresh + alpha.resolve({ limit: 100, offset: 0, sessions: [alphaRow], total: 1 }) + await staleAlphaRequest + }) + + expect(vi.mocked(listAllProfileSessions).mock.calls.map(call => call[4])).toEqual(['beta']) + expect($messagingSessions.get()).toEqual([betaRow]) + expect($messagingPlatformTotals.get()).toEqual({}) + }) +}) 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 6f971c3165be..f06afb1c3bbd 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 @@ -71,11 +71,17 @@ interface UseSessionListActionsArgs { profileScope: string } +const sessionProfileForScope = (profileScope: string): 'all' | string => + profileScope === ALL_PROFILES ? 'all' : normalizeProfileKey(profileScope) + /** Owns the sidebar's session-list fetching + paging: recents, cron runs/jobs, * and the per-platform messaging slices. Returns the callbacks the controller * wires into the sidebar and refresh effects. */ export function useSessionListActions({ profileScope }: UseSessionListActionsArgs) { + const profileScopeRef = useRef(profileScope) + const refreshMessagingSessionsRequestRef = useRef(0) const refreshSessionsRequestRef = useRef(0) + profileScopeRef.current = profileScope // 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 @@ -98,11 +104,29 @@ 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 sessionProfile = sessionProfileForScope(profileScope) + + if (sessionProfileForScope(profileScopeRef.current) !== sessionProfile) { + return + } + + const requestId = refreshMessagingSessionsRequestRef.current + 1 + refreshMessagingSessionsRequestRef.current = requestId + + setMessagingPlatformTotals({}) + 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 }) + if ( + refreshMessagingSessionsRequestRef.current !== requestId || + sessionProfileForScope(profileScopeRef.current) !== sessionProfile + ) { + return + } + // Drop any non-messaging source the broad exclude didn't catch (custom // sources) — those stay in local recents, not a platform section. const rows = result.sessions.filter(s => isMessagingSource(s.source)) @@ -114,20 +138,29 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg } catch { // Non-fatal: the messaging sections just stay empty/stale. } - }, []) + }, [profileScope]) // 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 sessionProfile = sessionProfileForScope(profileScope) + + const inProfileScope = (s: SessionInfo) => + sessionProfile === 'all' || normalizeProfileKey(s.profile) === sessionProfile + + const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform && inProfileScope(s) const loaded = $messagingSessions.get().filter(inPlatform).length - const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', { + const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', sessionProfile, { source: platform }) - const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform) + if (sessionProfileForScope(profileScopeRef.current) !== sessionProfile) { + return + } + + const incoming = result.sessions.filter(inPlatform) setMessagingSessions(prev => [ ...prev.filter(s => !inPlatform(s)), @@ -136,7 +169,7 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg const total = result.total ?? incoming.length setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) })) - }, []) + }, [profileScope]) // Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created // synchronously (agent tool call or the cron UI), so refreshing here right @@ -171,7 +204,7 @@ 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 sessionProfile = sessionProfileForScope(profileScope) const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, { excludeSources: SIDEBAR_EXCLUDED_SOURCES