# NousResearch/hermes-agent · Desktop sidebar session ordering and recency fixes

## Open now

- Nothing open on GitHub.

Multiple fixes and features addressing how sessions are ordered in the desktop sidebar, including preventing unwanted auto-persistence of order, preserving recency sorting, and related sidebar UX changes.

## TL;DR

- 5 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #42537. Verify it addresses your case.
- **Recommended:** read PR #42537 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.40`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #42516: Desktop: new sessions appear at bottom because sidebar order is auto-persisted on startup

## Prior & competing PRs

- **#42537: fix(desktop): local-only recents, per-platform sidebar sections, and Ctrl+N regressions**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `apps/desktop/src/app/chat/sidebar/index.tsx`, `apps/desktop/src/app/chat/sidebar/session-row.tsx`, `apps/desktop/src/app/desktop-controller.tsx`, `apps/desktop/src/app/hooks/use-keybinds.ts`, `apps/desktop/src/app/session/hooks/use-route-resume.test.tsx`, `apps/desktop/src/app/session/hooks/use-route-resume.ts`, `apps/desktop/src/app/session/hooks/use-session-state-cache.ts`, `apps/desktop/src/components/assistant-ui/markdown-text.tsx`, `apps/desktop/src/i18n/en.ts`, `apps/desktop/src/i18n/ja.ts`, `apps/desktop/src/i18n/types.ts`, `apps/desktop/src/i18n/zh-hant.ts`, `apps/desktop/src/i18n/zh.ts`, `apps/desktop/src/lib/session-source.ts`, `apps/desktop/src/store/session.ts`, `apps/desktop/src/types/hermes.ts`
  - fix hunks:
  - `apps/desktop/src/app/chat/sidebar/index.tsx`

    ```diff
    @@ -76,6 +76,9 @@ import {
     } from '@/store/profile'
     import {
       $cronSessions,
    +  $messagingPlatformTotals,
    +  $messagingSessions,
    +  $messagingTruncated,
       $selectedStoredSessionId,
       $sessionProfileTotals,
       $sessions,
    ```

    ```diff
    @@ -124,7 +127,6 @@ const WORKSPACE_PAGE = 5
     // unified list scannable, then reveal/fetch more in N-sized steps on demand.
     const PROFILE_INITIAL_PAGE = 5
     const GROUP_DND_ID_PREFIX = 'group:'
    -const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui'])
     
     const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
     
    ```

    ```diff
    @@ -141,24 +143,25 @@ function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[
     
       const byId = new Map(items.map(item => [getId(item), item]))
       const seen = new Set<string>()
    -  const out: T[] = []
    +  const ordered: T[] = []
     
       for (const id of orderIds) {
         const item = byId.get(id)
     
         if (item) {
    -      out.push(item)
    +      ordered.push(item)
           seen.add(id)
         }
       }
     
    -  for (const item of items) {
    -    if (!seen.has(getId(item))) {
    -      out.push(item)
    -    }
    -  }
    +  // Items missing from the persisted order are new since it was last
    +  // reconciled. Callers pass recency-sorted lists (newest first), so surface
    +  // these at the TOP instead of burying them beneath the saved order —
    +  // otherwise a brand-new session sinks to the bottom of the sidebar and reads
    +  // as "my latest session never showed up".
    +  const fresh = items.filter(item => !seen.has(getId(item)))
     
    -  return out
    +  return fresh.length ? [...fresh, ...ordered] : ordered
     }
     
     function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
    ```

    ```diff
    @@ -171,17 +174,15 @@ function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
       }
     
       const current = new Set(currentIds)
    -  const next = orderIds.filter(id => current.has(id))
    -  const known = new Set(next)
    +  const retained = orderIds.filter(id => current.has(id))
    +  const retainedSet = new Set(retained)
     
    -  for (const id of currentIds) {
    -    if (!known.has(id)) {
    -      next.push(id)
    -      known.add(id)
    -    }
    -  }
    +  // New ids (absent from the saved order) are the newest sessions/groups; keep
    +  // them ahead of the persisted order so fresh activity surfaces at the top of
    +  // the sidebar rather than being appended to the bottom.
    +  const fresh = currentIds.filter(id => !retainedSet.has(id))
     
    -  return next
    +  return [...fresh, ...retained]
     }
     
     function sameIds(left: string[], right: string[]) {
    ```

    ```diff
    @@ -251,43 +252,6 @@ function workspaceGroupsFor(
       return [...groups.values()]
     }
     
    -function sourceSessionGroupsFor(sessions: SessionInfo[]): {
    -  localSessions: SessionInfo[]
    -  sourceGroups: SidebarSessionGroup[]
    -} {
    -  const groups = new Map<string, SidebarSessionGroup>()
    -  const localSessions: SessionInfo[] = []
    -
    -  for (const session of sessions) {
    -    const sourceId = normalizeSessionSource(session.source)
    -
    -    if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) {
    -      localSessions.push(session)
    -
    -      continue
    -    }
    -
    -    const label = sessionSourceLabel(sourceId) ?? sourceId
    -
    -    const group = groups.get(sourceId) ?? {
    -      id: `source:${sourceId}`,
    -      label,
    -      mode: 'source',
    -      path: null,
    -      sessions: [],
    -      sourceId
    -    }
    -
    -    group.sessions.push(session)
    -    groups.set(sourceId, group)
    -  }
    -
    -  return {
    -    localSessions,
    -    sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
    -  }
    -}
    -
     function useSortableBindings(id: string) {
       const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
     
    ```

    ```diff
    @@ -309,6 +273,7 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
       onNavigate: (item: SidebarNavItem) => void
       onLoadMoreSessions: () => void
       onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
    +  onLoadMoreMessaging?: (platform: string) => Promise<void> | void
       onResumeSession: (sessionId: string) => void
       onDeleteSession: (sessionId: string) => void
       onArchiveSession: (sessionId: string) => void
    ```

    ```diff
    @@ -322,6 +287,7 @@ export function ChatSidebar({
       onNavigate,
       onLoadMoreSessions,
       onLoadMoreProfileSessions,
    +  onLoadMoreMessaging,
       onResumeSession,
       onDeleteSession,
       onArchiveSession,
    ```

    ```diff
    @@ -345,6 +311,9 @@ export function ChatSidebar({
       const sessions = useStore($sessions)
       const cronSessions = useStore($cronSessions)
       const cronJobs = useStore($cronJobs)
    +  const messagingSessions = useStore($messagingSessions)
    +  const messagingPlatformTotals = useStore($messagingPlatformTotals)
    +  const messagingTruncated = useStore($messagingTruncated)
       const sessionsLoading = useStore($sessionsLoading)
       const sessionsTotal = useStore($sessionsTotal)
       const sessionProfileTotals = useStore($sessionProfileTotals)
    ```

    ```diff
    @@ -364,6 +333,8 @@ export function ChatSidebar({
       const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
       const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
       const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
    +  const [messagingLoadMorePending, setMessagingLoadMorePending] = useState<Record<string, boolean>>({})
    +  const [messagingOpen, setMessagingOpen] = useState<Record<string, boolean>>({})
       const searchInputRef = useRef<HTMLInputElement>(null)
       const trimmedQuery = searchQuery.trim()
     
    ```

    ```diff
    @@ -529,24 +500,12 @@ export function ChatSidebar({
         [unpinnedAgentSessions, agentOrderIds]
       )
     
    -  const { localSessions: localAgentSessions, sourceGroups } = useMemo(
    -    () => sourceSessionGroupsFor(agentSessions),
    -    [agentSessions]
    -  )
    -
    -  const orderedSourceGroups = useMemo(
    -    () => orderByIds(sourceGroups, g => g.id, workspaceOrderIds),
    -    [sourceGroups, workspaceOrderIds]
    -  )
    -
    +  // Recents are local-only: messaging-platform sessions are fetched as their
    +  // own slice ($messagingSessions) and rendered in self-managed per-platform
    +  // sections below, so there is no source-grouping magic to untangle here.
       const agentGroups = useMemo(
    -    () =>
    -      orderByIds(
    -        workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }),
    -        g => g.id,
    -        workspaceOrderIds
    -      ),
    -    [localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds]
    +    () => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
    +    [agentSessions, s.noWorkspace, workspaceOrderIds]
       )
     
       const loadMoreForProfileGroup = useCallback(
    ```

    ```diff
    @@ -564,6 +523,64 @@ export function ChatSidebar({
         [onLoadMoreProfileSessions]
       )
     
    +  const loadMoreForMessaging = useCallback(
    +    (platform: string) => {
    +      if (!onLoadMoreMessaging) {
    +        return
    +      }
    +
    +      setMessagingLoadMorePending(prev => ({ ...prev, [platform]: true }))
    +
    +      void Promise.resolve(onLoadMoreMessaging(platform))
    +        .catch(() => undefined)
    +        .finally(() => setMessagingLoadMorePending(({ [platform]: _done, ...rest }) => rest))
    +    },
    +    [onLoadMoreMessaging]
    +  )
    +
    +  // Each messaging platform is its own self-managed section: split the
    +  // separately-fetched messaging slice by source, newest platform first, rows
    +  // 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) {
    +      return []
    +    }
    +
    +    const bySource = new Map<string, SessionInfo[]>()
    +
    +    for (const session of messagingSessions) {
    +      const sourceId = normalizeSessionSource(session.source)
    +
    +      if (!sourceId) {
    +        continue
    +      }
    +
    +      const list = bySource.get(sourceId) ?? []
    +      list.push(session)
    +      bySource.set(sourceId, list)
    +    }
    +
    +    return [...bySource.entries()]
    +      .map(([sourceId, list]) => {
    +        const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a))
    +        const known = messagingPlatformTotals[sourceId]
    +        const total = Math.max(ordered.length, known ?? 0)
    +
    +        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,
    +          sourceId,
    +          total
    +        }
    +      })
    +      .sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
    +  }, [messagingSessions, 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.
       const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
    ```

    ```diff
    @@ -610,37 +627,28 @@ export function ChatSidebar({
         sessionProfileTotals
       ])
     
    -  const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions
    -
    -  const displayAgentGroups = useMemo(() => {
    -    if (orderedSourceGroups.length) {
    -      const localGroups = agentsGrouped
    -        ? agentGroups
    -        : localAgentSessions.length
    -          ? [
    -              {
    -                id: 'local-sessions',
    -                label: 'Local',
    -                mode: 'workspace' as const,
    -                path: null,
    -                sessions: localAgentSessions
    -              }
    -            ]
    -          : []
    +  const displayAgentSessions = agentSessions
     
    -      return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds)
    -    }
    +  // Pagination is scope-aware. In "All profiles" mode it tracks the global
    +  // unified set. When scoped to one profile it must compare that profile's own
    +  // loaded rows against that profile's total — otherwise a huge default profile
    +  // 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]
     
    -    return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
    -  }, [
    -    agentGroups,
    -    agentsGrouped,
    -    localAgentSessions,
    -    orderedSourceGroups,
    -    profileGroups,
    -    showAllProfiles,
    -    workspaceOrderIds
    -  ])
    +  const knownSessionTotal = Math.max(
    +    showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
    +    loadedSessionCount
    +  )
    +
    +  const hasMoreSessions = knownSessionTotal > loadedSessionCount
    +  const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
    +
    +  const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
    +
    +  const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
     
       useEffect(() => {
         if (!displayAgentGroups?.length || showAllProfiles) {
    ```

    ```diff
    @@ -661,25 +669,6 @@ export function ChatSidebar({
     
       const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
     
    -  // Pagination is scope-aware. In "All profiles" mode it tracks the global
    -  // unified set. When scoped to one profile it must compare that profile's own
    -  // loaded rows against that profile's total — otherwise a huge default profile
    -  // 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 knownSessionTotal = Math.max(
    -    showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
    -    loadedSessionCount
    -  )
    -
    -  const hasMoreSessions = knownSessionTotal > loadedSessionCount
    -  const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
    -
    -  const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
    -
       const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
         if (!over || active.id === over.id) {
           return
    ```

    ```diff
    @@ -902,7 +891,7 @@ export function ChatSidebar({
                   // the toggle does nothing, and it's irrelevant in the ALL-profiles
                   // view (always grouped by profile), so hide the button (not the slot).
                   <div className="grid size-6 shrink-0 place-items-center">
    -                {!showAllProfiles && localAgentSessions.length > 0 ? (
    +                {!showAllProfiles && agentSessions.length > 0 ? (
                       <Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
                         <Button
                           aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
    ```

    ```diff
    @@ -942,6 +931,46 @@ export function ChatSidebar({
               />
             )}
     
    +        {contentVisible && showSessionSections && !trimmedQuery &&
    +          messagingGroups.map(group => (
    +            <SidebarSessionsSection
    +              activeSessionId={activeSidebarSessionId}
    +              contentClassName="flex max-h-56 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
    +              emptyState={null}
    +              footer={
    +                group.hasMore ? (
    +                  <SidebarLoadMoreRow
    +                    loading={Boolean(messagingLoadMorePending[group.sourceId])}
    +                    onClick={() => loadMoreForMessaging(group.sourceId)}
    +                    step={Math.max(0, group.total - group.sessions.length)}
    +                  />
    +                ) : null
    +              }
    +              key={group.sourceId}
    +              label={group.label}
    +              labelIcon={
    +                <PlatformAvatar
    +                  className="size-4 rounded-[4px] text-[0.5625rem] [&_svg]:size-3"
    +                  platformId={group.sourceId}
    +                  platformName={group.label}
    +                />
    +              }
    +              labelMeta={countLabel(group.sessions.length, group.total)}
    +              onArchiveSession={onArchiveSession}
    +              onDeleteSession={onDeleteSession}
    +              onResumeSession={onResumeSession}
    +              onToggle={() =>
    +                setMessagingOpen(prev => ({ ...prev, [group.sourceId]: prev[group.sourceId] === false }))
    +              }
    +              onTogglePin={pinSession}
    +              open={messagingOpen[group.sourceId] !== false}
    +              pinned={false}
    +              rootClassName="shrink-0 p-0"
    +              sessions={group.sessions}
    +              workingSessionIdSet={workingSessionIdSet}
    +            />
    +          ))}
    +
             {contentVisible && !trimmedQuery && cronJobs.length > 0 && (
               <SidebarCronJobsSection
                 jobs={cronJobs}
    ```

    ```diff
    @@ -972,9 +1001,10 @@ interface SidebarSectionHeaderProps {
       onToggle: () => void
       action?: React.ReactNode
       meta?: React.ReactNode
    +  icon?: React.ReactNode
     }
     
    -function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
    +function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: SidebarSectionHeaderProps) {
       return (
         <div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
           <button
    ```

    ```diff
    @@ -982,6 +1012,7 @@ function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSe
             onClick={onToggle}
             type="button"
           >
    +        {icon}
             <SidebarPanelLabel>{label}</SidebarPanelLabel>
             {meta && <SidebarCount>{meta}</SidebarCount>}
             <DisclosureCaret
    ```

    ```diff
    @@ -1044,6 +1075,14 @@ interface SidebarSessionGroup {
       totalCount?: number
     }
     
    +interface MessagingSection {
    +  sourceId: string
    +  label: string
    +  sessions: SessionInfo[]
    +  total: number
    +  hasMore: boolean
    +}
    +
     interface SidebarSessionsSectionProps {
       label: string
       open: boolean
    ```

    ```diff
    @@ -1065,6 +1104,7 @@ interface SidebarSessionsSectionProps {
       footer?: React.ReactNode
       groups?: SidebarSessionGroup[]
       labelMeta?: React.ReactNode
    +  labelIcon?: React.ReactNode
       sortable?: boolean
       onReorder?: (event: DragEndEvent) => void
       dndSensors?: ReturnType<typeof useSensors>
    ```

    ```diff
    @@ -1091,6 +1131,7 @@ function SidebarSessionsSection({
       footer,
       groups,
       labelMeta,
    +  labelIcon,
       sortable = false,
       onReorder,
       dndSensors
    ```

    ```diff
    @@ -1209,7 +1250,14 @@ function SidebarSessionsSection({
     
       return (
         <SidebarGroup className={rootClassName}>
    -      <SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />
    +      <SidebarSectionHeader
    +        action={headerAction}
    +        icon={labelIcon}
    +        label={label}
    +        meta={labelMeta}
    +        onToggle={onToggle}
    +        open={open}
    +      />
           {open && (
             <SidebarGroupContent className={resolvedContentClassName}>
               {body}
    ```
  - `apps/desktop/src/app/chat/sidebar/session-row.tsx`

    ```diff
    @@ -2,12 +2,15 @@ import { useStore } from '@nanostores/react'
     import type * as React from 'react'
     
     import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
    +import { PlatformAvatar } from '@/app/messaging/platform-icon'
     import { Button } from '@/components/ui/button'
     import { Codicon } from '@/components/ui/codicon'
    +import { Tip } from '@/components/ui/tooltip'
     import type { SessionInfo } from '@/hermes'
     import { type Translations, useI18n } from '@/i18n'
     import { sessionTitle } from '@/lib/chat-runtime'
     import { triggerHaptic } from '@/lib/haptics'
    +import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
     import { cn } from '@/lib/utils'
     import { $attentionSessionIds } from '@/store/session'
     
    ```

    ```diff
    @@ -67,6 +70,11 @@ export function SidebarSessionRow({
       const title = sessionTitle(session)
       const age = formatAge(session.last_active || session.started_at, r)
       const handleLabel = `Reorder ${title}`
    +  // A handed-off session's live source is local, but it originated on a
    +  // messaging platform — surface that origin as a small badge so e.g. a
    +  // Telegram thread continued here still reads as Telegram.
    +  const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
    +  const handoffLabel = handoffSource ? sessionSourceLabel(handoffSource) ?? handoffSource : null
       // Subscribe per-row (the leaf) instead of drilling a set through the list —
       // the atom is tiny and rarely non-empty. True when a clarify prompt in this
       // session is waiting on the user.
    ```

    ```diff
    @@ -179,6 +187,15 @@ export function SidebarSessionRow({
                   <SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
                 </span>
               )}
    +          {handoffSource && handoffLabel ? (
    +            <Tip label={r.handoffOrigin(handoffLabel)}>
    +              <PlatformAvatar
    +                className="size-4 rounded-[4px] text-[0.5rem] [&_svg]:size-2.5"
    +                platformId={handoffSource}
    +                platformName={handoffLabel}
    +              />
    +            </Tip>
    +          ) : null}
               <span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
                 {title}
               </span>
    ```
  - `apps/desktop/src/app/desktop-controller.tsx`

    ```diff
    @@ -14,6 +14,12 @@ import { useSkinCommand } from '@/themes/use-skin-command'
     import { formatRefValue } from '../components/assistant-ui/directive-text'
     import { getCronJobs, 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 { setCronFocusJobId, setCronJobs } from '../store/cron'
     import {
       $panesFlipped,
    ```

    ```diff
    @@ -46,10 +52,12 @@ import {
       $gatewayState,
       $selectedStoredSessionId,
       $sessions,
    +  $messagingSessions,
       $workingSessionIds,
       CRON_SECTION_LIMIT,
       getRecentlySettledSessionIds,
       mergeSessionPage,
    +  MESSAGING_SECTION_LIMIT,
       sessionPinId,
       setAwaitingResponse,
       setBusy,
    ```

    ```diff
    @@ -59,6 +67,9 @@ import {
       setCurrentModel,
       setCurrentProvider,
       setMessages,
    +  setMessagingPlatformTotals,
    +  setMessagingSessions,
    +  setMessagingTruncated,
       setSessionProfileTotals,
       setSessions,
       setSessionsLoading,
    ```

    ```diff
    @@ -121,6 +132,15 @@ const SkillsView = lazy(async () => ({ default: (await import('./skills')).Skill
     // this cadence while the app is open + visible so new runs surface promptly
     // instead of waiting for the next user-triggered refreshSessions().
     const CRON_POLL_INTERVAL_MS = 30_000
    +// The recents list is local-only: cron rows have their own section, and each
    +// messaging platform (telegram, discord, …) is fetched separately into its own
    +// self-managed sidebar section (refreshMessagingSessions). Excluding both here
    +// keeps "Load more" paging through interactive local chats instead of
    +// interleaving gateway threads that bury them.
    +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 the poll only swaps the atom (and re-renders the
     // sidebar) when the visible cron rows actually changed.
    ```

    ```diff
    @@ -280,6 +300,51 @@ export function DesktopController() {
         }
       }, [])
     
    +  // 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 => (sameCronSignature(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(s => normalizeSessionSource(s.source) === platform)
    +
    +    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) }))
    +  }, [])
    +
       // Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
       // synchronously (agent tool call or the cron UI), so refreshing here right
       // after an agent turn surfaces a new job immediately; the interval poll keeps
    ```

    ```diff
    @@ -316,7 +381,7 @@ export function DesktopController() {
           const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
     
           const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
    -        excludeSources: ['cron']
    +        excludeSources: SIDEBAR_EXCLUDED_SOURCES
           })
     
           if (refreshSessionsRequestRef.current === requestId) {
    ```

    ```diff
    @@ -332,7 +397,8 @@ export function DesktopController() {
     
         void refreshCronSessions()
         void refreshCronJobs()
    -  }, [profileScope, refreshCronSessions, refreshCronJobs])
    +    void refreshMessagingSessions()
    +  }, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
     
       const loadMoreSessions = useCallback(() => {
         bumpSessionsLimit()
    ```

    ```diff
    @@ -347,7 +413,7 @@ export function DesktopController() {
         const loaded = $sessions.get().filter(inKey).length
     
         const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
    -      excludeSources: ['cron']
    +      excludeSources: SIDEBAR_EXCLUDED_SOURCES
         })
     
         const keep = sessionsToKeep(key)
    ```

    ```diff
    @@ -704,6 +770,7 @@ export function DesktopController() {
           currentView={currentView}
           onArchiveSession={sessionId => void archiveSession(sessionId)}
           onDeleteSession={sessionId => void removeSession(sessionId)}
    +      onLoadMoreMessaging={loadMoreMessagingForPlatform}
           onLoadMoreProfileSessions={loadMoreSessionsForProfile}
           onLoadMoreSessions={loadMoreSessions}
           onManageCronJob={jobId => {
    ```
  - `apps/desktop/src/app/hooks/use-keybinds.ts`

    ```diff
    @@ -18,6 +18,7 @@ import {
       toggleSidebarOpen
     } from '@/store/layout'
     import {
    +  $newChatProfile,
       cycleProfile,
       requestProfileCreate,
       switchProfileToSlot,
    ```

    ```diff
    @@ -106,6 +107,10 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
         'nav.agents': () => navigate(AGENTS_ROUTE),
     
         'session.new': () => {
    +      // Match the sidebar New Session button. A plain keyboard new chat should
    +      // target the current live profile, not a stale per-profile quick-create
    +      // selection from a prior action.
    +      $newChatProfile.set(null)
           deps.startFreshSession()
           window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
         },
    ```
  - `apps/desktop/src/app/session/hooks/use-route-resume.test.tsx`

    ```diff
    @@ -84,6 +84,60 @@ describe('useRouteResume', () => {
         expect(resumeSession).not.toHaveBeenCalled()
       })
     
    +  it('self-heals a stranded routed session (null selected/active, same pathname, not a fresh draft)', () => {
    +    const resumeSession = vi.fn(async () => undefined)
    +    const startFreshSessionDraft = vi.fn()
    +    const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
    +    const creatingSessionRef = { current: false }
    +    const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
    +    const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
    +
    +    const { rerender } = render(
    +      <RouteResumeHarness
    +        activeSessionId="runtime-1"
    +        activeSessionIdRef={activeSessionIdRef}
    +        creatingSessionRef={creatingSessionRef}
    +        currentView="chat"
    +        freshDraftReady={false}
    +        gatewayState="open"
    +        locationPathname="/session-1"
    +        resumeSession={resumeSession}
    +        routedSessionId="session-1"
    +        runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
    +        selectedStoredSessionId="session-1"
    +        selectedStoredSessionIdRef={selectedStoredSessionIdRef}
    +        startFreshSessionDraft={startFreshSessionDraft}
    +      />
    +    )
    +
    +    expect(resumeSession).not.toHaveBeenCalled()
    +
    +    // A create/stream race nulls selected/active but the route stays on the
    +    // session and freshDraftReady is false (NOT a new-chat transition).
    +    activeSessionIdRef.current = null
    +    selectedStoredSessionIdRef.current = null
    +    rerender(
    +      <RouteResumeHarness
    +        activeSessionId={null}
    +        activeSessionIdRef={activeSessionIdRef}
    +        creatingSessionRef={creatingSessionRef}
    +        currentView="chat"
    +        freshDraftReady={false}
    +        gatewayState="open"
    +        locationPathname="/session-1"
    +        resumeSession={resumeSession}
    +        routedSessionId="session-1"
    +        runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
    +        selectedStoredSessionId={null}
    +        selectedStoredSessionIdRef={selectedStoredSessionIdRef}
    +        startFreshSessionDraft={startFreshSessionDraft}
    +      />
    +    )
    +
    +    expect(resumeSession).toHaveBeenCalledTimes(1)
    +    expect(resumeSession).toHaveBeenCalledWith('session-1', true)
    +  })
    +
       it('resumes when pathname changes to a routed session', () => {
         const resumeSession = vi.fn(async () => undefined)
         const startFreshSessionDraft = vi.fn()
    ```
  - `apps/desktop/src/app/session/hooks/use-route-resume.ts`

    ```diff
    @@ -77,10 +77,27 @@ export function useRouteResume({
             Boolean(cachedRuntime) &&
             cachedRuntime === activeSessionIdRef.current
     
    -      // Resume only when the route meaningfully changed (or gateway just opened).
    -      // This avoids a transient /:sid re-resume during "new chat" state clears
    +      // Self-heal a desynced view: the route points at a session that isn't the
    +      // loaded one. A create/stream race can leave selected/active null while
    +      // the route stays on /:sid (symptom: brand-new chat shows "Thinking" then
    +      // an empty transcript even though the turn completed and persisted). The
    +      // pathname didn't change, so the normal gate would skip and the view stays
    +      // stuck empty forever. selectedStoredSessionIdRef is set synchronously at
    +      // resume entry, so this can't loop; the resume's cached fast-path restores
    +      // the already-streamed messages without a refetch.
    +      //
    +      // Crucially this must NOT fire during a /:sid -> /new transition, where
    +      // startFreshSessionDraft nulls selected/active one render before the
    +      // pathname flips to / (same null+/:sid signature). freshDraftReady is the
    +      // discriminator: it's true while heading into a blank new chat, false when
    +      // genuinely stranded on a routed session.
    +      const stuckOnRoutedSession = routedSessionId !== selectedStoredSessionIdRef.current && !freshDraftReady
    +
    +      // Resume when the route meaningfully changed, the gateway just opened, or
    +      // we're stranded on a routed session that never loaded. The first two
    +      // guard against a transient /:sid re-resume during "new chat" state clears
           // before the pathname updates from /:sid -> /.
    -      const shouldResume = pathnameChanged || gatewayBecameOpen
    +      const shouldResume = pathnameChanged || gatewayBecameOpen || stuckOnRoutedSession
     
           if (!alreadyActive && shouldResume && !creatingSessionRef.current) {
             void resumeSession(routedSessionId, true)
    ```
  - `apps/desktop/src/app/session/hooks/use-session-state-cache.ts`

    ```diff
    @@ -150,6 +150,29 @@ export function useSessionStateCache({
     
           pendingViewStateRef.current = { sessionId, state }
     
    +      // Terminal / attention transitions (turn finished, error, or the agent is
    +      // now waiting on the user) MUST reach the view immediately. Electron
    +      // throttles `requestAnimationFrame` to ~0 while the window is
    +      // backgrounded, occluded, or unfocused, so an RAF-deferred flush can be
    +      // stranded in `pendingViewStateRef` indefinitely — that's the "new chat
    +      // stuck on Thinking until I refocus / F5" bug. Flush these synchronously
    +      // (cancelling any in-flight RAF, since we're about to publish the latest
    +      // state anyway). The plain busy heartbeat stays RAF-batched: that
    +      // coalescing exists only to keep periodic `session.info` updates from
    +      // churning `$messages` and jerking the scroll position while reading.
    +      const isCriticalTransition = !state.busy || state.needsInput
    +
    +      if (isCriticalTransition) {
    +        if (viewSyncRafRef.current !== null && typeof window !== 'undefined') {
    +          window.cancelAnimationFrame(viewSyncRafRef.current)
    +          viewSyncRafRef.current = null
    +        }
    +
    +        flushPendingViewState()
    +
    +        return
    +      }
    +
           if (viewSyncRafRef.current !== null) {
             return
           }
    ```
  - `apps/desktop/src/components/assistant-ui/markdown-text.tsx`

    ```diff
    @@ -494,11 +494,9 @@ export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: Markdo
     
     const MarkdownTextImpl = () => {
       return (
    -    <SmoothStreamingText>
    -      <DeferStreamingText>
    -        <MarkdownTextSurface />
    -      </DeferStreamingText>
    -    </SmoothStreamingText>
    +    <DeferStreamingText>
    +      <MarkdownTextSurface />
    +    </DeferStreamingText>
       )
     }
     
    ```
  - `apps/desktop/src/i18n/en.ts`

    ```diff
    @@ -1081,6 +1081,7 @@ export const en: Translations = {
           sessionRunning: 'Session running',
           needsInput: 'Needs your input',
           waitingForAnswer: 'Waiting for your answer',
    +      handoffOrigin: platform => `Handed off from ${platform}`,
           renamed: 'Renamed',
           renameFailed: 'Rename failed',
           renameTitle: 'Rename session',
    ```
  - `apps/desktop/src/i18n/ja.ts`

    ```diff
    @@ -1224,6 +1224,7 @@ export const ja = defineLocale({
           sessionRunning: 'セッション実行中',
           needsInput: '入力が必要です',
           waitingForAnswer: '回答を待っています',
    +      handoffOrigin: platform => `${platform} から引き継ぎ`,
           renamed: '名前を変更しました',
           renameFailed: '名前の変更に失敗しました',
           renameTitle: 'セッションの名前を変更',
    ```
  - `apps/desktop/src/i18n/types.ts`

    ```diff
    @@ -838,6 +838,7 @@ export interface Translations {
           sessionRunning: string
           needsInput: string
           waitingForAnswer: string
    +      handoffOrigin: (platform: string) => string
           renamed: string
           renameFailed: string
           renameTitle: string
    ```
  - `apps/desktop/src/i18n/zh-hant.ts`

    ```diff
    @@ -1190,6 +1190,7 @@ export const zhHant = defineLocale({
           sessionRunning: '工作階段執行中',
           needsInput: '需要您的輸入',
           waitingForAnswer: '等待您的回答',
    +      handoffOrigin: platform => `從 ${platform} 轉接`,
           renamed: '已重新命名',
           renameFailed: '重新命名失敗',
           renameTitle: '重新命名工作階段',
    ```
  - `apps/desktop/src/i18n/zh.ts`

    ```diff
    @@ -1268,6 +1268,7 @@ export const zh: Translations = {
           sessionRunning: '会话运行中',
           needsInput: '需要你输入',
           waitingForAnswer: '正在等待你的回答',
    +      handoffOrigin: platform => `从 ${platform} 转接`,
           renamed: '已重命名',
           renameFailed: '重命名失败',
           renameTitle: '重命名会话',
    ```
  - `apps/desktop/src/lib/session-source.ts`

    ```diff
    @@ -34,12 +34,76 @@ const SOURCE_ALIASES: Record<string, string[]> = {
       whatsapp: ['wa']
     }
     
    +// Sources that run on the local machine rather than an external messaging
    +// platform. A handoff *from* one of these isn't a platform origin worth a badge.
    +// Exported so the recents fetch can keep these in the main list while the
    +// messaging fetch excludes them.
    +export const LOCAL_SESSION_SOURCE_IDS = ['cli', 'codex', 'desktop', 'gateway', 'local', 'tui']
    +const LOCAL_SOURCE_IDS = new Set(LOCAL_SESSION_SOURCE_IDS)
    +
    +// External messaging platforms that each get their own self-managed sidebar
    +// section (fetched separately from local recents). Mirrors the gateway platform
    +// adapters; keep in sync with PLATFORM_ICONS in app/messaging/platform-icon.tsx.
    +export const MESSAGING_SESSION_SOURCE_IDS = [
    +  'telegram',
    +  'discord',
    +  'slack',
    +  'mattermost',
    +  'matrix',
    +  'signal',
    +  'whatsapp',
    +  'bluebubbles',
    +  'homeassistant',
    +  'email',
    +  'sms',
    +  'webhook',
    +  'api_server',
    +  'weixin',
    +  'wecom',
    +  'qqbot',
    +  'yuanbao',
    +  'dingtalk',
    +  'feishu'
    +]
    +const MESSAGING_SOURCE_IDS = new Set(MESSAGING_SESSION_SOURCE_IDS)
    +
    +/** True when a source id is an external messaging platform (gets its own
    + *  sidebar section) rather than a local/CLI/desktop session. */
    +export function isMessagingSource(source: null | string | undefined): boolean {
    +  const id = normalizeSessionSource(source)
    +
    +  return id != null && MESSAGING_SOURCE_IDS.has(id)
    +}
    +
     export function normalizeSessionSource(source: null | string | undefined): string | null {
       const id = source?.trim().toLowerCase()
     
       return id || null
     }
     
    +/**
    + * Resolve the origin messaging platform for a handed-off session. Returns the
    + * normalized platform id (e.g. 'telegram') when the session completed a handoff
    + * from a real messaging platform, otherwise null. After a handoff the live
    + * source is local, so this is what drives the row's origin-platform badge.
    + */
    +export function handoffOriginSource(
    +  handoffState: null | string | undefined,
    +  handoffPlatform: null | string | undefined
    +): string | null {
    +  if (handoffState !== 'completed') {
    +    return null
    +  }
    +
    +  const id = normalizeSessionSource(handoffPlatform)
    +
    +  if (!id || LOCAL_SOURCE_IDS.has(id)) {
    +    return null
    +  }
    +
    +  return id
    +}
    +
     export function sessionSourceLabel(source: null | string | undefined): string | null {
       const id = normalizeSessionSource(source)
     
    ```
  - `apps/desktop/src/store/session.ts`

    ```diff
    @@ -85,6 +85,20 @@ export const $cronSessions = atom<SessionInfo[]>([])
     // badge renders "N+". Lives here so the controller (fetch) and sidebar (badge)
     // share one source of truth without a circular import.
     export const CRON_SECTION_LIMIT = 50
    +// Messaging-platform sessions (telegram/discord/...) are fetched as their own
    +// slice — separate from local recents — so each platform renders a
    +// self-managed sidebar section and never interleaves with (or buries) local
    +// chats in the recents page. One combined fetch seeds every platform; a
    +// platform that exceeds this cap gets its own per-platform "load more".
    +export const $messagingSessions = atom<SessionInfo[]>([])
    +export const MESSAGING_SECTION_LIMIT = 100
    +// Exact per-platform conversation totals, keyed by source id. Empty until a
    +// per-platform "load more" fetch resolves it (the combined seed fetch only
    +// knows the aggregate), so sections fall back to their loaded count.
    +export const $messagingPlatformTotals = atom<Record<string, number>>({})
    +// True when the combined seed fetch hit MESSAGING_SECTION_LIMIT, so at least
    +// one platform may have more rows on disk than were loaded.
    +export const $messagingTruncated = atom<boolean>(false)
     // Listable conversation count per profile (children excluded), keyed by profile
     // name. Lets the sidebar scope its "Load more" footer to the active profile so a
     // huge default profile doesn't keep "Load more" visible while browsing a small
    ```

    ```diff
    @@ -129,6 +143,10 @@ export const setGatewayState = (next: Updater<string>) => updateAtom($gatewaySta
     export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
     export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)
     export const setCronSessions = (next: Updater<SessionInfo[]>) => updateAtom($cronSessions, next)
    +export const setMessagingSessions = (next: Updater<SessionInfo[]>) => updateAtom($messagingSessions, next)
    +export const setMessagingPlatformTotals = (next: Updater<Record<string, number>>) =>
    +  updateAtom($messagingPlatformTotals, next)
    +export const setMessagingTruncated = (next: Updater<boolean>) => updateAtom($messagingTruncated, next)
     export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =>
       updateAtom($sessionProfileTotals, next)
     export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
    ```
  - `apps/desktop/src/types/hermes.ts`

    ```diff
    @@ -297,6 +297,14 @@ export interface SessionInfo {
       started_at: number
       title: null | string
       tool_call_count: number
    +  /** Origin platform when this session was handed off from a messaging
    +   *  platform (e.g. a Telegram thread continued in the desktop app). The live
    +   *  {@link source} becomes local (tui/desktop) after a handoff, so the origin
    +   *  is preserved here to surface the platform badge on the row. */
    +  handoff_platform?: null | string
    +  /** Handoff lifecycle: 'pending' | 'in_progress' | 'completed' | 'failed'. */
    +  handoff_state?: null | string
    +  handoff_error?: null | string
       /** Owning profile name, set by the cross-profile aggregator
        *  (`/api/profiles/sessions`). Absent on legacy single-profile responses,
        *  which the UI treats as the default profile. */
    ```
- **#42520: fix(desktop): keep natural sidebar order until manual drag**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `d046169646b03d889bfadac3329970559b655a27`, `#42537`
- **#42606: fix(desktop): avoid auto-seeding sidebar session order**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#42609: fix(desktop): keep new sessions in recency order**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`

## Fix status

- Merged to main in PR #42537 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-09`.
