Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 2 additions & 30 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-bac
import { rehomePrimaryConnection } from './primary-connection-rehome'
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
import {
buildSidebarSessionSliceParams,
fetchPrimaryProfileSessions,
fetchRemoteProfileSessions,
mergeProfileSessionWindow
Expand Down Expand Up @@ -12314,36 +12315,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 sliceParams = (limitKey, defaultLimit, extra) => {
const sp = new URLSearchParams({
limit: searchParams.get(limitKey) || defaultLimit,
offset: '0',
min_messages: '1',
archived: 'exclude',
order: 'recent',
...extra
})

return sp
}

const recentsSp = sliceParams('recents_limit', '20', { profile: recentsProfile })
const recentsExclude = searchParams.get('recents_exclude')

if (recentsExclude) {
recentsSp.set('exclude_sources', recentsExclude)
}

const cronSp = sliceParams('cron_limit', '50', { profile: 'all', source: 'cron' })

const messagingSp = sliceParams('messaging_limit', '100', { profile: 'all' })
const messagingExclude = searchParams.get('messaging_exclude')

if (messagingExclude) {
messagingSp.set('exclude_sources', messagingExclude)
}
const { recents: recentsSp, cron: cronSp, messaging: messagingSp } = buildSidebarSessionSliceParams(searchParams)

const [recents, cron, messaging] = await Promise.all([
fetchProfilesSessionSlice(recentsSp, remoteProfiles),
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/electron/profile-session-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,55 @@ import assert from 'node:assert/strict'
import { test } from 'vitest'

import {
buildSidebarSessionSliceParams,
fetchPrimaryProfileSessions,
fetchRemoteProfileSessions,
mergeProfileSessionWindow
} from './profile-session-routing'

test('remote sidebar slices all follow the selected profile', () => {
const slices = buildSidebarSessionSliceParams(
new URLSearchParams({
recents_profile: 'work-vps',
recents_limit: '30',
cron_limit: '40',
messaging_limit: '50',
recents_exclude: 'cron,signal',
messaging_exclude: 'desktop,cron'
})
)

assert.equal(slices.recents.get('profile'), 'work-vps')
assert.equal(slices.cron.get('profile'), 'work-vps')
assert.equal(slices.messaging.get('profile'), 'work-vps')
assert.equal(slices.recents.get('exclude_sources'), 'cron,signal')
assert.equal(slices.cron.get('source'), 'cron')
assert.equal(slices.messaging.get('exclude_sources'), 'desktop,cron')
})

test('remote sidebar slices preserve the explicit all-profiles scope', () => {
const slices = buildSidebarSessionSliceParams(new URLSearchParams({ recents_profile: 'all' }))

assert.deepEqual(
Object.values(slices).map(params => params.get('profile')),
['all', 'all', 'all']
)
})

test('remote sidebar slices fall back to the all-profiles scope and default limits', () => {
for (const searchParams of [new URLSearchParams(), new URLSearchParams({ recents_profile: ' ' })]) {
const slices = buildSidebarSessionSliceParams(searchParams)

assert.deepEqual(
Object.values(slices).map(params => params.get('profile')),
['all', 'all', 'all']
)
assert.equal(slices.recents.get('limit'), '20')
assert.equal(slices.cron.get('limit'), '50')
assert.equal(slices.messaging.get('limit'), '100')
}
})

test('primary session reads use the profile-aware request path', async () => {
const calls: Array<{ profile: string | null; path: string }> = []
const expected = { sessions: [{ id: 'session-1' }], total: 1, profile_totals: { default: 1 } }
Expand Down
42 changes: 42 additions & 0 deletions apps/desktop/electron/profile-session-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,48 @@ export function mergeProfileSessionWindow(rows: unknown[], offset: number, limit
return window
}

export interface SidebarSessionSliceParams {
cron: URLSearchParams
messaging: URLSearchParams
recents: URLSearchParams
}

/** Build the three remote-profile sidebar reads from one workspace scope. */
export function buildSidebarSessionSliceParams(searchParams: URLSearchParams): SidebarSessionSliceParams {
const profile = (searchParams.get('recents_profile') || 'all').trim() || 'all'

const slice = (limitKey: string, defaultLimit: string, extra: Record<string, string>) =>
new URLSearchParams({
limit: searchParams.get(limitKey) || defaultLimit,
offset: '0',
min_messages: '1',
archived: 'exclude',
order: 'recent',
...extra
})

const recents = slice('recents_limit', '20', { profile })
const recentsExclude = searchParams.get('recents_exclude')

if (recentsExclude) {
recents.set('exclude_sources', recentsExclude)
}

const messaging = slice('messaging_limit', '100', { profile })
const messagingExclude = searchParams.get('messaging_exclude')

if (messagingExclude) {
messaging.set('exclude_sources', messagingExclude)
}

return {
cron: slice('cron_limit', '50', { profile, source: 'cron' }),
messaging,
recents
}
}

/** Fetch the primary backend's profile-aware session slice, falling back to an empty result when unavailable. */
export async function fetchPrimaryProfileSessions(
searchParams: URLSearchParams,
fetchJsonForProfile: FetchJsonForProfile
Expand Down
28 changes: 21 additions & 7 deletions apps/desktop/src/app/chat/sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ import {
$profiles,
$profileScope,
ALL_PROFILES,
normalizeProfileKey
messagingTotalsKey,
normalizeProfileKey,
sidebarProfileForScope
} from '@/store/profile'
import {
$activeProjectId,
Expand Down Expand Up @@ -144,6 +146,7 @@ import { SidebarCronJobsSection } from './cron-jobs-section'
import { SidebarFilterMenu } from './filter-menu'
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 {
Expand Down Expand Up @@ -395,6 +398,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 messagingProfile = sidebarProfileForScope(profileScope)
const agentOrderIds = useStore($sidebarSessionOrderIds)
const agentOrderManual = useStore($sidebarSessionOrderManual)
const workspaceOrderIds = useStore($sidebarWorkspaceOrderIds)
Expand Down Expand Up @@ -523,11 +527,21 @@ export function ChatSidebar({
[visibleSessions]
)

const visibleCronSessions = useMemo(
() => filterSessionsByProfileScope(cronSessions, profileScope),
[cronSessions, profileScope]
)

const visibleMessagingSessions = useMemo(
() => filterSessionsByProfileScope(messagingSessions, profileScope),
[messagingSessions, profileScope]
)

// Index sessions by every id a pin might be stored under — recents, cron,
// AND messaging, since all three can be pinned (see session-index.ts).
const sessionByAnyId = useMemo(
() => buildSessionByAnyId(visibleSessions, cronSessions, messagingSessions),
[visibleSessions, cronSessions, messagingSessions]
() => buildSessionByAnyId(visibleSessions, visibleCronSessions, visibleMessagingSessions),
[visibleSessions, visibleCronSessions, visibleMessagingSessions]
)

// Local pin ids first (hand-picked order), then server-flagged pins the
Expand Down Expand Up @@ -1160,7 +1174,7 @@ 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<MessagingSection[]>(() => {
if (!messagingSessions.length) {
if (!visibleMessagingSessions.length) {
return []
}

Expand All @@ -1170,7 +1184,7 @@ export function ChatSidebar({
// promises rows that will never appear.
const pinnedBySource = new Map<string, number>()

for (const session of messagingSessions) {
for (const session of visibleMessagingSessions) {
const sourceId = normalizeSessionSource(session.source)

if (!sourceId) {
Expand All @@ -1191,7 +1205,7 @@ export function ChatSidebar({
return [...bySource.entries()]
.map(([sourceId, list]) => {
const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a))
const known = messagingPlatformTotals[sourceId]
const known = messagingPlatformTotals[messagingTotalsKey(messagingProfile, sourceId)]
const unpinnedKnown = known == null ? null : Math.max(0, known - (pinnedBySource.get(sourceId) ?? 0))
const total = Math.max(ordered.length, unpinnedKnown ?? 0)

Expand All @@ -1207,7 +1221,7 @@ export function ChatSidebar({
}
})
.sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}, [messagingSessions, messagingPlatformTotals, messagingTruncated, isPinnedSession])
}, [visibleMessagingSessions, messagingPlatformTotals, messagingTruncated, isPinnedSession, messagingProfile])

// Grouping by profile: one collapsible group per profile, color on the header
// (not on every row). Default profile floats to the top, the rest alpha.
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop/src/app/chat/sidebar/profile-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'

import { ALL_PROFILES } from '@/store/profile'
import type { SessionInfo } from '@/types/hermes'

import { filterSessionsByProfileScope } from './profile-scope'

/** Build the smallest session row needed by the profile-scope tests. */
const row = (id: string, profile?: string): SessionInfo =>
({ id, message_count: 1, profile, source: 'signal', started_at: 0, title: id }) as SessionInfo

describe('filterSessionsByProfileScope', () => {
it('keeps only rows from the selected profile', () => {
const rows = [row('default-row', 'default'), row('work-row', 'work')]

expect(filterSessionsByProfileScope(rows, 'work').map(session => session.id)).toEqual(['work-row'])
})

it('treats legacy rows without a profile as default', () => {
const rows = [row('legacy-row'), row('work-row', 'work')]

expect(filterSessionsByProfileScope(rows, 'default').map(session => session.id)).toEqual(['legacy-row'])
})

it('preserves every row in the canonical all-profiles scope', () => {
const rows = [row('default-row', 'default'), row('work-row', 'work')]

expect(filterSessionsByProfileScope(rows, ALL_PROFILES)).toBe(rows)
})
})
13 changes: 13 additions & 0 deletions apps/desktop/src/app/chat/sidebar/profile-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ALL_PROFILES, normalizeProfileKey } from '@/store/profile'
import type { SessionInfo } from '@/types/hermes'

/** Return the sessions visible in one sidebar profile scope, or the original unified list for All profiles. */
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as HermesModule from '@/hermes'
import { getSession } from '@/hermes'
import { $activeGatewayProfile, $profiles } from '@/store/profile'
import { $sessions } from '@/store/session'
import { $cronSessions, $messagingSessions, $sessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'

import { resolveSessionProfile, resolveStoredSession } from './utils'
Expand All @@ -21,13 +21,17 @@ const profiles = (...names: string[]) => names.map(name => ({ name }) as never)

describe('resolveStoredSession profile ownership', () => {
beforeEach(() => {
$cronSessions.set([])
$messagingSessions.set([])
$sessions.set([])
$profiles.set(profiles('default', 'meta'))
$activeGatewayProfile.set('meta')
mockGetSession.mockReset()
})

afterEach(() => {
$cronSessions.set([])
$messagingSessions.set([])
$sessions.set([])
$profiles.set([])
$activeGatewayProfile.set('default')
Expand All @@ -42,6 +46,19 @@ describe('resolveStoredSession profile ownership', () => {
expect(mockGetSession).not.toHaveBeenCalled()
})

it.each([
['cron', $cronSessions],
['messaging', $messagingSessions]
])('resolves a %s sidebar row without duplicating it into regular sessions', async (_source, store) => {
store.set([session({ id: 's1', profile: 'default' })])

const resolved = await resolveStoredSession('s1')

expect(resolved?.profile).toBe('default')
expect(mockGetSession).not.toHaveBeenCalled()
expect($sessions.get()).toEqual([])
})

it('treats a profile-less cache hit as unresolved when multiple profiles exist', async () => {
$sessions.set([session({ id: 's1' })])
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import {
$cronSessions,
$currentCwd,
$messagingSessions,
$sessions,
commitWorkspaceCwdForSelectedSession,
releaseWorkspaceCwdOwner,
Expand Down Expand Up @@ -1291,7 +1293,9 @@ function upsertResolvedSession(session: SessionInfo, storedSessionId: string) {
}

export async function resolveStoredSession(storedSessionId: string): Promise<SessionInfo | undefined> {
const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const cached = [...$sessions.get(), ...$cronSessions.get(), ...$messagingSessions.get()].find(session =>
sessionMatchesStoredId(session, storedSessionId)
)

// A row with no owning profile can't route a resume when more than one
// profile exists — a resume without a profile lands on whichever gateway is
Expand Down
Loading