diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 32634e3ac41..35abc987d87 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -4614,7 +4614,7 @@ function createWindow() { mainWindow = new BrowserWindow({ width: 1220, height: 800, - minWidth: 900, + minWidth: 400, minHeight: 620, title: 'Hermes', // Frameless title bar on every platform so the renderer can paint the diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 572e1360a2c..4a0d3829c39 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -124,7 +124,10 @@ function ChatHeader({ return (
-
+
diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index ef1832837f3..dcc516deadc 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -47,6 +47,7 @@ import { $sidebarAgentsGrouped, $sidebarCronOpen, $sidebarOpen, + $sidebarOverlayMounted, $sidebarPinsOpen, $sidebarRecentsOpen, pinSession, @@ -247,6 +248,9 @@ export function ChatSidebar({ const { t } = useI18n() const s = t.sidebar const sidebarOpen = useStore($sidebarOpen) + // Collapsed-but-overlay-mounted → render the full sidebar, not just the nav rail. + const overlayMounted = useStore($sidebarOverlayMounted) + const contentVisible = sidebarOpen || overlayMounted const panesFlipped = useStore($panesFlipped) const agentsGrouped = useStore($sidebarAgentsGrouped) const pinnedSessionIds = useStore($pinnedSessionIds) @@ -580,7 +584,11 @@ export function ChatSidebar({ panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0', sidebarOpen ? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100' - : 'pointer-events-none border-transparent bg-transparent opacity-0' + : 'pointer-events-none border-transparent bg-transparent opacity-0', + // While floated by PaneShell's hover-reveal, force visible + interactive + // — on hover (group-hover/reveal) or when keyboard-pinned (data-forced). + 'in-data-[pane-hover-reveal=open]:pointer-events-auto in-data-[pane-hover-reveal=open]:border-(--sidebar-edge-border) in-data-[pane-hover-reveal=open]:bg-(--ui-sidebar-surface-background) in-data-[pane-hover-reveal=open]:opacity-100', + 'group-hover/reveal:pointer-events-auto group-hover/reveal:border-(--sidebar-edge-border) group-hover/reveal:bg-(--ui-sidebar-surface-background) group-hover/reveal:opacity-100' )} collapsible="none" > @@ -624,14 +632,14 @@ export function ChatSidebar({ type="button" > - {sidebarOpen && ( + {contentVisible && ( <> - + {s.nav[item.id] ?? item.label} {isNewSession && ( )} @@ -645,7 +653,7 @@ export function ChatSidebar({ - {sidebarOpen && showSessionSections && ( + {contentVisible && showSessionSections && (
)} - {sidebarOpen && showSessionSections && trimmedQuery && ( + {contentVisible && showSessionSections && trimmedQuery && ( )} - {sidebarOpen && showSessionSections && !trimmedQuery && ( + {contentVisible && showSessionSections && !trimmedQuery && ( )} - {sidebarOpen && showSessionSections && !trimmedQuery && ( + {contentVisible && showSessionSections && !trimmedQuery && ( )} - {sidebarOpen && !trimmedQuery && cronJobs.length > 0 && ( + {contentVisible && !trimmedQuery && cronJobs.length > 0 && ( )} - {sidebarOpen && !showSessionSections &&
} + {contentVisible && !showSessionSections &&
} - {sidebarOpen && ( + {contentVisible && (
diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 15466d20950..42df767ef59 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -8,6 +8,7 @@ import { DesktopInstallOverlay } from '@/components/desktop-install-overlay' import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay' import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay' import { Pane, PaneMain } from '@/components/pane-shell' +import { useMediaQuery } from '@/hooks/use-media-query' import { useSkinCommand } from '@/themes/use-skin-command' import { formatRefValue } from '../components/assistant-ui/directive-text' @@ -23,6 +24,7 @@ import { FILE_BROWSER_MAX_WIDTH, FILE_BROWSER_MIN_WIDTH, pinSession, + setSidebarOverlayMounted, SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, SIDEBAR_SESSIONS_PAGE_SIZE, @@ -76,6 +78,7 @@ import { CommandPalette } from './command-palette' import { useGatewayBoot } from './gateway/hooks/use-gateway-boot' import { useGatewayRequest } from './gateway/hooks/use-gateway-request' import { useKeybinds } from './hooks/use-keybinds' +import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants' import { ModelPickerOverlay } from './model-picker-overlay' import { ModelVisibilityOverlay } from './model-visibility-overlay' import { RightSidebarPane } from './right-sidebar' @@ -165,6 +168,10 @@ export function DesktopController() { const terminalTakeover = useStore($terminalTakeover) const panesFlipped = useStore($panesFlipped) const profileScope = useStore($profileScope) + // Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail — + // collapse both sidebars (without touching their stored open state) so the + // hover-reveal overlay becomes the way in. Restores once it's wide again. + const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY) const routedSessionId = routeSessionId(location.pathname) const routeToken = `${location.pathname}:${location.search}:${location.hash}` @@ -300,6 +307,7 @@ export function DesktopController() { // with few recent sessions isn't windowed out of the cross-profile // recency page — the empty-history-on-profile-switch bug. const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope + const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, { excludeSources: ['cron'] }) @@ -846,6 +854,8 @@ export function DesktopController() { { + if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) { + window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: CHAT_SIDEBAR_PANE_ID } })) + } else { + toggleSidebarOpen() + } + }, + 'view.toggleRightSidebar': () => { + if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) { + window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: FILE_BROWSER_PANE_ID } })) + } else { + toggleFileBrowserOpen() + } + }, 'view.showFiles': () => showRightSidebarTab('files'), 'view.showTerminal': () => showRightSidebarTab('terminal'), 'view.flipPanes': togglePanesFlipped, diff --git a/apps/desktop/src/app/layout-constants.ts b/apps/desktop/src/app/layout-constants.ts index fff56d1e2b6..3174fc790ee 100644 --- a/apps/desktop/src/app/layout-constants.ts +++ b/apps/desktop/src/app/layout-constants.ts @@ -11,3 +11,9 @@ export const PAGE_INSET_X = 'px-[clamp(1.25rem,4vw,4rem)]' // Matching negative inline-margin to bleed an element (e.g. a sticky header bar) // out to the gutter edges before re-applying PAGE_INSET_X. export const PAGE_INSET_NEG_X = '-mx-[clamp(1.25rem,4vw,4rem)]' + +// Below this viewport width a docked sidebar leaves no room for content, so both +// rails auto-collapse into the hover-reveal overlay. Single source of truth for +// the responsive collapse point. +export const SIDEBAR_COLLAPSE_BREAKPOINT_PX = 768 +export const SIDEBAR_COLLAPSE_MEDIA_QUERY = `(max-width: ${SIDEBAR_COLLAPSE_BREAKPOINT_PX}px)` diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index af9c75d6b7d..1c60e6411cf 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -5,6 +5,7 @@ import { useSyncExternalStore } from 'react' import { NotificationStack } from '@/components/notifications' import { PaneShell } from '@/components/pane-shell' import { SidebarProvider } from '@/components/ui/sidebar' +import { useMediaQuery } from '@/hooks/use-media-query' import { $fileBrowserOpen, $panesFlipped, @@ -16,6 +17,8 @@ import { import { $paneWidthOverride } from '@/store/panes' import { $connection } from '@/store/session' +import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants' + import { KeybindPanel } from './keybind-panel' import { StatusbarControls, type StatusbarItem } from './statusbar-controls' import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar' @@ -58,6 +61,7 @@ export function AppShell({ const sidebarOpen = useStore($sidebarOpen) const fileBrowserOpen = useStore($fileBrowserOpen) const panesFlipped = useStore($panesFlipped) + const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY) const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID)) const connection = useStore($connection) const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false) @@ -71,8 +75,10 @@ export function AppShell({ // The inset clears the top-left titlebar buttons when nothing covers the // window's left edge. Default layout: the sessions sidebar sits there. - // Flipped layout: the file browser does instead. - const leftEdgePaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen + // Flipped layout: the file browser does instead. Below the collapse + // breakpoint both rails are force-collapsed (hover-reveal overlay), so the + // edge is uncovered regardless of their stored open state. + const leftEdgePaneOpen = !narrowViewport && (panesFlipped ? fileBrowserOpen : sidebarOpen) const titlebarContentInset = leftEdgePaneOpen ? 0 diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index 30f77234f46..cf0d34fc662 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -425,7 +425,7 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
) => (
+
) : undefined @@ -470,9 +467,7 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star s => s.thread.isRunning && s.message.status?.type === 'running' && - s.message.parts - .slice(Math.max(0, startIndex)) - .some(p => p?.type === 'reasoning' && p.status?.type !== 'complete') + s.message.parts.slice(Math.max(0, startIndex)).some(p => p?.type === 'reasoning' && p.status?.type !== 'complete') ) // A reasoning group with no actual text is pure noise — drop the whole diff --git a/apps/desktop/src/components/chat/intro.tsx b/apps/desktop/src/components/chat/intro.tsx index e942f55ff21..f7784855ec9 100644 --- a/apps/desktop/src/components/chat/intro.tsx +++ b/apps/desktop/src/components/chat/intro.tsx @@ -160,14 +160,14 @@ export function Intro({ personality, seed }: IntroProps) { return (

{WORDMARK} diff --git a/apps/desktop/src/components/pane-shell/index.ts b/apps/desktop/src/components/pane-shell/index.ts index 40946890cf3..1874b4bf005 100644 --- a/apps/desktop/src/components/pane-shell/index.ts +++ b/apps/desktop/src/components/pane-shell/index.ts @@ -1,4 +1,4 @@ export type { PaneShellContextValue, PaneSlot } from './context' export { PaneShellContext } from './context' -export { Pane, PaneMain, PaneShell } from './pane-shell' +export { Pane, PANE_TOGGLE_REVEAL_EVENT, PaneMain, PaneShell } from './pane-shell' export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell' diff --git a/apps/desktop/src/components/pane-shell/pane-shell.tsx b/apps/desktop/src/components/pane-shell/pane-shell.tsx index a3f6719ee54..8651ecd3ee9 100644 --- a/apps/desktop/src/components/pane-shell/pane-shell.tsx +++ b/apps/desktop/src/components/pane-shell/pane-shell.tsx @@ -10,7 +10,8 @@ import { useContext, useEffect, useMemo, - useRef + useRef, + useState } from 'react' import { cn } from '@/lib/utils' @@ -31,6 +32,12 @@ export interface PaneProps { defaultOpen?: boolean /** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */ disabled?: boolean + /** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */ + forceCollapsed?: boolean + /** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */ + hoverReveal?: boolean + /** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */ + onOverlayActiveChange?: (overlayActive: boolean) => void id: string maxWidth?: WidthValue minWidth?: WidthValue @@ -53,6 +60,7 @@ export interface PaneShellProps { interface CollectedPane { defaultOpen: boolean disabled: boolean + forceCollapsed: boolean id: string resizable: boolean side: PaneSide @@ -62,6 +70,22 @@ interface CollectedPane { const DEFAULT_WIDTH = '16rem' const DEFAULT_RESIZE_MIN_WIDTH = 160 +// Hover-reveal slide. The enter delay is a pure-CSS hover-intent gate: a fast +// pass-by doesn't dwell on the trigger long enough for the delay to elapse. +const HOVER_REVEAL_SLIDE_MS = 220 +const HOVER_REVEAL_ENTER_DELAY_MS = 130 +const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)' +// Offset shadow lifting the revealed panel off the content (same both sides; +// the mirror axis is offset-x, which is 0). Same color on light + dark. +const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012' +// Edge trigger strip, inset past the OS window-resize grab area. +const HOVER_REVEAL_TRIGGER_WIDTH = 14 +const HOVER_REVEAL_EDGE_GUTTER = 6 + +// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal +// from the keyboard, since its store-open toggle is a no-op while collapsed. +export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal' + const widthToCss = (value: WidthValue | undefined, fallback: string) => value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value @@ -110,6 +134,7 @@ function collectPanes(children: ReactNode) { const entry: CollectedPane = { defaultOpen: props.defaultOpen ?? true, disabled: props.disabled ?? false, + forceCollapsed: props.forceCollapsed ?? false, id: props.id, resizable: props.resizable ?? false, side: props.side, @@ -124,7 +149,7 @@ function collectPanes(children: ReactNode) { function trackForPane(pane: CollectedPane, states: Record) { const stateOpen = states[pane.id]?.open ?? pane.defaultOpen - const open = !pane.disabled && stateOpen + const open = !pane.disabled && !pane.forceCollapsed && stateOpen if (!open) { return { open: false, track: '0px' } @@ -193,14 +218,29 @@ export function Pane({ className, defaultOpen = true, disabled = false, + hoverReveal = false, id, maxWidth, minWidth, - resizable = false + onOverlayActiveChange, + resizable = false, + width }: PaneProps) { const ctx = useContext(PaneShellContext) + const paneStates = useStore($paneStates) const registered = useRef(false) const paneRef = useRef(null) + // Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS. + const [forced, setForced] = useState(false) + + const slot = ctx?.paneById.get(id) + const open = Boolean(slot?.open && !disabled) + const side = slot?.side ?? 'left' + // Collapsed + hoverReveal: float the pane contents over the main column on + // hover/focus instead of hiding them. Honors any persisted resize width. + const overlayActive = !open && hoverReveal && !disabled + const override = resizable ? paneStates[id]?.widthOverride : undefined + const overlayWidth = override !== undefined ? `${override}px` : widthToCss(width, DEFAULT_WIDTH) useEffect(() => { if (registered.current) { @@ -211,12 +251,34 @@ export function Pane({ ensurePaneRegistered(id, { open: defaultOpen }) }, [defaultOpen, id]) - const slot = ctx?.paneById.get(id) - const open = Boolean(slot?.open && !disabled) + // Keyboard toggle pins/unpins the reveal while collapsed; clear when no longer + // a collapsed overlay (reopened / widened). + useEffect(() => { + if (typeof window === 'undefined' || !overlayActive) { + setForced(false) + + return + } + + const onToggle = (e: Event) => { + if ((e as CustomEvent<{ id: string }>).detail?.id === id) { + setForced(v => !v) + } + } + + window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle) + + return () => window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle) + }, [id, overlayActive]) + + // Keep contents mounted while collapsed so reveal is a pure CSS transform. + useEffect(() => { + onOverlayActiveChange?.(overlayActive) + }, [onOverlayActiveChange, overlayActive]) + const canResize = open && resizable const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY - const side = slot?.side ?? 'left' const startResize = useCallback( (event: ReactPointerEvent) => { @@ -273,6 +335,58 @@ export function Pane({ return null } + // Collapsed hover-reveal track: a 0px, pointer-transparent grid cell holding a + // thin edge trigger + the floating panel (both absolute, escaping the zero + // box). group-hover (or data-forced from the keyboard) drives the slide; the + // enter-delay is the hover-intent gate. No JS pointer math. + if (overlayActive) { + const edge = side === 'left' ? 'left' : 'right' + const offscreen = side === 'left' ? '-translate-x-[calc(100%+1rem)]' : 'translate-x-[calc(100%+1rem)]' + + return ( +

+ + ) + } + return (
= computed($paneStates, states export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY)) export const $sidebarPinsOpen = atom(true) +// Set by the PaneShell hover-reveal overlay while the sidebar is collapsed; kept +// true the whole time it's a floating overlay (not just while shown) so the +// consumer mounts contents off-screen, ready to slide. ChatSidebar mounts its +// rows on `sidebarOpen || this`. +export const $sidebarOverlayMounted = atom(false) export const $sidebarRecentsOpen = atom(true) // Cron-job sessions live in their own section below recents, collapsed by // default (it only renders at all when cron sessions exist) so the @@ -116,6 +121,10 @@ export function setSidebarPinsOpen(open: boolean) { $sidebarPinsOpen.set(open) } +export function setSidebarOverlayMounted(mounted: boolean) { + $sidebarOverlayMounted.set(mounted) +} + export function setSidebarRecentsOpen(open: boolean) { $sidebarRecentsOpen.set(open) } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index fc7d3a03bf9..4dc57fb1c69 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -888,52 +888,42 @@ canvas { } .fit-text { + --fit-captured-length: initial; + --fit-support-sentinel: var(--fit-captured-length, 9999px); + display: flex; - font-size: var(--fit-text-min, 1rem); container-type: inline-size; - --captured-length: initial; - --support-sentinel: var(--captured-length, 9999px); } -.fit-text > [aria-hidden='true'] { +.fit-text > [aria-hidden] { visibility: hidden; } -.fit-text > :not([aria-hidden='true']) { +.fit-text > :not([aria-hidden]) { flex-grow: 1; container-type: inline-size; - --captured-length: 100cqi; - --available-space: var(--captured-length); + + --fit-captured-length: 100cqi; + --fit-available-space: var(--fit-captured-length); } -.fit-text > :not([aria-hidden='true']) > * { +.fit-text > :not([aria-hidden]) > * { + --fit-support-sentinel: inherit; + --fit-captured-length: 100cqi; + --fit-ratio: tan(atan2(var(--fit-available-space), var(--fit-available-space) - var(--fit-captured-length))); + display: block; - inline-size: var(--available-space); - line-height: var(--fit-text-line-height, 1); - --support-sentinel: inherit; - --captured-length: 100cqi; - --ratio: tan(atan2(var(--available-space), var(--available-space) - var(--captured-length))); - --font-size: clamp( - var(--fit-text-min, 1em), - 1em * var(--ratio), - var(--fit-text-max, infinity * 1px) - var(--support-sentinel) - ); - font-size: var(--font-size); + 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)); } @container (inline-size > 0) { - .fit-text > :not([aria-hidden='true']) > * { + .fit-text > :not([aria-hidden]) > * { white-space: nowrap; } } -@property --captured-length { - syntax: ''; - initial-value: 0px; - inherits: true; -} - -@property --captured-length2 { +@property --fit-captured-length { syntax: ''; initial-value: 0px; inherits: true;