Desktop sidebar section header overlap fix
Cluster 42987 · apps/desktop. Data as of
Nothing open on GitHub. Fix merged in #43147 on 10 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #43147. 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.
These issues and pull requests address a bug where section headers in the desktop sidebar can overlap each other. The work involves CSS and layout changes to prevent this overlap behavior.
Open issues0
Open PRs0
Fixes on record
-
#43147 fix(desktop): sidebar sections never overlap — two-mode CSS scroll + collapse/cap groups
files:
apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx,apps/desktop/src/app/chat/sidebar/index.tsx,apps/desktop/src/app/chat/sidebar/load-more-row.tsx,apps/desktop/src/store/layout.ts,apps/desktop/src/styles.cssShow the diff
-
apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx@@ -14,6 +14,8 @@ import type { CronJob } from '@/types/hermes' import { jobState, jobTitle, STATE_DOT } from '../../cron/job-state' import { SidebarPanelLabel } from '../../shell/sidebar-label' +import { SidebarLoadMoreRow } from './load-more-row' + const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused']) // Recent runs shown in the inline quick-peek — enough to glance at history @@ -24,6 +26,11 @@ const PEEK_RUN_LIMIT = 5 // open peek so a freshly-fired run shows up within a few seconds. const PEEK_POLL_INTERVAL_MS = 8000 +// Keep the section compact: show a few jobs up front, reveal more in larger +// steps on demand (mirrors the messaging sections in the sidebar). +const INITIAL_VISIBLE_JOBS = 3 +const LOAD_MORE_STEP = 10 + const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' }) // Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the @@ -33,17 +40,25 @@ function relativeTime(targetMs: number, nowMs: number): string { const abs = Math.abs(diff) const sign = diff < 0 ? -1 : 1 - if (abs < 60_000) {return relativeFmt.format(sign * Math.round(abs / 1000), 'second')} + if (abs < 60_000) { + return relativeFmt.format(sign * Math.round(abs / 1000), 'second') + } - if (abs < 3_600_000) {return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')} + if (abs < 3_600_000) { + return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute') + } - if (abs < 86_400_000) {return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')} + if (abs < 86_400_000) { + return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour') + } return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day') } function nextRunMs(job: CronJob): null | number { - if (!job.next_run_at) {return null} + if (!job.next_run_at) { + return null + } const ms = Date.parse(job.next_run_at) @@ -54,7 +69,9 @@ function nextRunMs(job: CronJob): null | number { // the timestamp is what tells them apart. Compact (no year, no seconds) for the // narrow sidebar. function formatRunTime(seconds?: null | number): string { - if (!seconds) {return '—'} + if (!seconds) { + return '—' + } const date = new Date(seconds * 1000) @@ -90,11 +107,15 @@ export function SidebarCronJobsSection({ const [nowMs, setNowMs] = useState(() => Date.now()) // Single-open inline peek so the section stays scannable. const [peekJobId, setPeekJobId] = useState<null | string>(null) + // Rows revealed so far; starts compact, grows in steps via "load more". + const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_JOBS) // One clock for the whole section (rows are pure) so the countdowns tick // without re-rendering the rest of the sidebar. Only runs while expanded. useEffect(() => { - if (!open) {return} + if (!open) { + return + } const id = window.setInterval(() => setNowMs(Date.now()), 1000) @@ -108,17 +129,25 @@ export function SidebarCronJobsSection({ const an = nextRunMs(a) const bn = nextRunMs(b) - if (an !== null && bn !== null && an !== bn) {return an - bn} + if (an !== null && bn !== null && an !== bn) { + return an - bn + } - if (an === null && bn !== null) {return 1} + if (an === null && bn !== null) { + return 1 + } - if (an !== null && bn === null) {return -1} + if (an !== null && bn === null) { + return -1 + } return jobTitle(a).localeCompare(jobTitle(b)) }) }, [jobs]) - const shown = sorted.slice(0, max) + const cap = Math.min(visibleCount, max) + const shown = sorted.slice(0, cap) + const hiddenCount = Math.min(sorted.length, max) - shown.length // When capped, signal "50+" rather than implying the list is complete. const countLabel = jobs.length > max ? `${max}+` : String(jobs.length) @@ -139,7 +168,7 @@ export function SidebarCronJobsSection({ </button> </div> {open && ( - <SidebarGroupContent className="flex max-h-72 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"> + <SidebarGroupContent className="flex max-h-72 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible"> {shown.map(job => ( <CronJobSidebarRow expanded={peekJobId === job.id} @@ -152,6 +181,12 @@ export function SidebarCronJobsSection({ onTrigger={() => onTriggerJob(job.id)} /> ))} + {hiddenCount > 0 && ( + <SidebarLoadMoreRow + onClick={() => setVisibleCount(count => count + LOAD_MORE_STEP)} + step={Math.min(LOAD_MORE_STEP, hiddenCount)} + /> + )} </SidebarGroupContent> )} </SidebarGroup> @@ -181,11 +216,7 @@ function CronJobSidebarRow({ const next = nextRunMs(job) const label = jobTitle(job) - const meta = INACTIVE_STATES.has(state) - ? (c.states[state] ?? state) - : next !== null - ? relativeTime(next, nowMs) - : '—' + const meta = INACTIVE_STATES.has(state) ? (c.states[state] ?? state) : next !== null ? relativeTime(next, nowMs) : '—' return ( <div> @@ -257,13 +288,7 @@ function CronJobSidebarRow({ ) } -function CronJobSidebarRuns({ - jobId, - onOpenRun -}: { - jobId: string - onOpenRun: (sessionId: string) => void -}) { +function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (sessionId: string) => void }) { const { t } = useI18n() const c = t.cron const selectedSessionId = useStore($selectedStoredSessionId) @@ -275,16 +300,22 @@ function CronJobSidebarRuns({ const load = () => getCronJobRuns(jobId, PEEK_RUN_LIMIT) .then(result => { - if (!cancelled) {setRuns(result)} + if (!cancelled) { + setRuns(result) + } }) .catch(() => { - if (!cancelled) {setRuns(prev => prev ?? [])} + if (!cancelled) { + setRuns(prev => prev ?? []) + } }) void load() const intervalId = window.setInterval(() => { - if (document.visibilityState === 'visible') {void load()} + if (document.visibilityState === 'visible') { + void load() + } }, PEEK_POLL_INTERVAL_MS) return () => { -
apps/desktop/src/app/chat/sidebar/index.tsx@@ -48,6 +48,7 @@ import { $pinnedSessionIds, $sidebarAgentsGrouped, $sidebarCronOpen, + $sidebarMessagingOpenIds, $sidebarOpen, $sidebarOverlayMounted, $sidebarPinsOpen, @@ -64,6 +65,7 @@ import { setSidebarSessionOrderIds, setSidebarWorkspaceOrderIds, SIDEBAR_SESSIONS_PAGE_SIZE, + toggleSidebarMessagingOpen, unpinSession } from '@/store/layout' import { @@ -93,12 +95,19 @@ import { SidebarPanelLabel } from '../../shell/sidebar-label' import type { SidebarNavItem } from '../../types' import { SidebarCronJobsSection } from './cron-jobs-section' +import { SidebarLoadMoreRow } from './load-more-row' import { ProfileRail } from './profile-switcher' import { SidebarSessionRow } from './session-row' import { VirtualSessionList } from './virtual-session-list' const VIRTUALIZE_THRESHOLD = 25 +// Non-session groups (messaging platforms) stay compact: show a few rows up +// front, reveal more in larger steps on demand. Keeps a busy platform from +// dominating the sidebar before the user asks to see it. +const NON_SESSION_INITIAL_ROWS = 3 +const NON_SESSION_LOAD_STEP = 10 + // Render the modifier key the user actually presses on this platform. The // global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere // else) in desktop-controller.tsx, but the hint should match muscle memory. @@ -128,6 +137,16 @@ const WORKSPACE_PAGE = 5 const PROFILE_INITIAL_PAGE = 5 const GROUP_DND_ID_PREFIX = 'group:' +// Two modes via the `compact` height variant (styles.css): +// tall → each section is shrink-0, capped, its own scroller; Sessions is flex-1. +// compact → COMPACT_FLAT drops the caps so the whole stack scrolls as one. +// Sections stay shrink-0 so none can be squeezed below its content and bleed onto +// the next — the flexbox `min-height: auto` overlap trap that caused the bug. +const COMPACT_FLAT = 'compact:max-h-none compact:overflow-visible' + +// A non-session group's scroll body: own scroller when tall, flattened when compact. +const GROUP_BODY = cn('overflow-y-auto overscroll-contain', COMPACT_FLAT) + const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}` const parseGroupDndId = (id: string) => @@ -334,7 +353,9 @@ export function ChatSidebar({ 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 messagingOpenIds = useStore($sidebarMessagingOpenIds) + // Per-platform count of rows currently revealed (starts at NON_SESSION_INITIAL_ROWS). + const [messagingVisible, setMessagingVisible] = useState<Record<string, number>>({}) const searchInputRef = useRef<HTMLInputElement>(null) const trimmedQuery = searchQuery.trim() @@ -538,6 +559,18 @@ export function ChatSidebar({ [onLoadMoreMessaging] ) + // Reveal another batch of a platform's rows; fetch from the backend too if we + // run past what's loaded and more remain on disk. + const revealMoreMessaging = (platform: string, loaded: number, hasMore: boolean) => { + const next = (messagingVisible[platform] ?? NON_SESSION_INITIAL_ROWS) + NON_SESSION_LOAD_STEP + + setMessagingVisible(prev => ({ ...prev, [platform]: next })) + + if (next > loaded && hasMore) { + loadMoreForMessaging(platform) + } + } + // 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 @@ -650,6 +683,12 @@ export function ChatSidebar({ const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined + // The recents list owns its own (virtualized) scroll container only when it's a + // long flat list. In that case it must keep its scroller even in short mode, so + // we don't flatten it (flattening would defeat virtualization). Short flat lists + // and grouped views flatten into the single outer scroll instead. + const recentsVirtualizes = !displayAgentGroups?.length && displayAgentSessions.length >= VIRTUALIZE_THRESHOLD + useEffect(() => { if (!displayAgentGroups?.length || showAllProfiles) { return @@ -781,9 +820,7 @@ export function ChatSidebar({ <item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" /> {contentVisible && ( <> - <span className="min-w-0 flex-1 truncate"> - {s.nav[item.id] ?? item.label} - </span> + <span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span> {isNewSession && ( <KbdGroup className={cn('ml-auto', newSessionKbdFlash && 'opacity-100!')} @@ -812,175 +849,191 @@ export function ChatSidebar({ </div> )} - {contentVisible && showSessionSections && trimmedQuery && ( - <SidebarSessionsSection - activeSessionId={activeSidebarSessionId} - contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75" - emptyState={ - <div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)"> - {s.noMatch(trimmedQuery)} - </div> - } - label={s.results} - labelMeta={String(searchResults.length)} - onArchiveSession={onArchiveSession} - onDeleteSession={onDeleteSession} - onResumeSession={onResumeSession} - onToggle={() => undefined} - onTogglePin={pinSession} - open - pinned={false} - rootClassName="min-h-0 flex-1 p-0" - sessions={searchResults} - workingSessionIdSet={workingSessionIdSet} - /> - )} + {contentVisible && showSessionSections && ( + <div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75"> + {trimmedQuery && ( + <SidebarSessionsSection + activeSessionId={activeSidebarSessionId} + contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75" + emptyState={ + <div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)"> + {s.noMatch(trimmedQuery)} + </div> + } + label={s.results} + labelMeta={String(searchResults.length)} + onArchiveSession={onArchiveSession} + onDeleteSession={onDeleteSession} + onResumeSession={onResumeSession} + onToggle={() => undefined} + onTogglePin={pinSession} + open + pinned={false} + rootClassName="min-h-32 flex-1 overflow-hidden p-0" + sessions={searchResults} + workingSessionIdSet={workingSessionIdSet} + /> + )} - {contentVisible && showSessionSections && !trimmedQuery && ( - <SidebarSessionsSection - activeSessionId={activeSidebarSessionId} - contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1" - dndSensors={dndSensors} - emptyState={<SidebarPinnedEmptyState />} - label={s.pinned} - onArchiveSession={onArchiveSession} - onDeleteSession={onDeleteSession} - onReorder={handlePinnedDragEnd} - onResumeSession={onResumeSession} - onToggle={() => setSidebarPinsOpen(!pinsOpen)} - onTogglePin={unpinSession} - open={pinsOpen} - pinned - rootClassName="shrink-0 p-0 pb-1" - sessions={pinnedSessions} - sortable={pinnedSessions.length > 1} - workingSessionIdSet={workingSessionIdSet} - /> - )} + {!trimmedQuery && ( + <SidebarSessionsSection + activeSessionId={activeSidebarSessionId} + contentClassName={cn('flex max-h-44 flex-col gap-px rounded-lg pb-2 pt-1', GROUP_BODY)} + dndSensors={dndSensors} + emptyState={<SidebarPinnedEmptyState />} + label={s.pinned} + onArchiveSession={onArchiveSession} + onDeleteSession={onDeleteSession} + onReorder={handlePinnedDragEnd} + onResumeSession={onResumeSession} + onToggle={() => setSidebarPinsOpen(!pinsOpen)} + onTogglePin={unpinSession} + open={pinsOpen} + pinned + rootClassName="shrink-0 p-0 pb-1" + sessions={pinnedSessions} + sortable={pinnedSessions.length > 1} + workingSessionIdSet={workingSessionIdSet} + /> + )} - {contentVisible && showSessionSections && !trimmedQuery && ( - <SidebarSessionsSection - activeSessionId={activeSidebarSessionId} - contentClassName={cn( - 'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75', - // Separate profile sections clearly in the ALL view; rows inside - // each group keep their own tight gap-px rhythm. - showAllProfiles ? 'gap-3' : 'gap-px' + {!trimmedQuery && ( + <SidebarSessionsSection + activeSessionId={activeSidebarSessionId} + contentClassName={cn( + 'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75', + // Separate profile sections clearly in the ALL view; rows inside + // each group keep their own tight gap-px rhythm. + showAllProfiles ? 'gap-3' : 'gap-px', + // Flatten into the single scroll when compact — unless this is the + // virtualized long list, which must keep its own scroller. + !recentsVirtualizes && COMPACT_FLAT + )} + dndSensors={dndSensors} + emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />} + footer={ + // Hide "load more" only when workspace-grouped (those groups page + // themselves). ALL-profiles now pages per-profile from each profile + // header; the global footer only applies to non-ALL views. + !showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? ( + <SidebarLoadMoreRow + loading={sessionsLoading} + onClick={onLoadMoreSessions} + step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)} + /> + ) : null + } + forceEmptyState={showSessionSkeletons} + groups={displayAgentGroups} + headerAction={ + // Always reserve the icon-xs (size-6) slot so the header keeps the + // same height whether or not the toggle renders — otherwise the + // "Sessions" label jumps when switching to the ALL-profiles view. + // Grouping operates on unpinned recents; if everything is pinned + // 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 && agentSessions.length > 0 ? ( + <Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}> + <Button + aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped} + className={cn( + 'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100', + agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100' + )} + onClick={event => { + event.stopPropagation() + setSidebarRecentsOpen(true) + setSidebarAgentsGrouped(!agentsGrouped) + }} + size="icon-xs" + variant="ghost" + > + <Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" /> + </Button> + </Tip> + ) : null} + </div> + } + label={s.sessions} + labelMeta={recentsMeta} + onArchiveSession={onArchiveSession} + onDeleteSession={onDeleteSession} + onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace} + onReorder={showAllProfiles ? undefined : handleAgentDragEnd} + onResumeSession={onResumeSession} + onToggle={() => setSidebarRecentsOpen(!agentsOpen)} + onTogglePin={pinSession} + open={agentsOpen} + pinned={false} + rootClassName={cn( + 'min-h-32 flex-1 overflow-hidden p-0', + !recentsVirtualizes && 'compact:min-h-0 compact:flex-none compact:overflow-visible' + )} + sessions={displayAgentSessions} + sortable={!showAllProfiles && agentSessions.length > 1} + workingSessionIdSet={workingSessionIdSet} + /> )} - dndSensors={dndSensors} - emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />} - footer={ - // Hide "load more" only when workspace-grouped (those groups page - // themselves). ALL-profiles now pages per-profile from each profile - // header; the global footer only applies to non-ALL views. - !showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? ( - <SidebarLoadMoreRow - loading={sessionsLoading} - onClick={onLoadMoreSessions} - step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)} - /> - ) : null - } - forceEmptyState={showSessionSkeletons} - groups={displayAgentGroups} - headerAction={ - // Always reserve the icon-xs (size-6) slot so the header keeps the - // same height whether or not the toggle renders — otherwise the - // "Sessions" label jumps when switching to the ALL-profiles view. - // Grouping operates on unpinned recents; if everything is pinned - // 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 && agentSessions.length > 0 ? ( - <Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}> - <Button - aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped} - className={cn( - 'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100', - agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100' - )} - onClick={event => { - event.stopPropagation() - setSidebarRecentsOpen(true) - setSidebarAgentsGrouped(!agentsGrouped) - }} - size="icon-xs" - variant="ghost" - > - <Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" /> - </Button> - </Tip> - ) : null} - </div> - } - label={s.sessions} - labelMeta={recentsMeta} - onArchiveSession={onArchiveSession} - onDeleteSession={onDeleteSession} - onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace} - onReorder={showAllProfiles ? undefined : handleAgentDragEnd} - onResumeSession={onResumeSession} - onToggle={() => setSidebarRecentsOpen(!agentsOpen)} - onTogglePin={pinSession} - open={agentsOpen} - pinned={false} - rootClassName="min-h-0 flex-1 p-0" - sessions={displayAgentSessions} - sortable={!showAllProfiles && agentSessions.length > 1} - workingSessionIdSet={workingSessionIdSet} - /> - )} - {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)} + {!trimmedQuery && + messagingGroups.map(group => { + const visible = messagingVisible[group.sourceId] ?? NON_SESSION_INITIAL_ROWS + const shownSessions = group.sessions.slice(0, visible) + // More to show if rows are hidden behind the cap, or the backend + // still has older threads on disk. + const canRevealMore = visible < group.sessions.length || group.hasMore + + return ( + <SidebarSessionsSection + activeSessionId={activeSidebarSessionId} + contentClassName={cn('flex max-h-56 flex-col gap-px pb-1.75', GROUP_BODY)} + emptyState={null} + footer={ + canRevealMore ? ( + <SidebarLoadMoreRow + loading={Boolean(messagingLoadMorePending[group.sourceId])} + onClick={() => revealMoreMessaging(group.sourceId, group.sessions.length, group.hasMore)} + step={Math.min(NON_SESSION_LOAD_STEP, Math.max(0, group.total - shownSessions.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={() => toggleSidebarMessagingOpen(group.sourceId)} + onTogglePin={pinSession} + open={messagingOpenIds.includes(group.sourceId)} + pinned={false} + rootClassName="shrink-0 p-0" + sessions={shownSessions} + workingSessionIdSet={workingSessionIdSet} /> - ) : 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} - label={s.cronJobs} - onManageJob={onManageCronJob} - onOpenRun={onResumeSession} - onToggle={() => setSidebarCronOpen(!cronOpen)} - onTriggerJob={onTriggerCronJob} - open={cronOpen} - /> + ) + })} + + {!trimmedQuery && cronJobs.length > 0 && ( + <SidebarCronJobsSection + jobs={cronJobs} + label={s.cronJobs} + onManageJob={onManageCronJob} + onOpenRun={onResumeSession} + onToggle={() => setSidebarCronOpen(!cronOpen)} + onTriggerJob={onTriggerCronJob} + open={cronOpen} + /> + )} + </div> )} {contentVisible && !showSessionSections && <div className="min-h-0 flex-1" />} @@ -1222,6 +1275,7 @@ function SidebarSessionsSection({ inner = ( <VirtualSessionList activeSessionId={activeSessionId} + className={contentClassName} onArchiveSession={onArchiveSession} onDeleteSession={onDeleteSession} onResumeSession={onResumeSession} @@ -1446,30 +1500,3 @@ interface SortableSessionRowProps { function SortableSidebarSessionRow(props: SortableSessionRowProps) { return <SidebarSessionRow {...props} {...useSortableBindings(props.session.id)} /> } - -interface SidebarLoadMoreRowProps { - loading: boolean - onClick: () => void - step: number -} - -function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) { - const { t } = useI18n() - const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore - - return ( - <button - className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)" - disabled={loading} - onClick={onClick} - type="button" - > - {/* Seat the icon in the same w-3.5 column session rows use for their dot - so the chevron + label line up with the rows above. */} - <span className="grid w-3.5 shrink-0 place-items-center"> - <Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} /> - </span> - <span>{label}</span> - </button> - ) -} -
apps/desktop/src/app/chat/sidebar/load-more-row.tsx@@ -0,0 +1,30 @@ +import { Codicon } from '@/components/ui/codicon' +import { useI18n } from '@/i18n' + +interface SidebarLoadMoreRowProps { + step: number + onClick: () => void + loading?: boolean +} + +// "Load N more" affordance shared by the recents, messaging, and cron sections. +// The chevron sits in the same w-3.5 column the rows use for their dot, so it +// lines up with the list above. +export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLoadMoreRowProps) { + const { t } = useI18n() + const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore + + return ( + <button + className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)" + disabled={loading} + onClick={onClick} + type="button" + > + <span className="grid w-3.5 shrink-0 place-items-center"> + <Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} /> + </span> + <span>{label}</span> + </button> + ) +} -
apps/desktop/src/store/layout.ts@@ -23,6 +23,7 @@ export const SIDEBAR_SESSIONS_PAGE_SIZE = 50 const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions' const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorkspace' const SIDEBAR_CRON_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarCronOpen' +const SIDEBAR_MESSAGING_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarMessagingOpen' const SIDEBAR_SESSION_ORDER_STORAGE_KEY = 'hermes.desktop.sessionOrder' const SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceOrder' const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped' @@ -68,6 +69,10 @@ export const $sidebarRecentsOpen = atom(true) // default (it only renders at all when cron sessions exist) so the // scheduler's `[IMPORTANT: …]` first-message previews don't spam recents. export const $sidebarCronOpen = atom(storedBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, false)) +// Messaging platform sections collapse by default (they can be numerous and +// tall). We persist the ids the user has *explicitly expanded*, so the default +// stays collapsed unless they've opened a platform before. +export const $sidebarMessagingOpenIds = atom<string[]>(storedStringArray(SIDEBAR_MESSAGING_OPEN_STORAGE_KEY)) export const $sidebarAgentsGrouped = atom(storedBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, false)) // When true, the sessions sidebar moves to the right and the file browser + // preview rail move to the left — a mirror of the default layout. @@ -77,6 +82,7 @@ export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE) $pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids])) $sidebarCronOpen.subscribe(open => persistBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, open)) +$sidebarMessagingOpenIds.subscribe(ids => persistStringArray(SIDEBAR_MESSAGING_OPEN_STORAGE_KEY, [...ids])) $sidebarSessionOrderIds.subscribe(ids => persistStringArray(SIDEBAR_SESSION_ORDER_STORAGE_KEY, [...ids])) $sidebarWorkspaceOrderIds.subscribe(ids => persistStringArray(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY, [...ids])) $sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped)) @@ -139,6 +145,14 @@ export function setSidebarCronOpen(open: boolean) { $sidebarCronOpen.set(open) } +export function toggleSidebarMessagingOpen(sourceId: string) { + const current = $sidebarMessagingOpenIds.get() + + $sidebarMessagingOpenIds.set( + current.includes(sourceId) ? current.filter(id => id !== sourceId) : [...current, sourceId] + ) +} + export function setSidebarAgentsGrouped(grouped: boolean) { $sidebarAgentsGrouped.set(grouped) } -
apps/desktop/src/styles.css@@ -5,6 +5,10 @@ @import '@vscode/codicons/dist/codicon.css'; @custom-variant dark (&:is(.dark *)); +/* Sidebar sections: tall viewports give each its own scroller; compact ones + (this variant) flatten everything into one shared scroll. See ChatSidebar. */ +@custom-variant compact (@media (max-height: 768px)); + @font-face { font-family: 'Collapse'; font-style: normal; @@ -266,10 +270,12 @@ --dt-user-bubble: var(--ui-chat-bubble-background); --dt-user-bubble-border: var(--ui-stroke-tertiary); - --dt-font-sans: 'Segoe WPC', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif, - 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', emoji; - --dt-font-mono: 'Cascadia Code', 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, Consolas, monospace, + --dt-font-sans: + 'Segoe WPC', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', emoji; + --dt-font-mono: + 'Cascadia Code', 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, Consolas, monospace, 'Apple Color Emoji', + 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', emoji; --dt-base-size: 1rem; --dt-line-height: 1.5; --dt-letter-spacing: 0; @@ -914,7 +920,11 @@ canvas { display: block; inline-size: var(--fit-available-space); - font-size: clamp(var(--fit-min, 1em), 1em * var(--fit-ratio), var(--fit-max, infinity * 1px) - var(--fit-support-sentinel)); + font-size: clamp( + var(--fit-min, 1em), + 1em * var(--fit-ratio), + var(--fit-max, infinity * 1px) - var(--fit-support-sentinel) + ); } @container (inline-size > 0) {
-
1 PR closed without merging
From the close comments: done elsewhere 1
-
#42988 fix(desktop): prevent sidebar section header overlap · done elsewhere →
8bb6529,#43147,#42987
Closed issues · 1
- Closed#42987Desktop sidebar: Sessions and messaging headers can overlap
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.67: 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
- Subsystem
- apps/desktop
- Merged PRs
- #43147
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 10 Jun 2026