{"repo":"NousResearch/hermes-agent","public_id":"42516","display_label":"Desktop sidebar session ordering and recency fixes","summary":"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.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":42537},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-09T14:24:25+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":42516,"pr_number":42537,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":42516,"title":"Desktop: new sessions appear at bottom because sidebar order is auto-persisted on startup","url":null,"state":"closed"},{"kind":"pr","number":42520,"title":"fix(desktop): keep natural sidebar order until manual drag","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d046169646b03d889bfadac3329970559b655a27","number":0},{"kind":"pr","value":"#42537","number":42537}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":42537,"title":"fix(desktop): local-only recents, per-platform sidebar sections, and Ctrl+N regressions","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"apps/desktop/src/app/chat/sidebar/index.tsx","hunks":["@@ -76,6 +76,9 @@ import {\n } from '@/store/profile'\n import {\n   $cronSessions,\n+  $messagingPlatformTotals,\n+  $messagingSessions,\n+  $messagingTruncated,\n   $selectedStoredSessionId,\n   $sessionProfileTotals,\n   $sessions,","@@ -124,7 +127,6 @@ const WORKSPACE_PAGE = 5\n // unified list scannable, then reveal/fetch more in N-sized steps on demand.\n const PROFILE_INITIAL_PAGE = 5\n const GROUP_DND_ID_PREFIX = 'group:'\n-const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui'])\n \n const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`\n ","@@ -141,24 +143,25 @@ function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[\n \n   const byId = new Map(items.map(item => [getId(item), item]))\n   const seen = new Set<string>()\n-  const out: T[] = []\n+  const ordered: T[] = []\n \n   for (const id of orderIds) {\n     const item = byId.get(id)\n \n     if (item) {\n-      out.push(item)\n+      ordered.push(item)\n       seen.add(id)\n     }\n   }\n \n-  for (const item of items) {\n-    if (!seen.has(getId(item))) {\n-      out.push(item)\n-    }\n-  }\n+  // Items missing from the persisted order are new since it was last\n+  // reconciled. Callers pass recency-sorted lists (newest first), so surface\n+  // these at the TOP instead of burying them beneath the saved order —\n+  // otherwise a brand-new session sinks to the bottom of the sidebar and reads\n+  // as \"my latest session never showed up\".\n+  const fresh = items.filter(item => !seen.has(getId(item)))\n \n-  return out\n+  return fresh.length ? [...fresh, ...ordered] : ordered\n }\n \n function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {","@@ -171,17 +174,15 @@ function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {\n   }\n \n   const current = new Set(currentIds)\n-  const next = orderIds.filter(id => current.has(id))\n-  const known = new Set(next)\n+  const retained = orderIds.filter(id => current.has(id))\n+  const retainedSet = new Set(retained)\n \n-  for (const id of currentIds) {\n-    if (!known.has(id)) {\n-      next.push(id)\n-      known.add(id)\n-    }\n-  }\n+  // New ids (absent from the saved order) are the newest sessions/groups; keep\n+  // them ahead of the persisted order so fresh activity surfaces at the top of\n+  // the sidebar rather than being appended to the bottom.\n+  const fresh = currentIds.filter(id => !retainedSet.has(id))\n \n-  return next\n+  return [...fresh, ...retained]\n }\n \n function sameIds(left: string[], right: string[]) {","@@ -251,43 +252,6 @@ function workspaceGroupsFor(\n   return [...groups.values()]\n }\n \n-function sourceSessionGroupsFor(sessions: SessionInfo[]): {\n-  localSessions: SessionInfo[]\n-  sourceGroups: SidebarSessionGroup[]\n-} {\n-  const groups = new Map<string, SidebarSessionGroup>()\n-  const localSessions: SessionInfo[] = []\n-\n-  for (const session of sessions) {\n-    const sourceId = normalizeSessionSource(session.source)\n-\n-    if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) {\n-      localSessions.push(session)\n-\n-      continue\n-    }\n-\n-    const label = sessionSourceLabel(sourceId) ?? sourceId\n-\n-    const group = groups.get(sourceId) ?? {\n-      id: `source:${sourceId}`,\n-      label,\n-      mode: 'source',\n-      path: null,\n-      sessions: [],\n-      sourceId\n-    }\n-\n-    group.sessions.push(session)\n-    groups.set(sourceId, group)\n-  }\n-\n-  return {\n-    localSessions,\n-    sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))\n-  }\n-}\n-\n function useSortableBindings(id: string) {\n   const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })\n ","@@ -309,6 +273,7 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {\n   onNavigate: (item: SidebarNavItem) => void\n   onLoadMoreSessions: () => void\n   onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void\n+  onLoadMoreMessaging?: (platform: string) => Promise<void> | void\n   onResumeSession: (sessionId: string) => void\n   onDeleteSession: (sessionId: string) => void\n   onArchiveSession: (sessionId: string) => void","@@ -322,6 +287,7 @@ export function ChatSidebar({\n   onNavigate,\n   onLoadMoreSessions,\n   onLoadMoreProfileSessions,\n+  onLoadMoreMessaging,\n   onResumeSession,\n   onDeleteSession,\n   onArchiveSession,","@@ -345,6 +311,9 @@ export function ChatSidebar({\n   const sessions = useStore($sessions)\n   const cronSessions = useStore($cronSessions)\n   const cronJobs = useStore($cronJobs)\n+  const messagingSessions = useStore($messagingSessions)\n+  const messagingPlatformTotals = useStore($messagingPlatformTotals)\n+  const messagingTruncated = useStore($messagingTruncated)\n   const sessionsLoading = useStore($sessionsLoading)\n   const sessionsTotal = useStore($sessionsTotal)\n   const sessionProfileTotals = useStore($sessionProfileTotals)","@@ -364,6 +333,8 @@ export function ChatSidebar({\n   const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])\n   const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)\n   const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})\n+  const [messagingLoadMorePending, setMessagingLoadMorePending] = useState<Record<string, boolean>>({})\n+  const [messagingOpen, setMessagingOpen] = useState<Record<string, boolean>>({})\n   const searchInputRef = useRef<HTMLInputElement>(null)\n   const trimmedQuery = searchQuery.trim()\n ","@@ -529,24 +500,12 @@ export function ChatSidebar({\n     [unpinnedAgentSessions, agentOrderIds]\n   )\n \n-  const { localSessions: localAgentSessions, sourceGroups } = useMemo(\n-    () => sourceSessionGroupsFor(agentSessions),\n-    [agentSessions]\n-  )\n-\n-  const orderedSourceGroups = useMemo(\n-    () => orderByIds(sourceGroups, g => g.id, workspaceOrderIds),\n-    [sourceGroups, workspaceOrderIds]\n-  )\n-\n+  // Recents are local-only: messaging-platform sessions are fetched as their\n+  // own slice ($messagingSessions) and rendered in self-managed per-platform\n+  // sections below, so there is no source-grouping magic to untangle here.\n   const agentGroups = useMemo(\n-    () =>\n-      orderByIds(\n-        workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }),\n-        g => g.id,\n-        workspaceOrderIds\n-      ),\n-    [localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds]\n+    () => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),\n+    [agentSessions, s.noWorkspace, workspaceOrderIds]\n   )\n \n   const loadMoreForProfileGroup = useCallback(","@@ -564,6 +523,64 @@ export function ChatSidebar({\n     [onLoadMoreProfileSessions]\n   )\n \n+  const loadMoreForMessaging = useCallback(\n+    (platform: string) => {\n+      if (!onLoadMoreMessaging) {\n+        return\n+      }\n+\n+      setMessagingLoadMorePending(prev => ({ ...prev, [platform]: true }))\n+\n+      void Promise.resolve(onLoadMoreMessaging(platform))\n+        .catch(() => undefined)\n+        .finally(() => setMessagingLoadMorePending(({ [platform]: _done, ...rest }) => rest))\n+    },\n+    [onLoadMoreMessaging]\n+  )\n+\n+  // Each messaging platform is its own self-managed section: split the\n+  // separately-fetched messaging slice by source, newest platform first, rows\n+  // within a platform by recency. Per-platform totals (when a \"load more\" has\n+  // resolved them) drive the count + whether more remain on disk.\n+  const messagingGroups = useMemo<MessagingSection[]>(() => {\n+    if (!messagingSessions.length) {\n+      return []\n+    }\n+\n+    const bySource = new Map<string, SessionInfo[]>()\n+\n+    for (const session of messagingSessions) {\n+      const sourceId = normalizeSessionSource(session.source)\n+\n+      if (!sourceId) {\n+        continue\n+      }\n+\n+      const list = bySource.get(sourceId) ?? []\n+      list.push(session)\n+      bySource.set(sourceId, list)\n+    }\n+\n+    return [...bySource.entries()]\n+      .map(([sourceId, list]) => {\n+        const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a))\n+        const known = messagingPlatformTotals[sourceId]\n+        const total = Math.max(ordered.length, known ?? 0)\n+\n+        return {\n+          // Known exact total → more exist iff total exceeds loaded; otherwise\n+          // the seed fetch was capped, so assume more until a per-platform load\n+          // resolves the count.\n+          hasMore: known != null ? known > ordered.length : messagingTruncated,\n+          label: sessionSourceLabel(sourceId) ?? sourceId,\n+          sessions: ordered,\n+          sourceId,\n+          total\n+        }\n+      })\n+      .sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))\n+  }, [messagingSessions, messagingPlatformTotals, messagingTruncated])\n+\n   // ALL-profiles view: one collapsible group per profile, color on the header\n   // (not on every row). Default profile floats to the top, the rest alpha.\n   const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {","@@ -610,37 +627,28 @@ export function ChatSidebar({\n     sessionProfileTotals\n   ])\n \n-  const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions\n-\n-  const displayAgentGroups = useMemo(() => {\n-    if (orderedSourceGroups.length) {\n-      const localGroups = agentsGrouped\n-        ? agentGroups\n-        : localAgentSessions.length\n-          ? [\n-              {\n-                id: 'local-sessions',\n-                label: 'Local',\n-                mode: 'workspace' as const,\n-                path: null,\n-                sessions: localAgentSessions\n-              }\n-            ]\n-          : []\n+  const displayAgentSessions = agentSessions\n \n-      return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds)\n-    }\n+  // Pagination is scope-aware. In \"All profiles\" mode it tracks the global\n+  // unified set. When scoped to one profile it must compare that profile's own\n+  // loaded rows against that profile's total — otherwise a huge default profile\n+  // keeps \"Load more\" stuck on while you browse a small one (the aggregator's\n+  // total sums every profile). Per-profile totals come from the aggregator\n+  // (children excluded); fall back to the global total / loaded count.\n+  const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length\n+  const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]\n \n-    return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined\n-  }, [\n-    agentGroups,\n-    agentsGrouped,\n-    localAgentSessions,\n-    orderedSourceGroups,\n-    profileGroups,\n-    showAllProfiles,\n-    workspaceOrderIds\n-  ])\n+  const knownSessionTotal = Math.max(\n+    showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),\n+    loadedSessionCount\n+  )\n+\n+  const hasMoreSessions = knownSessionTotal > loadedSessionCount\n+  const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)\n+\n+  const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)\n+\n+  const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined\n \n   useEffect(() => {\n     if (!displayAgentGroups?.length || showAllProfiles) {","@@ -661,25 +669,6 @@ export function ChatSidebar({\n \n   const showSessionSections = showSessionSkeletons || sortedSessions.length > 0\n \n-  // Pagination is scope-aware. In \"All profiles\" mode it tracks the global\n-  // unified set. When scoped to one profile it must compare that profile's own\n-  // loaded rows against that profile's total — otherwise a huge default profile\n-  // keeps \"Load more\" stuck on while you browse a small one (the aggregator's\n-  // total sums every profile). Per-profile totals come from the aggregator\n-  // (children excluded); fall back to the global total / loaded count.\n-  const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length\n-  const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]\n-\n-  const knownSessionTotal = Math.max(\n-    showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),\n-    loadedSessionCount\n-  )\n-\n-  const hasMoreSessions = knownSessionTotal > loadedSessionCount\n-  const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)\n-\n-  const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)\n-\n   const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {\n     if (!over || active.id === over.id) {\n       return","@@ -902,7 +891,7 @@ export function ChatSidebar({\n               // the toggle does nothing, and it's irrelevant in the ALL-profiles\n               // view (always grouped by profile), so hide the button (not the slot).\n               <div className=\"grid size-6 shrink-0 place-items-center\">\n-                {!showAllProfiles && localAgentSessions.length > 0 ? (\n+                {!showAllProfiles && agentSessions.length > 0 ? (\n                   <Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>\n                     <Button\n                       aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}","@@ -942,6 +931,46 @@ export function ChatSidebar({\n           />\n         )}\n \n+        {contentVisible && showSessionSections && !trimmedQuery &&\n+          messagingGroups.map(group => (\n+            <SidebarSessionsSection\n+              activeSessionId={activeSidebarSessionId}\n+              contentClassName=\"flex max-h-56 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75\"\n+              emptyState={null}\n+              footer={\n+                group.hasMore ? (\n+                  <SidebarLoadMoreRow\n+                    loading={Boolean(messagingLoadMorePending[group.sourceId])}\n+                    onClick={() => loadMoreForMessaging(group.sourceId)}\n+                    step={Math.max(0, group.total - group.sessions.length)}\n+                  />\n+                ) : null\n+              }\n+              key={group.sourceId}\n+              label={group.label}\n+              labelIcon={\n+                <PlatformAvatar\n+                  className=\"size-4 rounded-[4px] text-[0.5625rem] [&_svg]:size-3\"\n+                  platformId={group.sourceId}\n+                  platformName={group.label}\n+                />\n+              }\n+              labelMeta={countLabel(group.sessions.length, group.total)}\n+              onArchiveSession={onArchiveSession}\n+              onDeleteSession={onDeleteSession}\n+              onResumeSession={onResumeSession}\n+              onToggle={() =>\n+                setMessagingOpen(prev => ({ ...prev, [group.sourceId]: prev[group.sourceId] === false }))\n+              }\n+              onTogglePin={pinSession}\n+              open={messagingOpen[group.sourceId] !== false}\n+              pinned={false}\n+              rootClassName=\"shrink-0 p-0\"\n+              sessions={group.sessions}\n+              workingSessionIdSet={workingSessionIdSet}\n+            />\n+          ))}\n+\n         {contentVisible && !trimmedQuery && cronJobs.length > 0 && (\n           <SidebarCronJobsSection\n             jobs={cronJobs}","@@ -972,9 +1001,10 @@ interface SidebarSectionHeaderProps {\n   onToggle: () => void\n   action?: React.ReactNode\n   meta?: React.ReactNode\n+  icon?: React.ReactNode\n }\n \n-function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {\n+function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: SidebarSectionHeaderProps) {\n   return (\n     <div className=\"group/section flex shrink-0 items-center justify-between pb-1 pt-1.5\">\n       <button","@@ -982,6 +1012,7 @@ function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSe\n         onClick={onToggle}\n         type=\"button\"\n       >\n+        {icon}\n         <SidebarPanelLabel>{label}</SidebarPanelLabel>\n         {meta && <SidebarCount>{meta}</SidebarCount>}\n         <DisclosureCaret","@@ -1044,6 +1075,14 @@ interface SidebarSessionGroup {\n   totalCount?: number\n }\n \n+interface MessagingSection {\n+  sourceId: string\n+  label: string\n+  sessions: SessionInfo[]\n+  total: number\n+  hasMore: boolean\n+}\n+\n interface SidebarSessionsSectionProps {\n   label: string\n   open: boolean","@@ -1065,6 +1104,7 @@ interface SidebarSessionsSectionProps {\n   footer?: React.ReactNode\n   groups?: SidebarSessionGroup[]\n   labelMeta?: React.ReactNode\n+  labelIcon?: React.ReactNode\n   sortable?: boolean\n   onReorder?: (event: DragEndEvent) => void\n   dndSensors?: ReturnType<typeof useSensors>","@@ -1091,6 +1131,7 @@ function SidebarSessionsSection({\n   footer,\n   groups,\n   labelMeta,\n+  labelIcon,\n   sortable = false,\n   onReorder,\n   dndSensors","@@ -1209,7 +1250,14 @@ function SidebarSessionsSection({\n \n   return (\n     <SidebarGroup className={rootClassName}>\n-      <SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />\n+      <SidebarSectionHeader\n+        action={headerAction}\n+        icon={labelIcon}\n+        label={label}\n+        meta={labelMeta}\n+        onToggle={onToggle}\n+        open={open}\n+      />\n       {open && (\n         <SidebarGroupContent className={resolvedContentClassName}>\n           {body}"]},{"file":"apps/desktop/src/app/chat/sidebar/session-row.tsx","hunks":["@@ -2,12 +2,15 @@ import { useStore } from '@nanostores/react'\n import type * as React from 'react'\n \n import { writeSessionDrag } from '@/app/chat/composer/inline-refs'\n+import { PlatformAvatar } from '@/app/messaging/platform-icon'\n import { Button } from '@/components/ui/button'\n import { Codicon } from '@/components/ui/codicon'\n+import { Tip } from '@/components/ui/tooltip'\n import type { SessionInfo } from '@/hermes'\n import { type Translations, useI18n } from '@/i18n'\n import { sessionTitle } from '@/lib/chat-runtime'\n import { triggerHaptic } from '@/lib/haptics'\n+import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'\n import { cn } from '@/lib/utils'\n import { $attentionSessionIds } from '@/store/session'\n ","@@ -67,6 +70,11 @@ export function SidebarSessionRow({\n   const title = sessionTitle(session)\n   const age = formatAge(session.last_active || session.started_at, r)\n   const handleLabel = `Reorder ${title}`\n+  // A handed-off session's live source is local, but it originated on a\n+  // messaging platform — surface that origin as a small badge so e.g. a\n+  // Telegram thread continued here still reads as Telegram.\n+  const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)\n+  const handoffLabel = handoffSource ? sessionSourceLabel(handoffSource) ?? handoffSource : null\n   // Subscribe per-row (the leaf) instead of drilling a set through the list —\n   // the atom is tiny and rarely non-empty. True when a clarify prompt in this\n   // session is waiting on the user.","@@ -179,6 +187,15 @@ export function SidebarSessionRow({\n               <SidebarRowDot isWorking={isWorking} needsInput={needsInput} />\n             </span>\n           )}\n+          {handoffSource && handoffLabel ? (\n+            <Tip label={r.handoffOrigin(handoffLabel)}>\n+              <PlatformAvatar\n+                className=\"size-4 rounded-[4px] text-[0.5rem] [&_svg]:size-2.5\"\n+                platformId={handoffSource}\n+                platformName={handoffLabel}\n+              />\n+            </Tip>\n+          ) : null}\n           <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\">\n             {title}\n           </span>"]},{"file":"apps/desktop/src/app/desktop-controller.tsx","hunks":["@@ -14,6 +14,12 @@ import { useSkinCommand } from '@/themes/use-skin-command'\n import { formatRefValue } from '../components/assistant-ui/directive-text'\n import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'\n import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'\n+import {\n+  isMessagingSource,\n+  LOCAL_SESSION_SOURCE_IDS,\n+  MESSAGING_SESSION_SOURCE_IDS,\n+  normalizeSessionSource\n+} from '../lib/session-source'\n import { setCronFocusJobId, setCronJobs } from '../store/cron'\n import {\n   $panesFlipped,","@@ -46,10 +52,12 @@ import {\n   $gatewayState,\n   $selectedStoredSessionId,\n   $sessions,\n+  $messagingSessions,\n   $workingSessionIds,\n   CRON_SECTION_LIMIT,\n   getRecentlySettledSessionIds,\n   mergeSessionPage,\n+  MESSAGING_SECTION_LIMIT,\n   sessionPinId,\n   setAwaitingResponse,\n   setBusy,","@@ -59,6 +67,9 @@ import {\n   setCurrentModel,\n   setCurrentProvider,\n   setMessages,\n+  setMessagingPlatformTotals,\n+  setMessagingSessions,\n+  setMessagingTruncated,\n   setSessionProfileTotals,\n   setSessions,\n   setSessionsLoading,","@@ -121,6 +132,15 @@ const SkillsView = lazy(async () => ({ default: (await import('./skills')).Skill\n // this cadence while the app is open + visible so new runs surface promptly\n // instead of waiting for the next user-triggered refreshSessions().\n const CRON_POLL_INTERVAL_MS = 30_000\n+// The recents list is local-only: cron rows have their own section, and each\n+// messaging platform (telegram, discord, …) is fetched separately into its own\n+// self-managed sidebar section (refreshMessagingSessions). Excluding both here\n+// keeps \"Load more\" paging through interactive local chats instead of\n+// interleaving gateway threads that bury them.\n+const SIDEBAR_EXCLUDED_SOURCES = ['cron', ...MESSAGING_SESSION_SOURCE_IDS]\n+// The messaging slice is the inverse: drop cron + every local source so only\n+// external-platform conversations remain, then split per platform in the UI.\n+const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]\n \n // Cheap signature compare so the poll only swaps the atom (and re-renders the\n // sidebar) when the visible cron rows actually changed.","@@ -280,6 +300,51 @@ export function DesktopController() {\n     }\n   }, [])\n \n+  // Messaging-platform sessions as their own slice, fetched separately from\n+  // local recents so each platform renders a self-managed section and never\n+  // competes with local chats for the recents page budget. One combined fetch\n+  // seeds every platform; the sidebar splits the rows per source.\n+  const refreshMessagingSessions = useCallback(async () => {\n+    try {\n+      const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {\n+        excludeSources: MESSAGING_EXCLUDED_SOURCES\n+      })\n+\n+      // Drop any non-messaging source the broad exclude didn't catch (custom\n+      // sources) — those stay in local recents, not a platform section.\n+      const rows = result.sessions.filter(s => isMessagingSource(s.source))\n+\n+      setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows))\n+      // Hit the cap → at least one platform may have more on disk than loaded,\n+      // so platform sections offer their own per-platform \"load more\".\n+      setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT)\n+    } catch {\n+      // Non-fatal: the messaging sections just stay empty/stale.\n+    }\n+  }, [])\n+\n+  // Page a single platform's section independently (mirrors the per-profile\n+  // pager): fetch that source's next window and merge it back in place, leaving\n+  // every other platform's rows untouched. Resolves the platform's exact total.\n+  const loadMoreMessagingForPlatform = useCallback(async (platform: string) => {\n+    const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform\n+    const loaded = $messagingSessions.get().filter(inPlatform).length\n+\n+    const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', {\n+      source: platform\n+    })\n+\n+    const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform)\n+\n+    setMessagingSessions(prev => [\n+      ...prev.filter(s => !inPlatform(s)),\n+      ...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep())\n+    ])\n+\n+    const total = result.total ?? incoming.length\n+    setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) }))\n+  }, [])\n+\n   // Cron *jobs* drive the sidebar \"Cron jobs\" section. Jobs are created\n   // synchronously (agent tool call or the cron UI), so refreshing here right\n   // after an agent turn surfaces a new job immediately; the interval poll keeps","@@ -316,7 +381,7 @@ export function DesktopController() {\n       const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope\n \n       const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {\n-        excludeSources: ['cron']\n+        excludeSources: SIDEBAR_EXCLUDED_SOURCES\n       })\n \n       if (refreshSessionsRequestRef.current === requestId) {","@@ -332,7 +397,8 @@ export function DesktopController() {\n \n     void refreshCronSessions()\n     void refreshCronJobs()\n-  }, [profileScope, refreshCronSessions, refreshCronJobs])\n+    void refreshMessagingSessions()\n+  }, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])\n \n   const loadMoreSessions = useCallback(() => {\n     bumpSessionsLimit()","@@ -347,7 +413,7 @@ export function DesktopController() {\n     const loaded = $sessions.get().filter(inKey).length\n \n     const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {\n-      excludeSources: ['cron']\n+      excludeSources: SIDEBAR_EXCLUDED_SOURCES\n     })\n \n     const keep = sessionsToKeep(key)","@@ -704,6 +770,7 @@ export function DesktopController() {\n       currentView={currentView}\n       onArchiveSession={sessionId => void archiveSession(sessionId)}\n       onDeleteSession={sessionId => void removeSession(sessionId)}\n+      onLoadMoreMessaging={loadMoreMessagingForPlatform}\n       onLoadMoreProfileSessions={loadMoreSessionsForProfile}\n       onLoadMoreSessions={loadMoreSessions}\n       onManageCronJob={jobId => {"]},{"file":"apps/desktop/src/app/hooks/use-keybinds.ts","hunks":["@@ -18,6 +18,7 @@ import {\n   toggleSidebarOpen\n } from '@/store/layout'\n import {\n+  $newChatProfile,\n   cycleProfile,\n   requestProfileCreate,\n   switchProfileToSlot,","@@ -106,6 +107,10 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {\n     'nav.agents': () => navigate(AGENTS_ROUTE),\n \n     'session.new': () => {\n+      // Match the sidebar New Session button. A plain keyboard new chat should\n+      // target the current live profile, not a stale per-profile quick-create\n+      // selection from a prior action.\n+      $newChatProfile.set(null)\n       deps.startFreshSession()\n       window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))\n     },"]},{"file":"apps/desktop/src/app/session/hooks/use-route-resume.test.tsx","hunks":["@@ -84,6 +84,60 @@ describe('useRouteResume', () => {\n     expect(resumeSession).not.toHaveBeenCalled()\n   })\n \n+  it('self-heals a stranded routed session (null selected/active, same pathname, not a fresh draft)', () => {\n+    const resumeSession = vi.fn(async () => undefined)\n+    const startFreshSessionDraft = vi.fn()\n+    const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }\n+    const creatingSessionRef = { current: false }\n+    const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }\n+    const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }\n+\n+    const { rerender } = render(\n+      <RouteResumeHarness\n+        activeSessionId=\"runtime-1\"\n+        activeSessionIdRef={activeSessionIdRef}\n+        creatingSessionRef={creatingSessionRef}\n+        currentView=\"chat\"\n+        freshDraftReady={false}\n+        gatewayState=\"open\"\n+        locationPathname=\"/session-1\"\n+        resumeSession={resumeSession}\n+        routedSessionId=\"session-1\"\n+        runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}\n+        selectedStoredSessionId=\"session-1\"\n+        selectedStoredSessionIdRef={selectedStoredSessionIdRef}\n+        startFreshSessionDraft={startFreshSessionDraft}\n+      />\n+    )\n+\n+    expect(resumeSession).not.toHaveBeenCalled()\n+\n+    // A create/stream race nulls selected/active but the route stays on the\n+    // session and freshDraftReady is false (NOT a new-chat transition).\n+    activeSessionIdRef.current = null\n+    selectedStoredSessionIdRef.current = null\n+    rerender(\n+      <RouteResumeHarness\n+        activeSessionId={null}\n+        activeSessionIdRef={activeSessionIdRef}\n+        creatingSessionRef={creatingSessionRef}\n+        currentView=\"chat\"\n+        freshDraftReady={false}\n+        gatewayState=\"open\"\n+        locationPathname=\"/session-1\"\n+        resumeSession={resumeSession}\n+        routedSessionId=\"session-1\"\n+        runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}\n+        selectedStoredSessionId={null}\n+        selectedStoredSessionIdRef={selectedStoredSessionIdRef}\n+        startFreshSessionDraft={startFreshSessionDraft}\n+      />\n+    )\n+\n+    expect(resumeSession).toHaveBeenCalledTimes(1)\n+    expect(resumeSession).toHaveBeenCalledWith('session-1', true)\n+  })\n+\n   it('resumes when pathname changes to a routed session', () => {\n     const resumeSession = vi.fn(async () => undefined)\n     const startFreshSessionDraft = vi.fn()"]},{"file":"apps/desktop/src/app/session/hooks/use-route-resume.ts","hunks":["@@ -77,10 +77,27 @@ export function useRouteResume({\n         Boolean(cachedRuntime) &&\n         cachedRuntime === activeSessionIdRef.current\n \n-      // Resume only when the route meaningfully changed (or gateway just opened).\n-      // This avoids a transient /:sid re-resume during \"new chat\" state clears\n+      // Self-heal a desynced view: the route points at a session that isn't the\n+      // loaded one. A create/stream race can leave selected/active null while\n+      // the route stays on /:sid (symptom: brand-new chat shows \"Thinking\" then\n+      // an empty transcript even though the turn completed and persisted). The\n+      // pathname didn't change, so the normal gate would skip and the view stays\n+      // stuck empty forever. selectedStoredSessionIdRef is set synchronously at\n+      // resume entry, so this can't loop; the resume's cached fast-path restores\n+      // the already-streamed messages without a refetch.\n+      //\n+      // Crucially this must NOT fire during a /:sid -> /new transition, where\n+      // startFreshSessionDraft nulls selected/active one render before the\n+      // pathname flips to / (same null+/:sid signature). freshDraftReady is the\n+      // discriminator: it's true while heading into a blank new chat, false when\n+      // genuinely stranded on a routed session.\n+      const stuckOnRoutedSession = routedSessionId !== selectedStoredSessionIdRef.current && !freshDraftReady\n+\n+      // Resume when the route meaningfully changed, the gateway just opened, or\n+      // we're stranded on a routed session that never loaded. The first two\n+      // guard against a transient /:sid re-resume during \"new chat\" state clears\n       // before the pathname updates from /:sid -> /.\n-      const shouldResume = pathnameChanged || gatewayBecameOpen\n+      const shouldResume = pathnameChanged || gatewayBecameOpen || stuckOnRoutedSession\n \n       if (!alreadyActive && shouldResume && !creatingSessionRef.current) {\n         void resumeSession(routedSessionId, true)"]},{"file":"apps/desktop/src/app/session/hooks/use-session-state-cache.ts","hunks":["@@ -150,6 +150,29 @@ export function useSessionStateCache({\n \n       pendingViewStateRef.current = { sessionId, state }\n \n+      // Terminal / attention transitions (turn finished, error, or the agent is\n+      // now waiting on the user) MUST reach the view immediately. Electron\n+      // throttles `requestAnimationFrame` to ~0 while the window is\n+      // backgrounded, occluded, or unfocused, so an RAF-deferred flush can be\n+      // stranded in `pendingViewStateRef` indefinitely — that's the \"new chat\n+      // stuck on Thinking until I refocus / F5\" bug. Flush these synchronously\n+      // (cancelling any in-flight RAF, since we're about to publish the latest\n+      // state anyway). The plain busy heartbeat stays RAF-batched: that\n+      // coalescing exists only to keep periodic `session.info` updates from\n+      // churning `$messages` and jerking the scroll position while reading.\n+      const isCriticalTransition = !state.busy || state.needsInput\n+\n+      if (isCriticalTransition) {\n+        if (viewSyncRafRef.current !== null && typeof window !== 'undefined') {\n+          window.cancelAnimationFrame(viewSyncRafRef.current)\n+          viewSyncRafRef.current = null\n+        }\n+\n+        flushPendingViewState()\n+\n+        return\n+      }\n+\n       if (viewSyncRafRef.current !== null) {\n         return\n       }"]},{"file":"apps/desktop/src/components/assistant-ui/markdown-text.tsx","hunks":["@@ -494,11 +494,9 @@ export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: Markdo\n \n const MarkdownTextImpl = () => {\n   return (\n-    <SmoothStreamingText>\n-      <DeferStreamingText>\n-        <MarkdownTextSurface />\n-      </DeferStreamingText>\n-    </SmoothStreamingText>\n+    <DeferStreamingText>\n+      <MarkdownTextSurface />\n+    </DeferStreamingText>\n   )\n }\n "]},{"file":"apps/desktop/src/i18n/en.ts","hunks":["@@ -1081,6 +1081,7 @@ export const en: Translations = {\n       sessionRunning: 'Session running',\n       needsInput: 'Needs your input',\n       waitingForAnswer: 'Waiting for your answer',\n+      handoffOrigin: platform => `Handed off from ${platform}`,\n       renamed: 'Renamed',\n       renameFailed: 'Rename failed',\n       renameTitle: 'Rename session',"]},{"file":"apps/desktop/src/i18n/ja.ts","hunks":["@@ -1224,6 +1224,7 @@ export const ja = defineLocale({\n       sessionRunning: 'セッション実行中',\n       needsInput: '入力が必要です',\n       waitingForAnswer: '回答を待っています',\n+      handoffOrigin: platform => `${platform} から引き継ぎ`,\n       renamed: '名前を変更しました',\n       renameFailed: '名前の変更に失敗しました',\n       renameTitle: 'セッションの名前を変更',"]},{"file":"apps/desktop/src/i18n/types.ts","hunks":["@@ -838,6 +838,7 @@ export interface Translations {\n       sessionRunning: string\n       needsInput: string\n       waitingForAnswer: string\n+      handoffOrigin: (platform: string) => string\n       renamed: string\n       renameFailed: string\n       renameTitle: string"]},{"file":"apps/desktop/src/i18n/zh-hant.ts","hunks":["@@ -1190,6 +1190,7 @@ export const zhHant = defineLocale({\n       sessionRunning: '工作階段執行中',\n       needsInput: '需要您的輸入',\n       waitingForAnswer: '等待您的回答',\n+      handoffOrigin: platform => `從 ${platform} 轉接`,\n       renamed: '已重新命名',\n       renameFailed: '重新命名失敗',\n       renameTitle: '重新命名工作階段',"]},{"file":"apps/desktop/src/i18n/zh.ts","hunks":["@@ -1268,6 +1268,7 @@ export const zh: Translations = {\n       sessionRunning: '会话运行中',\n       needsInput: '需要你输入',\n       waitingForAnswer: '正在等待你的回答',\n+      handoffOrigin: platform => `从 ${platform} 转接`,\n       renamed: '已重命名',\n       renameFailed: '重命名失败',\n       renameTitle: '重命名会话',"]},{"file":"apps/desktop/src/lib/session-source.ts","hunks":["@@ -34,12 +34,76 @@ const SOURCE_ALIASES: Record<string, string[]> = {\n   whatsapp: ['wa']\n }\n \n+// Sources that run on the local machine rather than an external messaging\n+// platform. A handoff *from* one of these isn't a platform origin worth a badge.\n+// Exported so the recents fetch can keep these in the main list while the\n+// messaging fetch excludes them.\n+export const LOCAL_SESSION_SOURCE_IDS = ['cli', 'codex', 'desktop', 'gateway', 'local', 'tui']\n+const LOCAL_SOURCE_IDS = new Set(LOCAL_SESSION_SOURCE_IDS)\n+\n+// External messaging platforms that each get their own self-managed sidebar\n+// section (fetched separately from local recents). Mirrors the gateway platform\n+// adapters; keep in sync with PLATFORM_ICONS in app/messaging/platform-icon.tsx.\n+export const MESSAGING_SESSION_SOURCE_IDS = [\n+  'telegram',\n+  'discord',\n+  'slack',\n+  'mattermost',\n+  'matrix',\n+  'signal',\n+  'whatsapp',\n+  'bluebubbles',\n+  'homeassistant',\n+  'email',\n+  'sms',\n+  'webhook',\n+  'api_server',\n+  'weixin',\n+  'wecom',\n+  'qqbot',\n+  'yuanbao',\n+  'dingtalk',\n+  'feishu'\n+]\n+const MESSAGING_SOURCE_IDS = new Set(MESSAGING_SESSION_SOURCE_IDS)\n+\n+/** True when a source id is an external messaging platform (gets its own\n+ *  sidebar section) rather than a local/CLI/desktop session. */\n+export function isMessagingSource(source: null | string | undefined): boolean {\n+  const id = normalizeSessionSource(source)\n+\n+  return id != null && MESSAGING_SOURCE_IDS.has(id)\n+}\n+\n export function normalizeSessionSource(source: null | string | undefined): string | null {\n   const id = source?.trim().toLowerCase()\n \n   return id || null\n }\n \n+/**\n+ * Resolve the origin messaging platform for a handed-off session. Returns the\n+ * normalized platform id (e.g. 'telegram') when the session completed a handoff\n+ * from a real messaging platform, otherwise null. After a handoff the live\n+ * source is local, so this is what drives the row's origin-platform badge.\n+ */\n+export function handoffOriginSource(\n+  handoffState: null | string | undefined,\n+  handoffPlatform: null | string | undefined\n+): string | null {\n+  if (handoffState !== 'completed') {\n+    return null\n+  }\n+\n+  const id = normalizeSessionSource(handoffPlatform)\n+\n+  if (!id || LOCAL_SOURCE_IDS.has(id)) {\n+    return null\n+  }\n+\n+  return id\n+}\n+\n export function sessionSourceLabel(source: null | string | undefined): string | null {\n   const id = normalizeSessionSource(source)\n "]},{"file":"apps/desktop/src/store/session.ts","hunks":["@@ -85,6 +85,20 @@ export const $cronSessions = atom<SessionInfo[]>([])\n // badge renders \"N+\". Lives here so the controller (fetch) and sidebar (badge)\n // share one source of truth without a circular import.\n export const CRON_SECTION_LIMIT = 50\n+// Messaging-platform sessions (telegram/discord/...) are fetched as their own\n+// slice — separate from local recents — so each platform renders a\n+// self-managed sidebar section and never interleaves with (or buries) local\n+// chats in the recents page. One combined fetch seeds every platform; a\n+// platform that exceeds this cap gets its own per-platform \"load more\".\n+export const $messagingSessions = atom<SessionInfo[]>([])\n+export const MESSAGING_SECTION_LIMIT = 100\n+// Exact per-platform conversation totals, keyed by source id. Empty until a\n+// per-platform \"load more\" fetch resolves it (the combined seed fetch only\n+// knows the aggregate), so sections fall back to their loaded count.\n+export const $messagingPlatformTotals = atom<Record<string, number>>({})\n+// True when the combined seed fetch hit MESSAGING_SECTION_LIMIT, so at least\n+// one platform may have more rows on disk than were loaded.\n+export const $messagingTruncated = atom<boolean>(false)\n // Listable conversation count per profile (children excluded), keyed by profile\n // name. Lets the sidebar scope its \"Load more\" footer to the active profile so a\n // 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\n export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)\n export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)\n export const setCronSessions = (next: Updater<SessionInfo[]>) => updateAtom($cronSessions, next)\n+export const setMessagingSessions = (next: Updater<SessionInfo[]>) => updateAtom($messagingSessions, next)\n+export const setMessagingPlatformTotals = (next: Updater<Record<string, number>>) =>\n+  updateAtom($messagingPlatformTotals, next)\n+export const setMessagingTruncated = (next: Updater<boolean>) => updateAtom($messagingTruncated, next)\n export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =>\n   updateAtom($sessionProfileTotals, next)\n export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)"]},{"file":"apps/desktop/src/types/hermes.ts","hunks":["@@ -297,6 +297,14 @@ export interface SessionInfo {\n   started_at: number\n   title: null | string\n   tool_call_count: number\n+  /** Origin platform when this session was handed off from a messaging\n+   *  platform (e.g. a Telegram thread continued in the desktop app). The live\n+   *  {@link source} becomes local (tui/desktop) after a handoff, so the origin\n+   *  is preserved here to surface the platform badge on the row. */\n+  handoff_platform?: null | string\n+  /** Handoff lifecycle: 'pending' | 'in_progress' | 'completed' | 'failed'. */\n+  handoff_state?: null | string\n+  handoff_error?: null | string\n   /** Owning profile name, set by the cross-profile aggregator\n    *  (`/api/profiles/sessions`). Absent on legacy single-profile responses,\n    *  which the UI treats as the default profile. */\n"]}]},{"kind":"pr","number":42606,"title":"fix(desktop): avoid auto-seeding sidebar session order","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":42609,"title":"fix(desktop): keep new sessions in recency order","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}