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
- 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
Open PRs0
Fixes on record
-
#42537 fix(desktop): local-only recents, per-platform sidebar sections, and Ctrl+N regressions
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.tsShow 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
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.00means 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 LabAt a glance
- Merged PRs
- #42537
- Closed issues
- 1
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 9 Jun 2026