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
20 changes: 20 additions & 0 deletions apps/desktop/electron/connection-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
savedProfileSsh,
sidebarSessionSliceProfiles,
tokenPreview
} from './connection-config'

Expand All @@ -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')
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/electron/connection-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -509,5 +522,6 @@ export {
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
savedProfileSsh,
sidebarSessionSliceProfiles,
tokenPreview
}
9 changes: 5 additions & 4 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
resolveAuthMode,
resolveTestWsUrl,
savedProfileSsh,
sidebarSessionSliceProfiles,
tokenPreview
} from './connection-config'
import { adoptServedDashboardToken } from './dashboard-token'
Expand Down Expand Up @@ -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({
Expand All @@ -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) {
Expand Down
105 changes: 67 additions & 38 deletions apps/desktop/src/app/chat/sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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<SessionSearchResult[]>([])
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<Record<string, boolean>>({})
Expand Down Expand Up @@ -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 —
Expand All @@ -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<string, SessionInfo>()

// 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<string>()
Expand All @@ -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)
Expand All @@ -466,7 +486,7 @@ export function ChatSidebar({
cancelled = true
window.clearTimeout(id)
}
}, [trimmedQuery])
}, [trimmedQuery, aggregateAllProfiles, profileScope, searchScope])

const searchResults = useMemo(() => {
if (!trimmedQuery) {
Expand All @@ -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)),
Expand Down Expand Up @@ -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<MessagingSection[]>(() => {
if (!messagingSessions.length) {
if (!visibleMessagingSessions.length) {
return []
}

const bySource = new Map<string, SessionInfo[]>()

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

if (!sourceId) {
Expand All @@ -884,22 +909,26 @@ 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
// the seed fetch was capped, so assume more until a per-platform load
// 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.
Expand Down Expand Up @@ -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
)

Expand Down
35 changes: 35 additions & 0 deletions apps/desktop/src/app/chat/sidebar/session-pin-index.test.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
24 changes: 24 additions & 0 deletions apps/desktop/src/app/chat/sidebar/session-pin-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { SessionInfo } from '@/hermes'
import { normalizeProfileKey } from '@/store/profile'

export function buildPinnedSessionIndex(
profileScope: string,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main already has session-index.ts:17 for the same live-ID and lineage-root pin resolution policy. During salvage, extend that helper or pass it scoped arrays rather than adding a second index implementation that can drift.

aggregateAllProfiles: boolean,
...sessionGroups: SessionInfo[][]
): Map<string, SessionInfo> {
const index = new Map<string, SessionInfo>()

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
}
Loading