diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 4361678cb8fe..27386d754af2 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -39,6 +39,7 @@ import { resolveTestWsUrl, RT_COOKIE_VARIANTS, savedProfileSsh, + sidebarSessionSliceProfiles, tokenPreview } from './connection-config' @@ -51,6 +52,25 @@ test('connectionScopeKey trims to a name or null for the global scope', () => { assert.equal(connectionScopeKey(undefined), null) }) +test('sidebarSessionSliceProfiles scopes every remote slice to the concrete profile', () => { + assert.deepEqual(sidebarSessionSliceProfiles(' alma '), { + recents: 'alma', + cron: 'alma', + messaging: 'alma' + }) +}) + +test('sidebarSessionSliceProfiles preserves All Profiles aggregation', () => { + const aggregate = { + recents: 'all', + cron: 'all', + messaging: 'all' + } + + assert.deepEqual(sidebarSessionSliceProfiles('all'), aggregate) + assert.deepEqual(sidebarSessionSliceProfiles(null), aggregate) +}) + test('normAuthMode coerces to token unless explicitly oauth', () => { assert.equal(normAuthMode('oauth'), 'oauth') assert.equal(normAuthMode('token'), 'token') diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index f7b1ac2e0d42..fad7b2010b1f 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -187,6 +187,19 @@ function connectionScopeKey(profile) { return String(profile ?? '').trim() || null } +// The profile rail selects one sidebar workspace. Keep the mapping for every +// remote session slice at this shared boundary so no caller can accidentally +// reintroduce a cross-profile sibling while constructing one request. +function sidebarSessionSliceProfiles(profile) { + const scope = connectionScopeKey(profile) ?? 'all' + + return { + recents: scope, + cron: scope, + messaging: scope + } +} + // Coerce a remote auth mode to one of the two supported values ('token' default). function normAuthMode(mode) { return mode === 'oauth' ? 'oauth' : 'token' @@ -509,5 +522,6 @@ export { resolveTestWsUrl, RT_COOKIE_VARIANTS, savedProfileSsh, + sidebarSessionSliceProfiles, tokenPreview } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 3856cd2abc22..0ffe819d8563 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -63,6 +63,7 @@ import { resolveAuthMode, resolveTestWsUrl, savedProfileSsh, + sidebarSessionSliceProfiles, tokenPreview } from './connection-config' import { adoptServedDashboardToken } from './dashboard-token' @@ -9126,7 +9127,7 @@ async function interceptSessionRequestForRemote(request) { return undefined // local fast path → batched endpoint's single DB open } - const recentsProfile = (searchParams.get('recents_profile') || 'all').trim() || 'all' + const sliceProfiles = sidebarSessionSliceProfiles(searchParams.get('recents_profile')) const sliceParams = (limitKey, defaultLimit, extra) => { const sp = new URLSearchParams({ @@ -9141,16 +9142,16 @@ async function interceptSessionRequestForRemote(request) { return sp } - const recentsSp = sliceParams('recents_limit', '20', { profile: recentsProfile }) + const recentsSp = sliceParams('recents_limit', '20', { profile: sliceProfiles.recents }) const recentsExclude = searchParams.get('recents_exclude') if (recentsExclude) { recentsSp.set('exclude_sources', recentsExclude) } - const cronSp = sliceParams('cron_limit', '50', { profile: 'all', source: 'cron' }) + const cronSp = sliceParams('cron_limit', '50', { profile: sliceProfiles.cron, source: 'cron' }) - const messagingSp = sliceParams('messaging_limit', '100', { profile: 'all' }) + const messagingSp = sliceParams('messaging_limit', '100', { profile: sliceProfiles.messaging }) const messagingExclude = searchParams.get('messaging_exclude') if (messagingExclude) { diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 5b3f2b4b30c2..0c470d7781c7 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -5,6 +5,7 @@ import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' +import { buildPinnedSessionIndex } from '@/app/chat/sidebar/session-pin-index' import { PlatformAvatar } from '@/app/messaging/platform-icon' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -198,7 +199,7 @@ const HEADER_NAV_BTN = // FTS results cover sessions that aren't in the loaded page; synthesize a // minimal SessionInfo so they render in the same row component (resume works // by id; the snippet stands in for the preview). -function searchResultToSession(result: SessionSearchResult): SessionInfo { +function searchResultToSession(result: SessionSearchResult, profile?: string): SessionInfo { const ts = result.session_started ?? Date.now() / 1000 return { @@ -214,6 +215,7 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo { model: result.model ?? null, output_tokens: 0, preview: result.snippet?.trim() || null, + profile, source: result.source ?? null, started_at: ts, title: null, @@ -311,6 +313,7 @@ export function ChatSidebar({ // profile while scope is still ALL (persisted), the rail is hidden and they'd // otherwise be stuck in the grouped view with no way out. const showAllProfiles = multiProfile && profileScope === ALL_PROFILES + const aggregateAllProfiles = profileScope === ALL_PROFILES const agentOrderIds = useStore($sidebarSessionOrderIds) const agentOrderManual = useStore($sidebarSessionOrderManual) const workspaceOrderIds = useStore($sidebarWorkspaceOrderIds) @@ -329,7 +332,13 @@ export function ChatSidebar({ const newSessionCombo = useStore($bindings)['session.new']?.[0] const newSessionKbd = newSessionCombo ? comboTokens(newSessionCombo) : [] const [searchQuery, setSearchQuery] = useState('') - const [serverMatches, setServerMatches] = useState([]) + const searchScope = aggregateAllProfiles ? ALL_PROFILES : profileScope + + const [serverSearch, setServerSearch] = useState<{ + scope: string + results: SessionSearchResult[] + }>({ scope: searchScope, results: [] }) + const [searchPending, setSearchPending] = useState(false) const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false) const [profileLoadMorePending, setProfileLoadMorePending] = useState>({}) @@ -381,8 +390,24 @@ export function ChatSidebar({ // profile in, grouped by profile below. Single-profile users land here with // scope === their only profile, so nothing is filtered out. const visibleSessions = useMemo( - () => (showAllProfiles ? sessions : sessions.filter(s => normalizeProfileKey(s.profile) === profileScope)), - [sessions, showAllProfiles, profileScope] + () => (aggregateAllProfiles ? sessions : sessions.filter(s => normalizeProfileKey(s.profile) === profileScope)), + [sessions, aggregateAllProfiles, profileScope] + ) + + const visibleCronSessions = useMemo( + () => + aggregateAllProfiles + ? cronSessions + : cronSessions.filter(s => normalizeProfileKey(s.profile) === profileScope), + [cronSessions, aggregateAllProfiles, profileScope] + ) + + const visibleMessagingSessions = useMemo( + () => + aggregateAllProfiles + ? messagingSessions + : messagingSessions.filter(s => normalizeProfileKey(s.profile) === profileScope), + [messagingSessions, aggregateAllProfiles, profileScope] ) // Agent session order is pinned to creation time (started_at), NOT activity — @@ -398,21 +423,15 @@ export function ChatSidebar({ // Index sessions by both their live id and their lineage-root id so a pin // stored as the pre-compression root resolves to the live continuation tip. const sessionByAnyId = useMemo(() => { - const map = new Map() - - // Cron sessions are listed separately but can still be pinned, so index - // them too — otherwise a pinned cron job can't resolve into the Pinned - // section. Recents take precedence on id collisions (set last). - for (const s of [...cronSessions, ...visibleSessions]) { - map.set(s.id, s) - - if (s._lineage_root_id && !map.has(s._lineage_root_id)) { - map.set(s._lineage_root_id, s) - } - } - - return map - }, [visibleSessions, cronSessions]) + // Recents are passed last and therefore win id collisions. + return buildPinnedSessionIndex( + profileScope, + aggregateAllProfiles, + visibleCronSessions, + visibleMessagingSessions, + visibleSessions + ) + }, [profileScope, aggregateAllProfiles, visibleSessions, visibleCronSessions, visibleMessagingSessions]) const pinnedSessions = useMemo(() => { const seen = new Set() @@ -432,26 +451,27 @@ export function ChatSidebar({ const pinnedRealIdSet = useMemo(() => new Set(pinnedSessions.map(s => s.id)), [pinnedSessions]) - // Full-text search across *all* sessions (not just the loaded page) so 699 - // sessions stay findable. Debounced; loaded sessions are matched instantly - // client-side and merged ahead of the server hits. + // Full-text search covers the complete selected profile, not just its loaded + // page. All Profiles preserves the existing aggregate/default request. useEffect(() => { if (!trimmedQuery) { - setServerMatches([]) + setServerSearch({ scope: searchScope, results: [] }) setSearchPending(false) return } let cancelled = false + const searchProfile = aggregateAllProfiles ? null : profileScope + setServerSearch({ scope: searchScope, results: [] }) setSearchPending(true) const id = window.setTimeout(() => { - void searchSessions(trimmedQuery) + void searchSessions(trimmedQuery, searchProfile) .then(res => { if (!cancelled) { - setServerMatches(res.results) + setServerSearch({ scope: searchScope, results: res.results }) } }) .catch(() => undefined) @@ -466,7 +486,7 @@ export function ChatSidebar({ cancelled = true window.clearTimeout(id) } - }, [trimmedQuery]) + }, [trimmedQuery, aggregateAllProfiles, profileScope, searchScope]) const searchResults = useMemo(() => { if (!trimmedQuery) { @@ -481,17 +501,22 @@ export function ChatSidebar({ } } + const serverMatches = serverSearch.scope === searchScope ? serverSearch.results : [] + for (const match of serverMatches) { if (out.has(match.session_id)) { continue } const loaded = sessionByAnyId.get(match.session_id) - out.set(match.session_id, loaded ?? searchResultToSession(match)) + out.set( + match.session_id, + loaded ?? searchResultToSession(match, aggregateAllProfiles ? undefined : profileScope) + ) } return [...out.values()] - }, [trimmedQuery, sortedSessions, serverMatches, sessionByAnyId]) + }, [trimmedQuery, sortedSessions, serverSearch, searchScope, sessionByAnyId, aggregateAllProfiles, profileScope]) const unpinnedAgentSessions = useMemo( () => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)), @@ -863,13 +888,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) { @@ -884,8 +909,10 @@ export function ChatSidebar({ return [...bySource.entries()] .map(([sourceId, list]) => { const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a)) + const unpinned = ordered.filter(session => !pinnedRealIdSet.has(session.id)) const known = messagingPlatformTotals[sourceId] - const total = Math.max(ordered.length, known ?? 0) + const pinnedLoaded = ordered.length - unpinned.length + const total = Math.max(unpinned.length, (known ?? ordered.length) - pinnedLoaded) return { // Known exact total → more exist iff total exceeds loaded; otherwise @@ -893,13 +920,15 @@ export function ChatSidebar({ // resolves the count. hasMore: known != null ? known > ordered.length : messagingTruncated, label: sessionSourceLabel(sourceId) ?? sourceId, - sessions: ordered, + sessions: unpinned, sourceId, - total + total, + sortTime: sessionTime(ordered[0]) } }) - .sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0])) - }, [messagingSessions, messagingPlatformTotals, messagingTruncated]) + .sort((a, b) => b.sortTime - a.sortTime) + .map(({ sortTime: _sortTime, ...section }) => section) + }, [visibleMessagingSessions, messagingPlatformTotals, messagingTruncated, pinnedRealIdSet]) // 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. @@ -957,11 +986,11 @@ export function ChatSidebar({ // keeps "Load more" stuck on while you browse a small one (the aggregator's // total sums every profile). Per-profile totals come from the aggregator // (children excluded); fall back to the global total / loaded count. - const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length - const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope] + const loadedSessionCount = aggregateAllProfiles ? sessions.length : visibleSessions.length + const scopedProfileTotal = aggregateAllProfiles ? undefined : sessionProfileTotals[profileScope] const knownSessionTotal = Math.max( - showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount), + aggregateAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount), loadedSessionCount ) diff --git a/apps/desktop/src/app/chat/sidebar/session-pin-index.test.ts b/apps/desktop/src/app/chat/sidebar/session-pin-index.test.ts new file mode 100644 index 000000000000..102ef3ec3780 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/session-pin-index.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/hermes' + +import { buildPinnedSessionIndex } from './session-pin-index' + +const messaging = (id: string, profile: string): SessionInfo => + ({ id, profile, source: 'telegram' }) as SessionInfo + +describe('buildPinnedSessionIndex', () => { + it('resolves only Messaging pins owned by the concrete profile', () => { + const index = buildPinnedSessionIndex( + 'alma', + false, + [], + [], + [messaging('alma-telegram', 'alma'), messaging('aegis-telegram', 'aegis_h-01')] + ) + + expect(index.has('alma-telegram')).toBe(true) + expect(index.has('aegis-telegram')).toBe(false) + }) + + it('resolves Messaging pins from every profile in All Profiles', () => { + const index = buildPinnedSessionIndex( + '__all__', + true, + [], + [], + [messaging('alma-telegram', 'alma'), messaging('aegis-telegram', 'aegis_h-01')] + ) + + expect([...index.keys()]).toEqual(['alma-telegram', 'aegis-telegram']) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/session-pin-index.ts b/apps/desktop/src/app/chat/sidebar/session-pin-index.ts new file mode 100644 index 000000000000..68d6fa6237a3 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/session-pin-index.ts @@ -0,0 +1,24 @@ +import type { SessionInfo } from '@/hermes' +import { normalizeProfileKey } from '@/store/profile' + +export function buildPinnedSessionIndex( + profileScope: string, + aggregateAllProfiles: boolean, + ...sessionGroups: SessionInfo[][] +): Map { + const index = new Map() + + for (const session of sessionGroups.flat()) { + if (!aggregateAllProfiles && normalizeProfileKey(session.profile) !== profileScope) { + continue + } + + index.set(session.id, session) + + if (session._lineage_root_id && !index.has(session._lineage_root_id)) { + index.set(session._lineage_root_id, session) + } + } + + return index +} 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 index b5c3ef07217f..99023b60817a 100644 --- 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 @@ -4,11 +4,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SessionInfo, SidebarSessionsResponse } from '@/hermes' import { $cronSessions, + $messagingPlatformTotals, $messagingSessions, + $messagingTruncated, $sessions, $sessionsLoading, setCronSessions, + setMessagingPlatformTotals, setMessagingSessions, + setMessagingTruncated, setSessions, setSessionsLoading } from '@/store/session' @@ -55,6 +59,16 @@ const sidebar = ( const listSidebarSessions = vi.fn() const listAllProfileSessions = vi.fn() +const deferred = () => { + let resolve!: (value: T) => void + + const promise = new Promise(done => { + resolve = done + }) + + return { promise, resolve } +} + vi.mock('@/hermes', async importOriginal => ({ ...(await importOriginal>()), getCronJobs: vi.fn(async () => []), @@ -77,6 +91,8 @@ beforeEach(() => { setSessions([]) setCronSessions([]) setMessagingSessions([]) + setMessagingPlatformTotals({}) + setMessagingTruncated(false) setSessionsLoading(false) }) @@ -84,6 +100,8 @@ afterEach(() => { setSessions([]) setCronSessions([]) setMessagingSessions([]) + setMessagingPlatformTotals({}) + setMessagingTruncated(false) setSessionsLoading(false) }) @@ -236,6 +254,173 @@ describe('refreshSessions batches slices into one request', () => { ) }) + it.each(['default', 'alma', 'aegis_h-01', 'synapse_h-01'])( + 'keeps every sidebar slice inside concrete profile %s', + async profileScope => { + const own = row(`${profileScope}-local`, { profile: profileScope }) + const ownCron = row(`${profileScope}-cron`, { profile: profileScope, source: 'cron' }) + const ownTelegram = row(`${profileScope}-telegram`, { profile: profileScope, source: 'telegram' }) + const sibling = row('sibling-local', { profile: 'sibling' }) + const siblingCron = row('sibling-cron', { profile: 'sibling', source: 'cron' }) + const siblingTelegram = row('sibling-telegram', { profile: 'sibling', source: 'telegram' }) + + listSidebarSessions.mockResolvedValue( + sidebar( + { sessions: [own, sibling], total: 2, profile_totals: { [profileScope]: 1, sibling: 1 } }, + [ownCron, siblingCron], + [ownTelegram, siblingTelegram] + ) + ) + + const { result } = renderHook(() => useSessionListActions({ profileScope })) + + await act(async () => { + await result.current.refreshSessions() + }) + + expect($sessions.get().map(s => s.id)).toEqual([`${profileScope}-local`]) + expect($cronSessions.get().map(s => s.id)).toEqual([`${profileScope}-cron`]) + expect($messagingSessions.get().map(s => s.id)).toEqual([`${profileScope}-telegram`]) + } + ) + + it('keeps provenance while All Profiles aggregates every profile', async () => { + const messaging = [ + row('default-telegram', { profile: 'default', source: 'telegram' }), + row('alma-telegram', { profile: 'alma', source: 'telegram' }) + ] + + listSidebarSessions.mockResolvedValue( + sidebar({ sessions: [], total: 0, profile_totals: {} }, [], messaging) + ) + + const { result } = renderHook(() => useSessionListActions({ profileScope: '__all__' })) + + await act(async () => { + await result.current.refreshSessions() + }) + + expect($messagingSessions.get().map(s => [s.id, s.profile])).toEqual([ + ['default-telegram', 'default'], + ['alma-telegram', 'alma'] + ]) + }) + + it('replaces default rows with Alma rows after a profile switch', async () => { + listSidebarSessions + .mockResolvedValueOnce( + sidebar( + { sessions: [row('default-local')], total: 1, profile_totals: { default: 1 } }, + [], + [row('default-telegram', { source: 'telegram' })] + ) + ) + .mockResolvedValueOnce( + sidebar( + { sessions: [row('alma-local', { profile: 'alma' })], total: 1, profile_totals: { alma: 1 } }, + [], + [row('alma-telegram', { profile: 'alma', source: 'telegram' })] + ) + ) + + const { result, rerender } = renderHook( + ({ scope }: { scope: string }) => useSessionListActions({ profileScope: scope }), + { initialProps: { scope: 'default' } } + ) + + await act(async () => { + await result.current.refreshSessions() + }) + rerender({ scope: 'alma' }) + await act(async () => { + await result.current.refreshSessions() + }) + + expect($sessions.get().map(s => s.id)).toEqual(['alma-local']) + expect($messagingSessions.get().map(s => s.id)).toEqual(['alma-telegram']) + }) + + it('clears Messaging pagination state when the profile scope changes', () => { + setMessagingPlatformTotals({ telegram: 27 }) + setMessagingTruncated(true) + + const { rerender } = renderHook( + ({ scope }: { scope: string }) => useSessionListActions({ profileScope: scope }), + { initialProps: { scope: 'default' } } + ) + + expect($messagingPlatformTotals.get()).toEqual({}) + expect($messagingTruncated.get()).toBe(false) + + setMessagingPlatformTotals({ telegram: 11 }) + setMessagingTruncated(true) + rerender({ scope: 'alma' }) + + expect($messagingPlatformTotals.get()).toEqual({}) + expect($messagingTruncated.get()).toBe(false) + }) + + it('does not let a delayed previous-profile refresh overwrite the new scope', async () => { + const oldRequest = deferred() + listSidebarSessions + .mockReturnValueOnce(oldRequest.promise) + .mockResolvedValueOnce( + sidebar( + { sessions: [row('alma-local', { profile: 'alma' })], total: 1, profile_totals: { alma: 1 } }, + [], + [row('alma-telegram', { profile: 'alma', source: 'telegram' })] + ) + ) + + const { result, rerender } = renderHook( + ({ scope }: { scope: string }) => useSessionListActions({ profileScope: scope }), + { initialProps: { scope: 'default' } } + ) + + const delayed = result.current.refreshSessions() + + rerender({ scope: 'alma' }) + await act(async () => { + await result.current.refreshSessions() + }) + + oldRequest.resolve( + sidebar( + { sessions: [row('default-local')], total: 1, profile_totals: { default: 1 } }, + [], + [row('default-telegram', { source: 'telegram' })] + ) + ) + await act(async () => { + await delayed + }) + + expect($sessions.get().map(s => s.id)).toEqual(['alma-local']) + expect($messagingSessions.get().map(s => s.id)).toEqual(['alma-telegram']) + }) + + it('preserves the concrete profile when loading more Messaging rows', async () => { + listAllProfileSessions.mockResolvedValue({ + sessions: [row('alma-telegram', { profile: 'alma', source: 'telegram' })], + total: 1 + }) + const { result } = renderHook(() => useSessionListActions({ profileScope: 'alma' })) + + await act(async () => { + await result.current.loadMoreMessagingForPlatform('telegram') + }) + + expect(listAllProfileSessions).toHaveBeenCalledWith( + expect.any(Number), + 1, + 'exclude', + 'recent', + 'alma', + { source: 'telegram' } + ) + expect($messagingSessions.get().map(s => s.id)).toEqual(['alma-telegram']) + }) + it('scopes the cron-jobs fetch to the active profile (all → unified view)', async () => { const { getCronJobs } = await import('@/hermes') listSidebarSessions.mockResolvedValue(sidebar({ sessions: [], total: 0, profile_totals: {} })) 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 a94573226293..170c31ef732b 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 @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { getCronJobs, listAllProfileSessions, listSidebarSessions, type SessionInfo } from '@/hermes' import { sameCronSignature } from '@/lib/session-signatures' @@ -75,20 +75,39 @@ interface UseSessionListActionsArgs { * wires into the sidebar and refresh effects. */ export function useSessionListActions({ profileScope }: UseSessionListActionsArgs) { const refreshSessionsRequestRef = useRef(0) + const profileScopeRef = useRef(profileScope) + profileScopeRef.current = profileScope + + const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope + + useEffect(() => { + setMessagingPlatformTotals({}) + setMessagingTruncated(false) + }, [profileScope]) // Messaging-platform sessions as their own slice, fetched separately from // local recents so each platform renders a self-managed section and never // 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 requestProfile = profileScope === ALL_PROFILES ? 'all' : profileScope + try { - const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { + const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', requestProfile, { excludeSources: MESSAGING_EXCLUDED_SOURCES }) + if (profileScopeRef.current !== profileScope) { + 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)) + const rows = result.sessions.filter( + s => + isMessagingSource(s.source) && + (requestProfile === 'all' || normalizeProfileKey(s.profile) === requestProfile) + ) setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows)) // Hit the cap → at least one platform may have more on disk than loaded, @@ -97,20 +116,34 @@ 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 requestProfile = profileScope === ALL_PROFILES ? 'all' : profileScope 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 inScope = (s: SessionInfo) => + requestProfile === 'all' || normalizeProfileKey(s.profile) === requestProfile + + const loaded = $messagingSessions.get().filter(s => inPlatform(s) && inScope(s)).length - const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform) + const result = await listAllProfileSessions( + loaded + SIDEBAR_SESSIONS_PAGE_SIZE, + 1, + 'exclude', + 'recent', + requestProfile, + { source: platform } + ) + + if (profileScopeRef.current !== profileScope) { + return + } + + const incoming = result.sessions.filter(s => inPlatform(s) && inScope(s)) setMessagingSessions(prev => [ ...prev.filter(s => !inPlatform(s)), @@ -119,7 +152,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 @@ -157,15 +190,8 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg // Require at least one message so abandoned/empty "Untitled" drafts (one // was created per TUI/desktop launch before the lazy-create fix) don't // clutter the sidebar. - // Unified cross-profile list (served read-only off each profile's - // state.db; no per-profile backend is spawned). Single-profile users get - // the same rows tagged profile="default". - // Scope recents 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. Cron + messaging - // stay cross-profile. - const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope - + // All Profiles is the only unified cross-profile workspace. Concrete + // profile workspaces scope every sidebar slice to that profile. // Batched: one request opens each profile DB once and returns all three // source-scoped slices, instead of three separate listAllProfileSessions // calls that each reopened + re-counted every profile DB per refresh. @@ -178,20 +204,25 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg messagingExclude: MESSAGING_EXCLUDED_SOURCES }) - if (refreshSessionsRequestRef.current === requestId) { + if (refreshSessionsRequestRef.current === requestId && profileScopeRef.current === profileScope) { const recents = result.recents + const inScope = (s: SessionInfo) => + sessionProfile === 'all' || normalizeProfileKey(s.profile) === sessionProfile + // Drop rows the user just deleted/archived: a refresh can race an // in-flight mutation and the backend page still carries the doomed row. // Honoring the optimistic tombstone keeps the removal from flashing back // (the tombstone self-clears once projects.tree confirms the delete). const tombstones = $removedSessionIds.get() + const scopedRecents = recents.sessions.filter(inScope) + const incoming = tombstones.size - ? recents.sessions.filter( + ? scopedRecents.filter( s => !tombstones.has(s.id) && !(s._lineage_root_id && tombstones.has(s._lineage_root_id)) ) - : recents.sessions + : scopedRecents // Signature-gate the swap (same pattern as cron/messaging): a refresh // that returns content-identical rows must keep the previous array @@ -214,14 +245,16 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg // Cron section: latest N cron sessions (kept so a pinned cron run still // resolves via sessionByAnyId), signature-gated like above. - setCronSessions(prev => (sameCronSignature(prev, result.cron.sessions) ? prev : result.cron.sessions)) + const cronRows = result.cron.sessions.filter(inScope) + setCronSessions(prev => (sameCronSignature(prev, cronRows) ? prev : cronRows)) // Messaging sections: drop any non-messaging source the broad exclude // didn't catch (custom sources stay in local recents), then split per // platform in the UI. - const messagingRows = result.messaging.sessions.filter(s => isMessagingSource(s.source)) + const messagingRows = result.messaging.sessions.filter(s => inScope(s) && isMessagingSource(s.source)) setMessagingSessions(prev => (sameCronSignature(prev, messagingRows) ? prev : messagingRows)) + // Hit the cap → at least one platform may have more on disk than loaded. setMessagingTruncated(result.messaging.sessions.length >= MESSAGING_SECTION_LIMIT) } @@ -233,7 +266,7 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg // Cron *jobs* are a distinct API (getCronJobs), not a session slice. void refreshCronJobs() - }, [profileScope, refreshCronJobs]) + }, [profileScope, refreshCronJobs, sessionProfile]) const loadMoreSessions = useCallback(async () => { bumpSessionsLimit() diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index f829471c5a33..d8f51c83ab06 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -19,6 +19,7 @@ import { listSessions, listSidebarSessions, resetSidebarBatchCapability, + searchSessions, speakText, transcribeAudio } from './hermes' @@ -70,6 +71,15 @@ describe('Hermes REST helpers', () => { ) }) + it('routes session search through the selected concrete profile', async () => { + await searchSessions('needle / value', 'alma') + + expect(api).toHaveBeenCalledWith({ + profile: 'alma', + path: '/api/sessions/search?q=needle%20%2F%20value' + }) + }) + it('batches the sidebar slices into a single request with per-slice limits + excludes', async () => { api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } }) @@ -148,7 +158,7 @@ describe('Hermes REST helpers', () => { }) // Slices reassembled from the legacy per-slice route with the same - // scoping: recents on the caller's profile, cron + messaging cross-profile. + // workspace scope for recents, cron, and messaging. expect(result.recents.sessions.map(s => s.id)).toEqual(['recent-1']) expect(result.recents.total).toBe(7) expect(result.recents.profile_totals).toEqual({ default: 7 }) @@ -158,7 +168,7 @@ describe('Hermes REST helpers', () => { const paths = api.mock.calls.map(call => (call[0] as { path: string }).path) expect(paths.filter(p => p.startsWith('/api/profiles/sessions/sidebar'))).toHaveLength(1) expect(paths.filter(p => p.startsWith('/api/profiles/sessions?'))).toHaveLength(3) - expect(paths).toContainEqual(expect.stringContaining('profile=work')) + expect(paths.filter(p => p.startsWith('/api/profiles/sessions?') && p.includes('profile=work'))).toHaveLength(3) expect(paths).toContainEqual(expect.stringContaining('source=cron')) expect(paths).toContainEqual(expect.stringContaining('exclude_sources=cron%2Ctool')) }) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 38b8e2fb85e9..56e978e4d73b 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -464,15 +464,15 @@ function isEndpointMissingError(err: unknown): boolean { // Compatibility fallback: reassemble the three sidebar slices from the // per-slice endpoint, mirroring the batched route's semantics (min_messages=1, // archived excluded, recency order; recents scoped to the caller's profile, -// cron + messaging cross-profile). Rides the same Electron remote-splice +// every slice scoped to the selected workspace). Rides the same Electron remote-splice // interception as the pre-batching desktop, so remote profiles stay correct. async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise { const [recents, cron, messaging] = await Promise.all([ listAllProfileSessions(req.recentsLimit, 1, 'exclude', 'recent', req.recentsProfile, { excludeSources: req.recentsExclude }), - listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', 'all', { source: 'cron' }), - listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', 'all', { + listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', req.recentsProfile, { source: 'cron' }), + listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', req.recentsProfile, { excludeSources: req.messagingExclude }) ]) @@ -546,8 +546,9 @@ export function setSessionArchived(id: string, archived: boolean, profile?: stri }) } -export function searchSessions(query: string): Promise { +export function searchSessions(query: string, profile?: string | null): Promise { return window.hermesDesktop.api({ + ...(profile ? { profile } : {}), path: `/api/sessions/search?q=${encodeURIComponent(query)}` }) } diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f73a77bb655d..4b39ef5a8469 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4941,9 +4941,11 @@ def get_profiles_sessions_sidebar( ): """Batched sidebar session slices — one profile-DB open per refresh. - The desktop sidebar needs three source-scoped windows per refresh: recents - (local chats, scoped to the active profile), cron sessions (all profiles), - and messaging-platform sessions (all profiles). Served as three separate + The desktop sidebar needs three source-scoped windows per refresh: recents, + cron sessions, and messaging-platform sessions. ``recents_profile`` is the + sidebar workspace scope for all three slices: a concrete profile is + isolated, while ``all`` explicitly aggregates every profile. Served as three + separate ``/api/profiles/sessions`` calls they reopened every profile's ``state.db`` three times and re-counted each refresh. This opens each DB once and runs the three filtered queries together, returning the three windows in one @@ -4959,7 +4961,6 @@ def get_profiles_sessions_sidebar( from hermes_state import SessionDB from hermes_cli import profiles as profiles_mod - # cron + messaging are cross-profile; recents is scoped to recents_profile. # Scan every profile once regardless (each DB opened a single time). try: infos = profiles_mod.list_profiles() @@ -5033,10 +5034,10 @@ def _slice(db, *, source=None, exclude=None, cap): ) recents_total += rtotal recents_profile_totals[name] = rtotal - cron_rows.extend(_tag(_slice(db, source="cron", cap=cron_cap), name)) - messaging_rows.extend( - _tag(_slice(db, exclude=messaging_exclude_list, cap=messaging_cap), name) - ) + cron_rows.extend(_tag(_slice(db, source="cron", cap=cron_cap), name)) + messaging_rows.extend( + _tag(_slice(db, exclude=messaging_exclude_list, cap=messaging_cap), name) + ) except Exception as exc: errors.append({"profile": name, "error": str(exc)}) finally: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 2ae86afdf6c5..7fa06ed97af5 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1871,6 +1871,38 @@ def test_profiles_sessions_sidebar_batches_three_slices(self): assert isinstance(data.get("errors"), list) assert data["recents"]["total"] >= 1 + def test_profiles_sessions_sidebar_scopes_every_slice_to_concrete_profile(self): + """A concrete Desktop workspace must not leak sibling profile rows.""" + from hermes_state import SessionDB + from hermes_cli import profiles as profiles_mod + + for profile, prefix in (("default", "default"), ("alma", "alma"), ("aegis_h-01", "aegis")): + home = profiles_mod.get_profile_dir(profile) + home.mkdir(parents=True, exist_ok=True) + db = SessionDB(db_path=home / "state.db") + try: + for suffix, source in (("local", "desktop"), ("cron", "cron"), ("telegram", "telegram")): + session_id = f"{prefix}-{suffix}" + db.create_session(session_id=session_id, source=source) + db.append_message(session_id=session_id, role="user", content="synthetic") + finally: + db.close() + + resp = self.client.get( + "/api/profiles/sessions/sidebar" + "?recents_profile=alma&recents_limit=20&recents_exclude=cron,telegram" + "&cron_limit=50&messaging_limit=100" + "&messaging_exclude=cron,cli,codex,desktop,gateway,local,tui" + ) + assert resp.status_code == 200 + data = resp.json() + + for section in ("recents", "cron", "messaging"): + rows = data[section]["sessions"] + assert rows + assert {row["profile"] for row in rows} == {"alma"} + assert all(row["id"].startswith("alma-") for row in rows) + def test_sessions_endpoint_reads_requested_profile(self): """The machine dashboard's global profile switcher must retarget the Sessions page, not just config/skills/model pages."""