Skip to content
Open
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
22 changes: 19 additions & 3 deletions apps/desktop/src/app/chat/sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,13 @@ import {
StartWorkButton,
useRepoWorktreeMap
} from './projects'
import { SidebarBlankState, SidebarPinnedEmptyState, SidebarSessionSkeletons } from './section-states'
import {
shouldIncludeMessagingSession,
shouldShowSessionSections,
SidebarBlankState,
SidebarPinnedEmptyState,
SidebarSessionSkeletons
} from './section-states'
import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section'
import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu'

Expand Down Expand Up @@ -855,6 +861,10 @@ export function ChatSidebar({
const bySource = new Map<string, SessionInfo[]>()

for (const session of messagingSessions) {
if (!shouldIncludeMessagingSession(profileScope, session.profile)) {
continue
}

const sourceId = normalizeSessionSource(session.source)

if (!sourceId) {
Expand Down Expand Up @@ -884,7 +894,7 @@ export function ChatSidebar({
}
})
.sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}, [messagingSessions, messagingPlatformTotals, messagingTruncated])
}, [messagingSessions, messagingPlatformTotals, messagingTruncated, profileScope])

// 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 @@ -1043,7 +1053,13 @@ export function ChatSidebar({

const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0

const showSessionSections = showSessionSkeletons || sortedSessions.length > 0 || projectModel.length > 0
const showSessionSections = shouldShowSessionSections({
hasCronJobs: cronJobs.length > 0,
hasMessaging: messagingGroups.length > 0,
hasProjects: projectModel.length > 0,
hasSessions: sortedSessions.length > 0,
loadingSessions: showSessionSkeletons
})

// Each reorderable list reports its OWN new id order; persisting is a direct,
// typed write — no id-prefix sniffing to figure out which level moved.
Expand Down
38 changes: 38 additions & 0 deletions apps/desktop/src/app/chat/sidebar/section-states.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'

import { ALL_PROFILES } from '@/store/profile'

import { shouldIncludeMessagingSession, shouldShowSessionSections } from './section-states'

const emptySidebar = {
hasCronJobs: false,
hasMessaging: false,
hasProjects: false,
hasSessions: false,
loadingSessions: false
}

describe('shouldShowSessionSections', () => {
it('keeps messaging visible without normal sessions', () => {
expect(shouldShowSessionSections({ ...emptySidebar, hasMessaging: true })).toBe(true)
})

it('keeps cron jobs visible without normal sessions', () => {
expect(shouldShowSessionSections({ ...emptySidebar, hasCronJobs: true })).toBe(true)
})

it('uses the blank state only when every section is empty', () => {
expect(shouldShowSessionSections(emptySidebar)).toBe(false)
})
})

describe('shouldIncludeMessagingSession', () => {
it('keeps rows when a persisted All Profiles scope falls back to one profile', () => {
expect(shouldIncludeMessagingSession(ALL_PROFILES, 'default')).toBe(true)
})

it('filters rows outside a concrete profile scope', () => {
expect(shouldIncludeMessagingSession('work', 'default')).toBe(false)
expect(shouldIncludeMessagingSession('work', 'work')).toBe(true)
})
})
23 changes: 23 additions & 0 deletions apps/desktop/src/app/chat/sidebar/section-states.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,29 @@ import { Codicon } from '@/components/ui/codicon'
import { Skeleton } from '@/components/ui/skeleton'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { ALL_PROFILES, normalizeProfileKey } from '@/store/profile'

interface SidebarSectionVisibility {
hasCronJobs: boolean
hasMessaging: boolean
hasProjects: boolean
hasSessions: boolean
loadingSessions: boolean
}

export function shouldShowSessionSections({
hasCronJobs,
hasMessaging,
hasProjects,
hasSessions,
loadingSessions
}: SidebarSectionVisibility): boolean {
return loadingSessions || hasSessions || hasProjects || hasMessaging || hasCronJobs
}

export function shouldIncludeMessagingSession(profileScope: string, sessionProfile?: string): boolean {
return profileScope === ALL_PROFILES || normalizeProfileKey(sessionProfile) === profileScope
}

export function SidebarSessionSkeletons() {
return (
Expand Down
189 changes: 189 additions & 0 deletions apps/desktop/src/app/session/hooks/use-session-list-actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { getCronJobs, listAllProfileSessions, type PaginatedSessions, type SessionInfo } from '@/hermes'
import { ALL_PROFILES } from '@/store/profile'
import {
$messagingPlatformTotals,
$messagingSessions,
setMessagingPlatformTotals,
setMessagingSessions
} 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()
}))

const emptyPage: PaginatedSessions = {
limit: 50,
offset: 0,
profile_totals: {},
sessions: [],
total: 0
}

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

const promise = new Promise<T>(done => {
resolve = done
})

return { promise, resolve }
}

function messagingSession(id: string, profile: string): SessionInfo {
return {
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: id,
tool_call_count: 0
}
}

describe('useSessionListActions messaging scope', () => {
beforeEach(() => {
setMessagingSessions([])
setMessagingPlatformTotals({})
vi.mocked(getCronJobs).mockResolvedValue([])
vi.mocked(listAllProfileSessions).mockResolvedValue(emptyPage)
})

afterEach(() => {
cleanup()
vi.clearAllMocks()
})

it('fetches messaging sessions only for the active profile', async () => {
const { result } = renderHook(() => useSessionListActions({ profileScope: 'nolan' }))

await act(async () => {
await result.current.refreshMessagingSessions()
})

expect(listAllProfileSessions).toHaveBeenCalledWith(
expect.any(Number),
1,
'exclude',
'recent',
'nolan',
expect.objectContaining({ excludeSources: expect.any(Array) })
)
})

it('keeps messaging global in the explicit all-profiles view', async () => {
const { result } = renderHook(() => useSessionListActions({ profileScope: ALL_PROFILES }))

await act(async () => {
await result.current.refreshMessagingSessions()
})

expect(listAllProfileSessions).toHaveBeenCalledWith(
expect.any(Number),
1,
'exclude',
'recent',
'all',
expect.any(Object)
)
})

it('pages one messaging platform within the active profile', async () => {
const { result } = renderHook(() => useSessionListActions({ profileScope: 'silas' }))

await act(async () => {
await result.current.loadMoreMessagingForPlatform('slack')
})

expect(listAllProfileSessions).toHaveBeenCalledWith(expect.any(Number), 1, 'exclude', 'recent', 'silas', {
source: 'slack'
})
})

it('ignores an older profile response that resolves after the active profile', async () => {
const first = deferred<typeof emptyPage>()
const second = deferred<typeof emptyPage>()

vi.mocked(listAllProfileSessions)
.mockReset()
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => second.promise)

const { rerender, result } = renderHook(({ profileScope }) => useSessionListActions({ profileScope }), {
initialProps: { profileScope: 'nolan' }
})

let firstRequest!: Promise<void>
act(() => {
firstRequest = result.current.refreshMessagingSessions()
})

rerender({ profileScope: 'silas' })

let secondRequest!: Promise<void>
act(() => {
secondRequest = result.current.refreshMessagingSessions()
})

await act(async () => {
second.resolve({ ...emptyPage, sessions: [messagingSession('silas-session', 'silas')], total: 1 })
await secondRequest
})

await act(async () => {
first.resolve({ ...emptyPage, sessions: [messagingSession('nolan-session', 'nolan')], total: 1 })
await firstRequest
})

expect($messagingSessions.get().map(session => session.id)).toEqual(['silas-session'])
})

it('ignores a load-more response after the active profile changes', async () => {
const page = deferred<typeof emptyPage>()

vi.mocked(listAllProfileSessions)
.mockReset()
.mockImplementationOnce(() => page.promise)
setMessagingSessions([messagingSession('nolan-seed', 'nolan')])
setMessagingPlatformTotals({ telegram: 1 })

const { rerender, result } = renderHook(({ profileScope }) => useSessionListActions({ profileScope }), {
initialProps: { profileScope: 'nolan' }
})

let loadMoreRequest!: Promise<void>
act(() => {
loadMoreRequest = result.current.loadMoreMessagingForPlatform('telegram')
})

rerender({ profileScope: 'silas' })
setMessagingSessions([messagingSession('silas-session', 'silas')])

await act(async () => {
page.resolve({
...emptyPage,
sessions: [messagingSession('nolan-more', 'nolan')],
total: 2
})
await loadMoreRequest
})

expect($messagingSessions.get().map(session => session.id)).toEqual(['silas-session'])
expect($messagingPlatformTotals.get()).toEqual({ telegram: 1 })
})
})
Loading