From 8bb65295532c7d353f081e3aaf63ed6f06ca1bd0 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Tue, 9 Jun 2026 20:11:45 -0500 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20sidebar=20sections=20never=20ov?= =?UTF-8?q?erlap=20=E2=80=94=20two-mode=20CSS=20scroll=20+=20collapse/cap?= =?UTF-8?q?=20groups=20(#43147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): prevent sidebar section overlap Use a shared sidebar section scroller only on short windows so sections do not overlap, while preserving per-section scrolling on taller layouts. * fix(desktop): measure section stack for compact sidebar mode Window-height media query kept big windows in compact mode whenever the OS chrome ate into 830px; observe the section stack element instead so compact only engages when the stack is actually short. * refactor(desktop): drive sidebar compact mode with CSS, not JS Replace the matchMedia hook with a `short` (max-height: 830px) Tailwind variant so the per-section scrollers flatten into one shared scroll stack on short windows purely in CSS. Taller windows keep their per-group scrollers and recents virtualization unchanged. * refactor(desktop): pure-CSS two-mode sidebar scroll + collapse/cap groups Drop the JS-measured compaction in favour of a single `compact` height variant (max-height: 768px): - tall: every section is its own capped, independent scroller; Sessions is the lone flex-1 scroller. - short: sections flatten and the stack scrolls as one. Every section is now `shrink-0`, so nothing is squeezed below its content and bled onto a sibling — the root cause of the header overlap (flexbox implied min-size). Sessions keeps its virtualized scroller in short mode only when it's the long list. Non-session groups (messaging, cron) collapse by default — expanded ids persist per platform — and render 3 rows, revealing 10 more on demand. Extract the shared SidebarLoadMoreRow. Stress harness seeds 50 recents to mirror the real first page. * chore(desktop): trim sidebar comments, unify "compact" naming Self-review polish: condense the over-long mode comments, use "compact" consistently (matching the variant) instead of mixing "short", and drop a no-op useCallback around revealMoreMessaging. * chore(desktop): drop dev sidebar stress harness from the PR Remove stress-probe.ts and its main.tsx import — it was a throwaway testing aid, not something to ship. --- .../app/chat/sidebar/cron-jobs-section.tsx | 83 ++-- apps/desktop/src/app/chat/sidebar/index.tsx | 419 ++++++++++-------- .../src/app/chat/sidebar/load-more-row.tsx | 30 ++ apps/desktop/src/store/layout.ts | 14 + apps/desktop/src/styles.css | 18 +- 5 files changed, 338 insertions(+), 226 deletions(-) create mode 100644 apps/desktop/src/app/chat/sidebar/load-more-row.tsx diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx index 7b0e7b95fe7..f8db6e390e2 100644 --- a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +++ b/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) + // 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({ {open && ( - + {shown.map(job => ( onTriggerJob(job.id)} /> ))} + {hiddenCount > 0 && ( + setVisibleCount(count => count + LOAD_MORE_STEP)} + step={Math.min(LOAD_MORE_STEP, hiddenCount)} + /> + )} )} @@ -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 (
@@ -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 () => { diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 6770234d853..6c2396f9100 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/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>({}) const [messagingLoadMorePending, setMessagingLoadMorePending] = useState>({}) - const [messagingOpen, setMessagingOpen] = useState>({}) + const messagingOpenIds = useStore($sidebarMessagingOpenIds) + // Per-platform count of rows currently revealed (starts at NON_SESSION_INITIAL_ROWS). + const [messagingVisible, setMessagingVisible] = useState>({}) const searchInputRef = useRef(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({ {contentVisible && ( <> - - {s.nav[item.id] ?? item.label} - + {s.nav[item.id] ?? item.label} {isNewSession && ( )} - {contentVisible && showSessionSections && trimmedQuery && ( - - {s.noMatch(trimmedQuery)} -
- } - 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 && !trimmedQuery && ( - } - 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 && ( - + {trimmedQuery && ( + + {s.noMatch(trimmedQuery)} + + } + 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} + /> )} - dndSensors={dndSensors} - emptyState={showSessionSkeletons ? : } - 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 ? ( - - ) : 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). -
- {!showAllProfiles && agentSessions.length > 0 ? ( - - - - ) : null} -
- } - 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 => ( - loadMoreForMessaging(group.sourceId)} - step={Math.max(0, group.total - group.sessions.length)} + {!trimmedQuery && ( + } + 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 && ( + : } + 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 ? ( + + ) : 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). +
+ {!showAllProfiles && agentSessions.length > 0 ? ( + + + + ) : null} +
+ } + 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} + /> + )} + + {!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 ( + 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={ + + } + 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={ - - } - 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 && ( - setSidebarCronOpen(!cronOpen)} - onTriggerJob={onTriggerCronJob} - open={cronOpen} - /> + {!trimmedQuery && cronJobs.length > 0 && ( + setSidebarCronOpen(!cronOpen)} + onTriggerJob={onTriggerCronJob} + open={cronOpen} + /> + )} + )} {contentVisible && !showSessionSections &&
} @@ -1222,6 +1275,7 @@ function SidebarSessionsSection({ inner = ( } - -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 ( - - ) -} diff --git a/apps/desktop/src/app/chat/sidebar/load-more-row.tsx b/apps/desktop/src/app/chat/sidebar/load-more-row.tsx new file mode 100644 index 00000000000..1229201be7c --- /dev/null +++ b/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 ( + + ) +} diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index 18b1ae0d1d5..b882608c7c9 100644 --- a/apps/desktop/src/store/layout.ts +++ b/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(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) } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 4dc57fb1c69..2bd7556d848 100644 --- a/apps/desktop/src/styles.css +++ b/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) {