Skip to content
Merged
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
47 changes: 38 additions & 9 deletions apps/desktop/src/app/chat/sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -628,10 +628,7 @@ export function ChatSidebar({
for (const session of messagingSessions) {
const sourceId = normalizeSessionSource(session.source)

// Pinning MOVES a row to the Pinned section (matching how recents
// disappear from Sessions when pinned) — don't render it here too. A
// platform whose every row is pinned simply drops its section.
if (!sourceId || pinnedRealIdSet.has(session.id)) {
if (!sourceId) {
continue
}

Expand All @@ -643,6 +640,12 @@ export function ChatSidebar({
return [...bySource.entries()]
.map(([sourceId, list]) => {
const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a))
// Pinning MOVES a row to the Pinned section (matching how recents
// disappear from Sessions when pinned) — don't render it here too.
// Pinned rows still count as LOADED for the load-more math: they're
// platform conversations already in memory, just housed elsewhere, so
// hiding them must not make the pager think more remain on disk.
const rows = ordered.filter(s => !pinnedRealIdSet.has(s.id))
const known = messagingPlatformTotals[sourceId]
const total = Math.max(ordered.length, known ?? 0)

Expand All @@ -652,12 +655,20 @@ export function ChatSidebar({
// resolves the count.
hasMore: known != null ? known > ordered.length : messagingTruncated,
label: sessionSourceLabel(sourceId) ?? sourceId,
sessions: ordered,
// Section recency comes from every loaded row (pinned included) so a
// platform doesn't reshuffle when its newest thread gets pinned.
latestActivity: sessionTime(ordered[0]),
loadedCount: ordered.length,
sessions: rows,
sourceId,
total
}
})
.sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
// A platform whose every loaded row is pinned (and with nothing more on
// disk) has nothing left to show — drop the empty shell. Kept when more
// rows exist so its pager stays reachable.
.filter(group => group.sessions.length > 0 || group.hasMore)
.sort((a, b) => b.latestActivity - a.latestActivity)
}, [messagingSessions, messagingPlatformTotals, messagingTruncated, pinnedRealIdSet])

// ALL-profiles view: one collapsible group per profile, color on the header
Expand Down Expand Up @@ -725,7 +736,19 @@ export function ChatSidebar({
const hasMoreSessions = knownSessionTotal > loadedSessionCount
const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)

const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
// The server total counts every listable local conversation — including ones
// currently pinned, which this section deliberately doesn't render. Pinned
// rows are always loaded (the refresh keep-set preserves them), so drop them
// from the label's BOTH sides; otherwise pinning one row leaves the count
// stuck at "17/18" forever, advertising a page that can never arrive.
const pinnedFromRecentsCount = useMemo(
() => visibleSessions.reduce((count, s) => (pinnedRealIdSet.has(s.id) ? count + 1 : count), 0),
[visibleSessions, pinnedRealIdSet]
)

const agentKnownTotal = Math.max(knownSessionTotal - pinnedFromRecentsCount, agentSessions.length)

const recentsMeta = countLabel(agentSessions.length, agentKnownTotal)
const archiveAllDisabled = sessionsLoading || agentSessions.length === 0 || archiveAllSubmitting

const handleArchiveAll = async () => {
Expand Down Expand Up @@ -1074,7 +1097,7 @@ export function ChatSidebar({
<SidebarLoadMoreRow
loading={Boolean(messagingLoadMorePending[group.sourceId])}
onClick={() => loadMoreForMessaging(group.sourceId)}
step={Math.max(0, group.total - group.sessions.length)}
step={Math.max(0, group.total - group.loadedCount)}
/>
) : null
}
Expand All @@ -1087,7 +1110,7 @@ export function ChatSidebar({
platformName={group.label}
/>
}
labelMeta={countLabel(group.sessions.length, group.total)}
labelMeta={countLabel(group.loadedCount, group.total)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
Expand Down Expand Up @@ -1261,7 +1284,13 @@ interface SidebarSessionGroup {
interface MessagingSection {
sourceId: string
label: string
/** Rows this section renders (pinned rows excluded — they live in Pinned). */
sessions: SessionInfo[]
/** Loaded conversations for this platform, pinned included — the honest
* "loaded" side of the count label and load-more math. */
loadedCount: number
/** Latest activity across every loaded row (pinned included). */
latestActivity: number
total: number
hasMore: boolean
}
Expand Down
144 changes: 138 additions & 6 deletions apps/desktop/src/app/desktop-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { autoArchiveOldSessions, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import {
isMessagingSource,
LOCAL_SESSION_SOURCE_IDS,
MESSAGING_SESSION_SOURCE_IDS,
normalizeSessionSource
} from '../lib/session-source'
import { refreshCronJobs } from '../store/cron'
import {
$panesFlipped,
$pinnedSessionIds,
Expand All @@ -27,26 +34,39 @@ import {
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '../store/layout'
import { refreshCronJobs } from '../store/cron'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import { $activeGatewayProfile, $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
import {
$activeGatewayProfile,
$freshSessionRequest,
$profileScope,
ALL_PROFILES,
normalizeProfileKey,
refreshActiveProfile
} from '../store/profile'
import {
$activeSessionId,
$currentCwd,
$freshDraftReady,
$gatewayState,
$messagingSessions,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
CRON_SECTION_LIMIT,
mergeSessionPage,
MESSAGING_SECTION_LIMIT,
sessionPinId,
setAwaitingResponse,
setBusy,
setCronSessions,
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
setCurrentProvider,
setMessages,
setMessagingPlatformTotals,
setMessagingSessions,
setMessagingTruncated,
setSessionProfileTotals,
setSessions,
setSessionsLoading,
Expand Down Expand Up @@ -94,6 +114,27 @@ import type { TitlebarTool } from './shell/titlebar-controls'
import { useGroupRegistry } from './shell/use-group-registry'
import { UpdatesOverlay } from './updates-overlay'

// The recents list is local-only: cron rows resolve pins via their own slice,
// and each messaging platform (telegram, discord, …) is fetched separately into
// its own self-managed sidebar section (refreshMessagingSessions). Excluding
// both here — from the page AND its server-side totals — keeps "Load more"
// paging through interactive local chats instead of advertising gateway threads
// that would never appear in this section.
const SIDEBAR_EXCLUDED_SOURCES = ['cron', ...MESSAGING_SESSION_SOURCE_IDS]
// The messaging slice is the inverse: drop cron + every local source so only
// external-platform conversations remain, then split per platform in the UI.
const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]

// Cheap signature compare so slice refreshes only swap the atom (and re-render
// the sidebar) when the visible rows actually changed.
function sameSessionSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
if (a.length !== b.length) {
return false
}

return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title)
}

const AgentsView = lazy(async () => ({ default: (await import('./agents')).AgentsView }))
const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView }))
const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView }))
Expand Down Expand Up @@ -226,6 +267,67 @@ export function DesktopController() {
}
}, [])

// Cron-job sessions as their own bounded list, independent of the recents
// page so the scheduler's always-newest rows never consume its budget. The
// sidebar lists cron *jobs*, not run sessions — this slice exists so a pinned
// cron run still resolves into the Pinned section via sessionByAnyId.
const refreshCronSessions = useCallback(async () => {
try {
const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
source: 'cron'
})

setCronSessions(prev => (sameSessionSignature(prev, sessions) ? prev : sessions))
} catch {
// Non-fatal: cron pins just resolve from stale rows until the next pass.
}
}, [])

// 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 () => {
try {
const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
excludeSources: MESSAGING_EXCLUDED_SOURCES
})

// 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))

setMessagingSessions(prev => (sameSessionSignature(prev, rows) ? prev : rows))
// Hit the cap → at least one platform may have more on disk than loaded,
// so platform sections offer their own per-platform "load more".
setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT)
} catch {
// Non-fatal: the messaging sections just stay empty/stale.
}
}, [])

// 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 loaded = $messagingSessions.get().filter(inPlatform).length

const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', {
source: platform
})

const incoming = result.sessions.filter(inPlatform)

setMessagingSessions(prev => [
...prev.filter(s => !inPlatform(s)),
...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep())
])

const total = result.total ?? incoming.length
setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) }))
}, [])

const refreshSessions = useCallback(async () => {
const requestId = refreshSessionsRequestRef.current + 1
refreshSessionsRequestRef.current = requestId
Expand Down Expand Up @@ -253,6 +355,7 @@ export function DesktopController() {
// Only run auto-archive on the first refresh (boot). Subsequent
// cascading refreshes from message events skip this to save a round-trip.
const isFirst = requestId <= 2

if (isFirst) {
try {
await autoArchiveOldSessions([...preserveIds])
Expand All @@ -266,8 +369,20 @@ export function DesktopController() {
// 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".
const result = await listAllProfileSessions(limit, 1)
// the same rows tagged profile="default". Cron + messaging sources are
// excluded here — page and totals alike — and fetched as their own
// slices, so "Load more" math always matches the rows this section lists.
// Scope to the active profile (not always 'all') so a profile with few
// recent sessions isn't windowed out of the cross-profile recency page.
// Read at call time (not a dep) so this callback's identity stays stable
// for useGatewayBoot/usePromptActions; the scope-change effect below
// triggers the refetch.
const scope = $profileScope.get()
const sessionProfile = scope === ALL_PROFILES ? 'all' : scope

const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
excludeSources: SIDEBAR_EXCLUDED_SOURCES
})

if (refreshSessionsRequestRef.current === requestId) {
setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep()))
Expand All @@ -279,20 +394,36 @@ export function DesktopController() {
setSessionsLoading(false)
}
}
}, [])

void refreshCronSessions()
void refreshMessagingSessions()
}, [refreshCronSessions, refreshMessagingSessions])

const loadMoreSessions = useCallback(() => {
bumpSessionsLimit()
void refreshSessions()
}, [refreshSessions])

// Refetch when the profile scope flips: the recents fetch is scoped to the
// active profile, so without this a freshly-scoped profile would keep
// whatever page the previous scope loaded.
const profileScope = useStore($profileScope)

useEffect(() => {
void refreshSessions()
}, [profileScope, refreshSessions])

// ALL-profiles view pages one profile at a time: fetch that profile's next
// page and merge it in place, leaving every other profile's rows untouched.
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
const key = normalizeProfileKey(profile)
const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key
const loaded = $sessions.get().filter(inKey).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key)

const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
excludeSources: SIDEBAR_EXCLUDED_SOURCES
})

const keep = sessionsToKeep(key)

setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])
Expand Down Expand Up @@ -635,6 +766,7 @@ export function DesktopController() {
onArchiveAllSessions={() => archiveAllSessions().then(() => refreshSessions())}
onArchiveSession={sessionId => void archiveSession(sessionId)}
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreMessaging={loadMoreMessagingForPlatform}
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
onLoadMoreSessions={loadMoreSessions}
onManageCronJob={() => navigate(CRON_ROUTE)}
Expand Down
23 changes: 20 additions & 3 deletions apps/desktop/src/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ export type {
AnalyticsSkillEntry,
AnalyticsSkillsSummary,
AnalyticsTotals,
BackendUpdateCheckResponse,
AudioSpeakResponse,
AudioTranscriptionResponse,
AuxiliaryModelsResponse,
BackendUpdateCheckResponse,
ConfigFieldSchema,
ConfigSchemaResponse,
CronJob,
Expand Down Expand Up @@ -165,6 +165,16 @@ export function bulkArchiveSessions(preserveIds: string[] = []): Promise<{ ok: b
})
}

// Optional source scoping for session lists: `source` fetches one class's rows
// (the cron slice, a single messaging platform), `excludeSources` drops classes
// (recents exclude cron + messaging). The server applies the same filter to the
// page AND its totals, so "load more" math always matches the rows a section
// actually lists.
export interface SessionSourceFilter {
source?: string
excludeSources?: string[]
}

// Unified, read-only session list aggregated across ALL profiles. Served by the
// primary backend straight off each profile's state.db — no per-profile backend
// is spawned. Single-profile users get the same rows as listSessions(), tagged
Expand All @@ -174,12 +184,19 @@ export async function listAllProfileSessions(
minMessages = 0,
archived: 'exclude' | 'include' | 'only' = 'exclude',
order: 'created' | 'recent' = 'recent',
profile: 'all' | (string & {}) = 'all'
profile: 'all' | (string & {}) = 'all',
filter: SessionSourceFilter = {}
): Promise<PaginatedSessions> {
const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : ''

const excludeParam = filter.excludeSources?.length
? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}`
: ''

const result = await window.hermesDesktop.api<PaginatedSessions>({
path:
`/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}`
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`
})

return {
Expand Down
Loading
Loading