diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 812be0a4867..b656afe0743 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -10,6 +10,7 @@ import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overla import { Pane, PaneMain } from '@/components/pane-shell' import { RemoteDisplayBanner } from '@/components/remote-display-banner' import { useMediaQuery } from '@/hooks/use-media-query' +import { cn } from '@/lib/utils' import { useSkinCommand } from '@/themes/use-skin-command' import { formatRefValue } from '../components/assistant-ui/directive-text' @@ -25,6 +26,7 @@ import { import { latestSessionTodos } from '../lib/todos' import { setCronFocusJobId, setCronJobs } from '../store/cron' import { + $fileBrowserOpen, $panesFlipped, $pinnedSessionIds, $sessionsLimit, @@ -41,6 +43,7 @@ import { SIDEBAR_SESSIONS_PAGE_SIZE, unpinSession } from '../store/layout' +import { $paneOpen } from '../store/panes' import { respondToApprovalAction } from '../store/native-notifications' import { setPetActivity } from '../store/pet' import { setPetOverlayOpenAppHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay' @@ -223,6 +226,8 @@ export function DesktopController() { const selectedStoredSessionId = useStore($selectedStoredSessionId) const terminalTakeover = useStore($terminalTakeover) const reviewOpen = useStore($reviewOpen) + const fileBrowserOpen = useStore($fileBrowserOpen) + const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID)) const panesFlipped = useStore($panesFlipped) const profileScope = useStore($profileScope) // Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail — @@ -1207,6 +1212,16 @@ export function DesktopController() { const sidebarSide = panesFlipped ? 'right' : 'left' const railSide = panesFlipped ? 'left' : 'right' + // Other sidebars docked as real columns on the terminal's rail. Force-collapsed + // hover-reveal overlays (narrow window) don't take a column, so they don't count. + const railColumnOpen = + (chatOpen && Boolean(previewTarget || filePreviewTarget) && previewPaneOpen) || + (chatOpen && !narrowViewport && fileBrowserOpen) || + (chatOpen && Boolean(currentCwd.trim()) && !narrowViewport && reviewOpen) + // Once the terminal would share its rail with another sidebar, drop it to a + // full-width row beneath them rather than cramming in one more skinny column. + const terminalAsRow = terminalSidebarOpen && railColumnOpen + const previewPane = ( -
+ {/* As a column the terminal clears the titlebar; as a bottom row it sits + below the rail's panes (so it fills its row edge-to-edge) and gets a + left border separating it from the chat — the column-mode separator + lives on the resize sash, which moves to the top edge as a row. */} +
diff --git a/apps/desktop/src/components/pane-shell/context.ts b/apps/desktop/src/components/pane-shell/context.ts index 2fa3738a791..b5bb3a907ac 100644 --- a/apps/desktop/src/components/pane-shell/context.ts +++ b/apps/desktop/src/components/pane-shell/context.ts @@ -1,9 +1,14 @@ import { createContext } from 'react' export interface PaneSlot { - column: number side: 'left' | 'right' open: boolean + /** Resolved CSS `grid-column` value (e.g. "3 / 4", or a full-side span for a bottom-row pane). */ + gridColumn: string + /** Resolved CSS `grid-row` value ("1 / -1" full-height, "1 / 2" above a bottom row, "2 / 3" the row itself). */ + gridRow: string + /** True when this pane lays out as a horizontal row beneath its rail instead of a vertical column. */ + bottomRow: boolean } export interface PaneShellContextValue { diff --git a/apps/desktop/src/components/pane-shell/pane-shell.tsx b/apps/desktop/src/components/pane-shell/pane-shell.tsx index 25ca6d03e41..43df930a5bc 100644 --- a/apps/desktop/src/components/pane-shell/pane-shell.tsx +++ b/apps/desktop/src/components/pane-shell/pane-shell.tsx @@ -15,7 +15,7 @@ import { } from 'react' import { cn } from '@/lib/utils' -import { $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes' +import { $paneStates, ensurePaneRegistered, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes' import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context' @@ -38,6 +38,17 @@ export interface PaneProps { forceCollapsed?: boolean /** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */ hoverReveal?: boolean + /** + * Lay the pane out as a horizontal row beneath its rail (spanning every column on + * its `side`) instead of as a vertical column. The pane then resizes on the Y axis. + * Used to drop the terminal under a crowded rail rather than squeezing another column in. + */ + bottomRow?: boolean + /** Default height of a `bottomRow` pane. */ + height?: WidthValue + /** Min/max height clamps for a `bottomRow` pane's vertical resize. */ + maxHeight?: WidthValue + minHeight?: WidthValue /** Width of the collapsed-overlay panel. Defaults to the docked width (or its resize override); set this to render a narrower overlay than the docked pane (e.g. min width on mobile). */ overlayWidth?: WidthValue /** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */ @@ -62,9 +73,11 @@ export interface PaneShellProps { } interface CollectedPane { + bottomRow: boolean defaultOpen: boolean disabled: boolean forceCollapsed: boolean + height: string id: string resizable: boolean side: PaneSide @@ -72,7 +85,26 @@ interface CollectedPane { } const DEFAULT_WIDTH = '16rem' +const DEFAULT_HEIGHT = '18rem' const DEFAULT_RESIZE_MIN_WIDTH = 160 +const DEFAULT_RESIZE_MIN_HEIGHT = 120 + +// Resize-sash geometry per axis: `x` is a vertical bar on the inner edge of a +// column; `y` is a horizontal bar on the top edge of a bottom row. +const SASH = { + x: { + orientation: 'vertical', + bar: 'bottom-0 top-0 w-1 cursor-col-resize', + line: 'inset-y-0 left-1/2 w-px -translate-x-1/2', + hover: 'inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2' + }, + y: { + orientation: 'horizontal', + bar: 'inset-x-0 top-0 h-1 -translate-y-1/2 cursor-row-resize', + line: 'inset-x-0 top-1/2 h-px -translate-y-1/2', + hover: 'inset-x-0 top-1/2 h-(--vscode-sash-hover-size,0.25rem) -translate-y-1/2' + } +} as const // 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. @@ -102,15 +134,16 @@ const remPx = () => : Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16 const viewportPx = () => (typeof window === 'undefined' ? 1280 : window.innerWidth) +const viewportHeightPx = () => (typeof window === 'undefined' ? 800 : window.innerHeight) -// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem" | "Nvw" | "N%") to -// pixels for drag clamping. Viewport units resolve against the current window width. +// Resolves PaneProps min/max (number | "Npx" | "Nrem" | "Nvw" | "Nvh" | "N%") to +// pixels for drag clamping. vw/% resolve against window width, vh against height. function widthToPx(value: WidthValue | undefined) { if (typeof value === 'number') { return Number.isFinite(value) ? value : undefined } - const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem|vw|%)?$/) + const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem|vw|vh|%)?$/) if (!match) { return undefined @@ -122,6 +155,9 @@ function widthToPx(value: WidthValue | undefined) { case 'rem': return n * remPx() + case 'vh': + return (n * viewportHeightPx()) / 100 + case 'vw': case '%': @@ -155,9 +191,11 @@ function collectPanes(children: ReactNode) { const props = child.props as PaneProps const entry: CollectedPane = { + bottomRow: props.bottomRow ?? false, defaultOpen: props.defaultOpen ?? true, disabled: props.disabled ?? false, forceCollapsed: props.forceCollapsed ?? false, + height: widthToCss(props.height, DEFAULT_HEIGHT), id: props.id, resizable: props.resizable ?? false, side: props.side, @@ -170,9 +208,16 @@ function collectPanes(children: ReactNode) { return { left, mainCount, right } } -function trackForPane(pane: CollectedPane, states: Record) { +type PaneStoreState = Record + +function paneIsOpen(pane: CollectedPane, states: PaneStoreState) { const stateOpen = states[pane.id]?.open ?? pane.defaultOpen - const open = !pane.disabled && !pane.forceCollapsed && stateOpen + + return !pane.disabled && !pane.forceCollapsed && stateOpen +} + +function trackForPane(pane: CollectedPane, states: PaneStoreState) { + const open = paneIsOpen(pane, states) if (!open) { return { open: false, track: '0px' } @@ -183,6 +228,12 @@ function trackForPane(pane: CollectedPane, states: Record collectPanes(children), [children]) @@ -197,34 +248,65 @@ export function PaneShell({ children, className, style }: PaneShellProps) { const cssVars: Record = {} let column = 1 - for (const pane of left) { + // A bottom-row pane drops out of its rail's column flow and instead spans + // every column on its side as a new row below them. The first open one wins + // and decides which rail gets split into two rows. + const leftCols = left.filter(pane => !pane.bottomRow) + const rightCols = right.filter(pane => !pane.bottomRow) + const bottomRowPanes = [...left, ...right].filter(pane => pane.bottomRow) + const activeBottomRow = bottomRowPanes.find(pane => paneIsOpen(pane, paneStates)) ?? null + const bottomRailSide = activeBottomRow?.side ?? null + + // Open column panes on the bottom row's side shrink to the top row; everything + // else (main, the other rail, closed / hover-reveal panes) stays full height. + const addColumn = (pane: CollectedPane, paneSide: PaneSide) => { const { open, track } = trackForPane(pane, paneStates) tracks.push(track) - paneById.set(pane.id, { column, open, side: 'left' }) cssVars[`--pane-${pane.id}-width`] = track + const gridRow = open && paneSide === bottomRailSide ? '1 / 2' : '1 / -1' + paneById.set(pane.id, { open, side: paneSide, gridColumn: `${column} / ${column + 1}`, gridRow, bottomRow: false }) column++ } + for (const pane of leftCols) { + addColumn(pane, 'left') + } + tracks.push('minmax(0,1fr)') const mainColumn = column++ - for (const pane of right) { - const { open, track } = trackForPane(pane, paneStates) - tracks.push(track) - paneById.set(pane.id, { column, open, side: 'right' }) - cssVars[`--pane-${pane.id}-width`] = track - column++ + for (const pane of rightCols) { + addColumn(pane, 'right') } - return { cssVars, gridTemplate: tracks.join(' '), mainColumn, paneById } satisfies PaneShellContextValue & { + // Place every bottom-row pane: span its rail's columns on the second row. + for (const pane of bottomRowPanes) { + const gridColumn = pane.side === 'left' ? `1 / ${mainColumn}` : `${mainColumn + 1} / -1` + paneById.set(pane.id, { open: pane === activeBottomRow, side: pane.side, gridColumn, gridRow: '2 / 3', bottomRow: true }) + } + + // Always emit explicit rows so `grid-row: 1 / -1` (full-height) resolves + // against a known last line. With a bottom row active there are two tracks; + // otherwise a single 1fr track behaves exactly like the old single-row grid. + const gridTemplateRows = activeBottomRow + ? `minmax(0,1fr) ${heightTrackForPane(activeBottomRow, paneStates)}` + : 'minmax(0,1fr)' + + return { cssVars, gridTemplate: tracks.join(' '), gridTemplateRows, mainColumn, paneById } satisfies PaneShellContextValue & { cssVars: Record gridTemplate: string + gridTemplateRows: string } }, [left, paneStates, right]) const composedStyle = useMemo( - () => ({ ...ctxValue.cssVars, ...style, gridTemplateColumns: ctxValue.gridTemplate }), - [ctxValue.cssVars, ctxValue.gridTemplate, style] + () => ({ + ...ctxValue.cssVars, + ...style, + gridTemplateColumns: ctxValue.gridTemplate, + gridTemplateRows: ctxValue.gridTemplateRows + }), + [ctxValue.cssVars, ctxValue.gridTemplate, ctxValue.gridTemplateRows, style] ) return ( @@ -243,6 +325,8 @@ export function Pane({ divider = false, disabled = false, hoverReveal = false, + maxHeight, + minHeight, overlayWidth: overlayWidthProp, id, maxWidth, @@ -308,33 +392,44 @@ export function Pane({ onOverlayActiveChange?.(overlayActive) }, [onOverlayActiveChange, overlayActive]) + const isBottomRow = Boolean(slot?.bottomRow) + const axis = isBottomRow ? 'y' : 'x' + const sash = SASH[axis] const canResize = open && resizable const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY + const loH = widthToPx(minHeight) ?? DEFAULT_RESIZE_MIN_HEIGHT + const hiH = widthToPx(maxHeight) ?? Number.POSITIVE_INFINITY + // One pointer-drag for both axes. Columns grow toward the main column (left + // rail → right, right rail → left); the bottom row grows up from its top edge. const startResize = useCallback( - (event: ReactPointerEvent) => { - const paneWidth = paneRef.current?.getBoundingClientRect().width ?? 0 + (event: ReactPointerEvent, axis: 'x' | 'y') => { + const rect = paneRef.current?.getBoundingClientRect() + const base = (axis === 'x' ? rect?.width : rect?.height) ?? 0 - if (!canResize || paneWidth <= 0) { + if (!canResize || base <= 0) { return } event.preventDefault() const handle = event.currentTarget - const { pointerId, clientX: startX } = event - const dir = side === 'left' ? 1 : -1 + const { pointerId } = event + const start = axis === 'x' ? event.clientX : event.clientY + const dir = axis === 'x' ? (side === 'left' ? 1 : -1) : -1 + const [min, max] = axis === 'x' ? [lo, hi] : [loH, hiH] + const apply = axis === 'x' ? setPaneWidthOverride : setPaneHeightOverride const restoreCursor = document.body.style.cursor const restoreSelect = document.body.style.userSelect handle.setPointerCapture?.(pointerId) - document.body.style.cursor = 'col-resize' + document.body.style.cursor = axis === 'x' ? 'col-resize' : 'row-resize' document.body.style.userSelect = 'none' const onMove = (e: PointerEvent) => { - const next = paneWidth + (e.clientX - startX) * dir - setPaneWidthOverride(id, Math.round(Math.min(hi, Math.max(lo, next)))) + const next = base + ((axis === 'x' ? e.clientX : e.clientY) - start) * dir + apply(id, Math.round(Math.min(max, Math.max(min, next)))) } const cleanup = () => { @@ -352,7 +447,7 @@ export function Pane({ window.addEventListener('pointercancel', cleanup, true) window.addEventListener('blur', cleanup) }, - [canResize, hi, id, lo, side] + [canResize, hi, hiH, id, lo, loH, side] ) if (!ctx) { @@ -377,14 +472,14 @@ export function Pane({ return (