NousResearch/hermes-agent

Desktop sidebar session ordering and recency fixes

Cluster 42516. Data as of

Nothing open on GitHub. Fix merged in #42537. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #42537. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #42537 fix(desktop): local-only recents, per-platform sidebar sections, and Ctrl+N regressions

    merged to main

    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

    Show the diff
    • apps/desktop/src/app/chat/sidebar/index.tsx
      @@ -76,6 +76,9 @@ import {
       } from '@/store/profile'
       import {
         $cronSessions,
      +  $messagingPlatformTotals,
      +  $messagingSessions,
      +  $messagingTruncated,
         $selectedStoredSessionId,
         $sessionProfileTotals,
         $sessions,
      @@ -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}`
       
      @@ -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[] {
      @@ -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[]) {
      @@ -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 })
       
      @@ -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
      @@ -322,6 +287,7 @@ export function ChatSidebar({
         onNavigate,
         onLoadMoreSessions,
         onLoadMoreProfileSessions,
      +  onLoadMoreMessaging,
         onResumeSession,
         onDeleteSession,
         onArchiveSession,
      @@ -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)
      @@ -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()
       
      @@ -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(
      @@ -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>(() => {
      @@ -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) {
      @@ -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
      @@ -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}
      @@ -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}
      @@ -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
      @@ -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
      @@ -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
      @@ -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>
      @@ -1091,6 +1131,7 @@ function SidebarSessionsSection({
         footer,
         groups,
         labelMeta,
      +  labelIcon,
         sortable = false,
         onReorder,
         dndSensors
      @@ -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
      @@ -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'
       
      @@ -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.
      @@ -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
      @@ -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,
      @@ -46,10 +52,12 @@ import {
         $gatewayState,
         $selectedStoredSessionId,
         $sessions,
      +  $messagingSessions,
         $workingSessionIds,
         CRON_SECTION_LIMIT,
         getRecentlySettledSessionIds,
         mergeSessionPage,
      +  MESSAGING_SECTION_LIMIT,
         sessionPinId,
         setAwaitingResponse,
         setBusy,
      @@ -59,6 +67,9 @@ import {
         setCurrentModel,
         setCurrentProvider,
         setMessages,
      +  setMessagingPlatformTotals,
      +  setMessagingSessions,
      +  setMessagingTruncated,
         setSessionProfileTotals,
         setSessions,
         setSessionsLoading,
      @@ -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.
      @@ -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
      @@ -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) {
      @@ -332,7 +397,8 @@ export function DesktopController() {
       
           void refreshCronSessions()
           void refreshCronJobs()
      -  }, [profileScope, refreshCronSessions, refreshCronJobs])
      +    void refreshMessagingSessions()
      +  }, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
       
         const loadMoreSessions = useCallback(() => {
           bumpSessionsLimit()
      @@ -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)
      @@ -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
      @@ -18,6 +18,7 @@ import {
         toggleSidebarOpen
       } from '@/store/layout'
       import {
      +  $newChatProfile,
         cycleProfile,
         requestProfileCreate,
         switchProfileToSlot,
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -1224,6 +1224,7 @@ export const ja = defineLocale({
             sessionRunning: 'セッション実行中',
             needsInput: '入力が必要です',
             waitingForAnswer: '回答を待っています',
      +      handoffOrigin: platform => `${platform} から引き継ぎ`,
             renamed: '名前を変更しました',
             renameFailed: '名前の変更に失敗しました',
             renameTitle: 'セッションの名前を変更',
    • apps/desktop/src/i18n/types.ts
      @@ -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
      @@ -1190,6 +1190,7 @@ export const zhHant = defineLocale({
             sessionRunning: '工作階段執行中',
             needsInput: '需要您的輸入',
             waitingForAnswer: '等待您的回答',
      +      handoffOrigin: platform => `從 ${platform} 轉接`,
             renamed: '已重新命名',
             renameFailed: '重新命名失敗',
             renameTitle: '重新命名工作階段',
    • apps/desktop/src/i18n/zh.ts
      @@ -1268,6 +1268,7 @@ export const zh: Translations = {
             sessionRunning: '会话运行中',
             needsInput: '需要你输入',
             waitingForAnswer: '正在等待你的回答',
      +      handoffOrigin: platform => `从 ${platform} 转接`,
             renamed: '已重命名',
             renameFailed: '重命名失败',
             renameTitle: '重命名会话',
    • apps/desktop/src/lib/session-source.ts
      @@ -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
      @@ -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
      @@ -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
      @@ -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. */

3 PRs closed without merging

From the close comments: closed without comment 1 · superseded by another PR 1 · other 1

  • #42520 fix(desktop): keep natural sidebar order until manual drag · superseded by another PR → d046169, #42537
  • #42606 fix(desktop): avoid auto-seeding sidebar session order · other
  • #42609 fix(desktop): keep new sessions in recency order · closed without comment
Closed issues · 1
  • Closed#42516Desktop: new sessions appear at bottom because sidebar order is auto-persisted on startup
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.40: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#42537
Closed issues
1
Attempts
3 PRs closed without merging
Fix link checked
9 Jun 2026

For your agent

The same facts, typed.