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
12 changes: 9 additions & 3 deletions apps/desktop/src/app/chat/sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => {
Expand All @@ -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<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 Down Expand Up @@ -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.
Expand Down
48 changes: 48 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,48 @@
import { describe, expect, it } from 'vitest'

import { ALL_PROFILES } from '@/store/profile'
import type { SessionInfo } from '@/types/hermes'
Comment thread
BlackishGreen33 marked this conversation as resolved.

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'
])
})
})
12 changes: 12 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,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)
}
104 changes: 104 additions & 0 deletions apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
getCronJobs: vi.fn(),
listAllProfileSessions: vi.fn()
}))

interface Deferred<T> {
promise: Promise<T>
resolve: (value: T) => void
}

function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void

const promise = new Promise<T>(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<Awaited<ReturnType<typeof listAllProfileSessions>>>()
const beta = deferred<Awaited<ReturnType<typeof listAllProfileSessions>>>()
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({})
})
})
47 changes: 40 additions & 7 deletions apps/desktop/src/app/session/hooks/use-session-list-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand All @@ -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)),
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading