feat(desktop): vertical resize for the bottom-row terminal pane

Extends the pane store with heightOverride (alongside widthOverride) and a
get/set/clear API, and wires the pane shell + desktop controller so the
bottom-row terminal pane can be resized on the Y axis with its size persisted.
This commit is contained in:
Brooklyn Nicholson 2026-06-25 19:50:29 -05:00
parent ff81365988
commit 1f950e189c
4 changed files with 199 additions and 45 deletions

View file

@ -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 = (
<Pane
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
@ -1277,18 +1292,31 @@ export function DesktopController() {
const terminalPane = (
<Pane
bottomRow={terminalAsRow}
defaultOpen
disabled={!terminalSidebarOpen}
divider
height="38vh"
id="terminal-sidebar"
key="terminal-sidebar"
maxHeight="80vh"
maxWidth="80vw"
minHeight="8rem"
minWidth="22vw"
resizable
side={railSide}
width="42vw"
>
<div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-editor-surface-background) pt-(--titlebar-height)">
{/* 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. */}
<div
className={cn(
'relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-editor-surface-background)',
terminalAsRow ? 'border-l border-(--ui-stroke-secondary) pt-0' : 'pt-(--titlebar-height)'
)}
>
<TerminalSlot />
</div>
</Pane>

View file

@ -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 {

View file

@ -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<string, { open: boolean; widthOverride?: number }>) {
type PaneStoreState = Record<string, { open: boolean; widthOverride?: number; heightOverride?: number }>
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<string, { open: boolea
return { open: true, track: override !== undefined ? `${override}px` : pane.width }
}
function heightTrackForPane(pane: CollectedPane, states: PaneStoreState) {
const override = pane.resizable ? states[pane.id]?.heightOverride : undefined
return override !== undefined ? `${override}px` : pane.height
}
export function PaneShell({ children, className, style }: PaneShellProps) {
const paneStates = useStore($paneStates)
const { left, mainCount, right } = useMemo(() => collectPanes(children), [children])
@ -197,34 +248,65 @@ export function PaneShell({ children, className, style }: PaneShellProps) {
const cssVars: Record<string, string> = {}
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<string, string>
gridTemplate: string
gridTemplateRows: string
}
}, [left, paneStates, right])
const composedStyle = useMemo<CSSProperties>(
() => ({ ...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<HTMLDivElement>) => {
const paneWidth = paneRef.current?.getBoundingClientRect().width ?? 0
(event: ReactPointerEvent<HTMLDivElement>, 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 (
<div
className={cn('group/reveal pointer-events-none relative row-start-1 min-w-0', className)}
className={cn('group/reveal pointer-events-none relative min-w-0', className)}
data-forced={forced ? '' : undefined}
data-pane-hover-reveal={forced ? 'open' : 'closed'}
data-pane-id={id}
data-pane-open="false"
data-pane-side={side}
ref={paneRef}
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
style={{ gridColumn: slot.gridColumn, gridRow: slot.gridRow }}
>
<div
aria-hidden="true"
@ -423,27 +518,33 @@ export function Pane({
return (
<div
aria-hidden={!open}
className={cn('relative row-start-1 min-w-0 overflow-hidden', !open && 'pointer-events-none', className)}
className={cn('relative min-h-0 min-w-0 overflow-hidden', !open && 'pointer-events-none', className)}
data-pane-id={id}
data-pane-open={open ? 'true' : 'false'}
data-pane-side={slot.side}
ref={paneRef}
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
style={{ gridColumn: slot.gridColumn, gridRow: slot.gridRow }}
>
{canResize && (
<div
aria-label={`Resize ${id}`}
aria-orientation="vertical"
aria-orientation={sash.orientation}
className={cn(
'group absolute bottom-0 top-0 z-20 w-1 cursor-col-resize [-webkit-app-region:no-drag]',
slot.side === 'left' ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2'
'group absolute z-20 [-webkit-app-region:no-drag]',
sash.bar,
!isBottomRow && (slot.side === 'left' ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2')
)}
onPointerDown={startResize}
onPointerDown={e => startResize(e, axis)}
role="separator"
tabIndex={0}
>
{divider && <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-(--ui-stroke-secondary)" />}
<span className="absolute inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2 bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100" />
{divider && <span className={cn('absolute bg-(--ui-stroke-secondary)', sash.line)} />}
<span
className={cn(
'absolute bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100',
sash.hover
)}
/>
</div>
)}
{children}
@ -466,9 +567,9 @@ export function PaneMain({ children, className }: PaneMainProps) {
return (
<div
className={cn('row-start-1 flex min-h-0 min-w-0 flex-col overflow-hidden', className)}
className={cn('flex min-h-0 min-w-0 flex-col overflow-hidden', className)}
data-pane-main="true"
style={{ gridColumn: `${ctx.mainColumn} / ${ctx.mainColumn + 1}` }}
style={{ gridColumn: `${ctx.mainColumn} / ${ctx.mainColumn + 1}`, gridRow: '1 / -1' }}
>
{children}
</div>

View file

@ -3,6 +3,8 @@ import { atom, computed, type ReadableAtom } from 'nanostores'
export interface PaneStateSnapshot {
open: boolean
widthOverride?: number
/** Vertical size override (px) for panes that resize on the Y axis (e.g. the bottom-row terminal). */
heightOverride?: number
}
export interface PaneRegisterDefaults {
@ -23,7 +25,11 @@ function isSnapshot(value: unknown): value is PaneStateSnapshot {
return false
}
return r.widthOverride === undefined || (typeof r.widthOverride === 'number' && Number.isFinite(r.widthOverride))
const widthOk = r.widthOverride === undefined || (typeof r.widthOverride === 'number' && Number.isFinite(r.widthOverride))
const heightOk =
r.heightOverride === undefined || (typeof r.heightOverride === 'number' && Number.isFinite(r.heightOverride))
return widthOk && heightOk
}
function load(): Record<string, PaneStateSnapshot> {
@ -42,7 +48,7 @@ function load(): Record<string, PaneStateSnapshot> {
for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
if (isSnapshot(value)) {
out[id] = { open: value.open, widthOverride: value.widthOverride }
out[id] = { open: value.open, widthOverride: value.widthOverride, heightOverride: value.heightOverride }
}
}
@ -92,10 +98,12 @@ function memoized<T>(
const openCache = new Map<string, ReadableAtom<boolean>>()
const stateCache = new Map<string, ReadableAtom<PaneStateSnapshot | undefined>>()
const widthCache = new Map<string, ReadableAtom<number | undefined>>()
const heightCache = new Map<string, ReadableAtom<number | undefined>>()
export const $paneOpen = (id: string) => memoized(openCache, id, s => s?.open ?? false)
export const $paneState = (id: string) => memoized(stateCache, id, s => s)
export const $paneWidthOverride = (id: string) => memoized(widthCache, id, s => s?.widthOverride)
export const $paneHeightOverride = (id: string) => memoized(heightCache, id, s => s?.heightOverride)
export function ensurePaneRegistered(id: string, defaults: PaneRegisterDefaults) {
const current = $paneStates.get()
@ -115,13 +123,13 @@ export function setPaneOpen(id: string, open: boolean) {
return
}
$paneStates.set({ ...current, [id]: { open, widthOverride: existing?.widthOverride } })
$paneStates.set({ ...current, [id]: { ...existing, open } })
}
export function togglePane(id: string) {
const current = $paneStates.get()
const existing = current[id]
$paneStates.set({ ...current, [id]: { open: !(existing?.open ?? false), widthOverride: existing?.widthOverride } })
$paneStates.set({ ...current, [id]: { ...existing, open: !(existing?.open ?? false) } })
}
export function setPaneWidthOverride(id: string, width: number | undefined) {
@ -132,8 +140,20 @@ export function setPaneWidthOverride(id: string, width: number | undefined) {
return
}
$paneStates.set({ ...current, [id]: { open: existing.open, widthOverride: width } })
$paneStates.set({ ...current, [id]: { ...existing, widthOverride: width } })
}
export function setPaneHeightOverride(id: string, height: number | undefined) {
const current = $paneStates.get()
const existing = current[id] ?? { open: false }
if (existing.heightOverride === height) {
return
}
$paneStates.set({ ...current, [id]: { ...existing, heightOverride: height } })
}
export const clearPaneWidthOverride = (id: string) => setPaneWidthOverride(id, undefined)
export const clearPaneHeightOverride = (id: string) => setPaneHeightOverride(id, undefined)
export const getPaneStateSnapshot = (id: string) => $paneStates.get()[id]