Smooth sidebar transitions and reduce terminal jitter (#433)

This commit is contained in:
Neil
2026-04-09 23:50:46 -07:00
committed by GitHub
parent 8d3f640e67
commit fee1165747
7 changed files with 298 additions and 76 deletions
+29 -2
View File
@@ -1,5 +1,5 @@
/* eslint-disable max-lines */
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants'
import { isGitRepoKind } from '../../shared/repo-kind'
@@ -29,6 +29,7 @@ import type { PersistedOpenFile } from '../../shared/types'
import type { OpenFile } from './store/slices/editor'
const isMac = navigator.userAgent.includes('Mac')
const SIDEBAR_TRANSITION_MS = 200
/** Build the editor-file portion of the workspace session for persistence.
* Only edit-mode files are saved — diffs and conflict views are transient. */
@@ -126,6 +127,7 @@ function App(): React.JSX.Element {
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
const setQuickOpenVisible = useAppStore((s) => s.setQuickOpenVisible)
const isFullScreen = useAppStore((s) => s.isFullScreen)
const hasSeenInitialSidebarStateRef = useRef(false)
// Subscribe to IPC push events
useIpcEvents()
@@ -299,6 +301,27 @@ function App(): React.JSX.Element {
return () => window.removeEventListener('beforeunload', captureAndFlush)
}, [])
useEffect(() => {
if (!persistedUIReady) {
return
}
if (!hasSeenInitialSidebarStateRef.current) {
hasSeenInitialSidebarStateRef.current = true
return
}
// Why: the terminal's WebGL renderer can flash blank while the app shell
// animates sidebar widths. Broadcasting the transition window lets active
// terminals temporarily fall back to the DOM renderer just for that
// animation, then restore GPU rendering after the layout settles.
window.dispatchEvent(
new CustomEvent('orca-layout-transition', {
detail: { durationMs: SIDEBAR_TRANSITION_MS }
})
)
}, [persistedUIReady, sidebarOpen, rightSidebarOpen])
useEffect(() => {
if (!persistedUIReady) {
return
@@ -575,7 +598,11 @@ function App(): React.JSX.Element {
{activeView === 'settings' ? <Settings /> : !activeWorktreeId ? <Landing /> : null}
</div>
</div>
{showSidebar && rightSidebarOpen ? <RightSidebar /> : null}
{/* Why: the right sidebar stays mounted even while "closed" so its
width can animate from 0px to the saved width. Unmounting here made
the panel pop in abruptly instead of matching the left sidebar's
smooth expand/collapse behavior. */}
{showSidebar ? <RightSidebar /> : null}
</div>
<QuickOpen />
<ZoomOverlay />
@@ -138,15 +138,16 @@ export default function RightSidebar(): React.JSX.Element {
: visibleItems[0].id
const activityBarSideWidth = activityBarPosition === 'side' ? ACTIVITY_BAR_SIDE_WIDTH : 0
const { containerRef, isResizing, onResizeStart } = useSidebarResize<HTMLDivElement>({
isOpen: rightSidebarOpen,
width: rightSidebarWidth,
minWidth: MIN_WIDTH,
maxWidth: MAX_WIDTH,
deltaSign: -1,
renderedExtraWidth: activityBarSideWidth,
setWidth: setRightSidebarWidth
})
const { containerRef, onResizeStart, renderedOpen, contentAnimationState } =
useSidebarResize<HTMLDivElement>({
isOpen: rightSidebarOpen,
width: rightSidebarWidth,
minWidth: MIN_WIDTH,
maxWidth: MAX_WIDTH,
deltaSign: -1,
renderedExtraWidth: activityBarSideWidth,
setWidth: setRightSidebarWidth
})
const panelContent = (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden scrollbar-sleek-parent">
@@ -171,54 +172,73 @@ export default function RightSidebar(): React.JSX.Element {
return (
<div
ref={containerRef}
aria-hidden={!rightSidebarOpen}
className={cn(
'relative flex-shrink-0 flex flex-row overflow-visible',
isResizing ? 'transition-none' : 'transition-[width] duration-200'
!renderedOpen && 'pointer-events-none'
)}
>
{/* Panel content area */}
<div
className="flex flex-col flex-1 min-w-0 bg-sidebar overflow-hidden"
style={{
borderLeft: rightSidebarOpen ? '1px solid var(--sidebar-border)' : 'none'
borderLeft: renderedOpen ? '1px solid var(--sidebar-border)' : 'none'
}}
>
{activityBarPosition === 'top' ? (
/* ── Top activity bar: horizontal icon row ── */
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex items-center border-b border-border h-[33px] min-h-[33px] px-1">
<TooltipProvider delayDuration={400}>{activityBarIcons}</TooltipProvider>
</div>
</ContextMenuTrigger>
<ActivityBarPositionMenu
currentPosition={activityBarPosition}
onChangePosition={setActivityBarPosition}
/>
</ContextMenu>
) : (
/* ── Side layout: static title header ── */
<div className="flex items-center h-[33px] min-h-[33px] px-3 border-b border-border">
<span className="text-[11px] font-semibold uppercase tracking-wider text-foreground">
{visibleItems.find((item) => item.id === effectiveTab)?.title ?? ''}
</span>
</div>
)}
<div
className={cn(
'flex min-h-0 flex-1 flex-col transition-[transform,opacity] duration-200 ease-out',
contentAnimationState === 'open' || contentAnimationState === 'opening'
? 'translate-x-0 opacity-100'
: 'translate-x-3 opacity-0'
)}
>
{activityBarPosition === 'top' ? (
/* ── Top activity bar: horizontal icon row ── */
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex items-center border-b border-border h-[33px] min-h-[33px] px-1">
<TooltipProvider delayDuration={400}>{activityBarIcons}</TooltipProvider>
</div>
</ContextMenuTrigger>
<ActivityBarPositionMenu
currentPosition={activityBarPosition}
onChangePosition={setActivityBarPosition}
/>
</ContextMenu>
) : (
/* ── Side layout: static title header ── */
<div className="flex items-center h-[33px] min-h-[33px] px-3 border-b border-border">
<span className="text-[11px] font-semibold uppercase tracking-wider text-foreground">
{visibleItems.find((item) => item.id === effectiveTab)?.title ?? ''}
</span>
</div>
)}
{panelContent}
{panelContent}
</div>
{/* Resize handle on LEFT side */}
<div
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-ring/20 active:bg-ring/30 transition-colors z-10"
onMouseDown={onResizeStart}
/>
{renderedOpen ? (
<div
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-ring/20 active:bg-ring/30 transition-colors z-10"
onMouseDown={onResizeStart}
/>
) : null}
</div>
{/* Side Activity Bar (icon strip on right edge) — only for 'side' position */}
{activityBarPosition === 'side' && (
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex flex-col items-center w-10 min-w-[40px] bg-sidebar border-l border-border">
<div
className={cn(
'flex flex-col items-center w-10 min-w-[40px] bg-sidebar border-l border-border transition-[transform,opacity] duration-200 ease-out',
contentAnimationState === 'open' || contentAnimationState === 'opening'
? 'translate-x-0 opacity-100'
: 'translate-x-3 opacity-0'
)}
>
<TooltipProvider delayDuration={400}>{activityBarIcons}</TooltipProvider>
</div>
</ContextMenuTrigger>
+34 -25
View File
@@ -33,43 +33,52 @@ export default function Sidebar(): React.JSX.Element {
}
}, [repoCount, fetchAllWorktrees])
const { containerRef, isResizing, onResizeStart } = useSidebarResize<HTMLDivElement>({
isOpen: sidebarOpen,
width: sidebarWidth,
minWidth: MIN_WIDTH,
maxWidth: MAX_WIDTH,
deltaSign: 1,
setWidth: setSidebarWidth
})
const { containerRef, onResizeStart, renderedOpen, contentAnimationState } =
useSidebarResize<HTMLDivElement>({
isOpen: sidebarOpen,
width: sidebarWidth,
minWidth: MIN_WIDTH,
maxWidth: MAX_WIDTH,
deltaSign: 1,
setWidth: setSidebarWidth
})
return (
<TooltipProvider delayDuration={400}>
<div
ref={containerRef}
className={cn(
'relative flex-shrink-0 bg-sidebar flex flex-col overflow-hidden scrollbar-sleek-parent',
isResizing ? 'transition-none' : 'transition-[width] duration-200'
)}
className="relative flex-shrink-0 bg-sidebar flex flex-col overflow-hidden scrollbar-sleek-parent"
style={{
borderRight: sidebarOpen ? '1px solid var(--sidebar-border)' : 'none'
borderRight: renderedOpen ? '1px solid var(--sidebar-border)' : 'none'
}}
>
{/* Fixed controls */}
<SidebarHeader />
<SearchBar />
<GroupControls />
<div
className={cn(
'flex min-h-0 flex-1 flex-col transition-[transform,opacity] duration-200 ease-out',
contentAnimationState === 'open' || contentAnimationState === 'opening'
? 'translate-x-0 opacity-100'
: '-translate-x-3 opacity-0'
)}
>
{/* Fixed controls */}
<SidebarHeader />
<SearchBar />
<GroupControls />
{/* Virtualized scrollable list */}
<WorktreeList />
{/* Virtualized scrollable list */}
<WorktreeList />
{/* Fixed bottom toolbar */}
<SidebarToolbar />
{/* Fixed bottom toolbar */}
<SidebarToolbar />
</div>
{/* Resize handle */}
<div
className="absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-ring/20 active:bg-ring/30 transition-colors z-10"
onMouseDown={onResizeStart}
/>
{renderedOpen ? (
<div
className="absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-ring/20 active:bg-ring/30 transition-colors z-10"
onMouseDown={onResizeStart}
/>
) : null}
</div>
{/* Dialog (rendered outside sidebar to avoid clipping) */}
@@ -1,12 +1,24 @@
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types'
function fitAndRefreshPane(pane: ManagedPane): void {
try {
pane.fitAddon.fit()
// Why: width animations from the left/right sidebars can leave xterm's
// renderer showing only the pane background until some later paint or PTY
// write arrives. Forcing a viewport refresh after fit keeps the existing
// scrollback visible throughout the transition instead of flashing blank.
if (pane.terminal.rows > 0) {
pane.terminal.refresh(0, pane.terminal.rows - 1)
}
} catch {
/* ignore */
}
}
export function fitPanes(manager: PaneManager): void {
for (const pane of manager.getPanes()) {
try {
pane.fitAddon.fit()
} catch {
/* ignore */
}
fitAndRefreshPane(pane)
}
}
@@ -120,6 +120,56 @@ export function useTerminalPaneGlobalEffects({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive])
useEffect(() => {
if (!isActive) {
return
}
let restoreTimer: number | null = null
const restoreGpuRendering = (): void => {
if (restoreTimer !== null) {
window.clearTimeout(restoreTimer)
restoreTimer = null
}
const manager = managerRef.current
if (!manager) {
return
}
manager.resumeRendering()
fitPanes(manager)
}
const onLayoutTransition = (event: Event): void => {
const durationMs = Math.max(
0,
((event as CustomEvent<{ durationMs?: number }>).detail?.durationMs ?? 0) + 34
)
const manager = managerRef.current
if (!manager) {
return
}
// Why: xterm's WebGL renderer can briefly clear to the pane background
// during animated sidebar width changes even though the terminal itself
// is still alive. Suspending GPU rendering for just the transition window
// keeps the DOM renderer in charge while the layout is unstable, then we
// restore WebGL after the animation finishes.
manager.suspendRendering()
fitPanes(manager)
if (restoreTimer !== null) {
window.clearTimeout(restoreTimer)
}
restoreTimer = window.setTimeout(restoreGpuRendering, durationMs)
}
window.addEventListener('orca-layout-transition', onLayoutTransition)
return () => {
window.removeEventListener('orca-layout-transition', onLayoutTransition)
if (restoreTimer !== null) {
window.clearTimeout(restoreTimer)
}
}
}, [isActive, managerRef])
useEffect(() => {
return window.api.ui.onFileDrop(({ path, target }) => {
if (!isActiveRef.current || target !== 'terminal') {
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'
import {
clampSidebarResizeWidth,
getNextSidebarResizeWidth,
getRenderedSidebarWidthCssValue
getRenderedSidebarWidthCssValue,
getRenderedSidebarWidthPx,
interpolateSidebarAnimationWidth
} from './useSidebarResize'
describe('useSidebarResize helpers', () => {
@@ -63,8 +65,19 @@ describe('useSidebarResize helpers', () => {
})
it('renders closed sidebars at zero width and open sidebars with extra width', () => {
expect(getRenderedSidebarWidthPx(false, 280, 40)).toBe(0)
expect(getRenderedSidebarWidthPx(true, 280, 0)).toBe(280)
expect(getRenderedSidebarWidthPx(true, 280, 40)).toBe(320)
expect(getRenderedSidebarWidthCssValue(false, 280, 40)).toBe('0px')
expect(getRenderedSidebarWidthCssValue(true, 280, 0)).toBe('280px')
expect(getRenderedSidebarWidthCssValue(true, 280, 40)).toBe('320px')
})
it('interpolates sidebar toggle widths with clamped easing progress', () => {
expect(interpolateSidebarAnimationWidth(0, 320, -1)).toBe(0)
expect(interpolateSidebarAnimationWidth(0, 320, 0)).toBe(0)
expect(interpolateSidebarAnimationWidth(0, 320, 1)).toBe(320)
expect(interpolateSidebarAnimationWidth(0, 320, 2)).toBe(320)
expect(interpolateSidebarAnimationWidth(0, 320, 0.5)).toBe(160)
})
})
+96 -5
View File
@@ -1,5 +1,9 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
const SIDEBAR_TOGGLE_ANIMATION_MS = 200
export type SidebarContentAnimationState = 'opening' | 'open' | 'closing' | 'closed'
type UseSidebarResizeOptions = {
isOpen: boolean
width: number
@@ -14,6 +18,8 @@ type UseSidebarResizeResult<T extends HTMLElement> = {
containerRef: React.RefObject<T | null>
isResizing: boolean
onResizeStart: (event: React.MouseEvent) => void
renderedOpen: boolean
contentAnimationState: SidebarContentAnimationState
}
export function clampSidebarResizeWidth(width: number, minWidth: number, maxWidth: number): number {
@@ -25,7 +31,28 @@ export function getRenderedSidebarWidthCssValue(
width: number,
renderedExtraWidth: number
): string {
return isOpen ? `${width + renderedExtraWidth}px` : '0px'
return `${getRenderedSidebarWidthPx(isOpen, width, renderedExtraWidth)}px`
}
export function getRenderedSidebarWidthPx(
isOpen: boolean,
width: number,
renderedExtraWidth: number
): number {
return isOpen ? width + renderedExtraWidth : 0
}
export function interpolateSidebarAnimationWidth(
startWidth: number,
endWidth: number,
progress: number
): number {
const clampedProgress = Math.min(1, Math.max(0, progress))
const easedProgress =
clampedProgress < 0.5
? 4 * clampedProgress * clampedProgress * clampedProgress
: 1 - Math.pow(-2 * clampedProgress + 2, 3) / 2
return startWidth + (endWidth - startWidth) * easedProgress
}
export function getNextSidebarResizeWidth({
@@ -62,7 +89,13 @@ export function useSidebarResize<T extends HTMLElement>({
const startWidthRef = useRef(width)
const draftWidthRef = useRef(width)
const frameRef = useRef<number | null>(null)
const closeTimerRef = useRef<number | null>(null)
const openFrameRef = useRef<number | null>(null)
const [isResizing, setIsResizing] = useState(false)
const [renderedOpen, setRenderedOpen] = useState(isOpen)
const [contentAnimationState, setContentAnimationState] = useState<SidebarContentAnimationState>(
isOpen ? 'open' : 'closed'
)
const resetDocumentStyles = useCallback(() => {
document.body.style.cursor = ''
@@ -70,7 +103,7 @@ export function useSidebarResize<T extends HTMLElement>({
}, [])
const applyRenderedWidth = useCallback(
(nextWidth: number) => {
(nextWidth: number, nextIsOpen: boolean = renderedOpen) => {
const container = containerRef.current
if (!container) {
return
@@ -80,9 +113,13 @@ export function useSidebarResize<T extends HTMLElement>({
// React props. Any unrelated rerender during a drag would otherwise
// snap the DOM width back to the last persisted store value and make the
// handle feel like it is lagging behind the pointer.
container.style.width = getRenderedSidebarWidthCssValue(isOpen, nextWidth, renderedExtraWidth)
container.style.width = getRenderedSidebarWidthCssValue(
nextIsOpen,
nextWidth,
renderedExtraWidth
)
},
[isOpen, renderedExtraWidth]
[renderedOpen, renderedExtraWidth]
)
useLayoutEffect(() => {
@@ -94,6 +131,52 @@ export function useSidebarResize<T extends HTMLElement>({
applyRenderedWidth(width)
}, [applyRenderedWidth, width])
useEffect(() => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
if (openFrameRef.current !== null) {
cancelAnimationFrame(openFrameRef.current)
openFrameRef.current = null
}
if (isOpen) {
setRenderedOpen(true)
setContentAnimationState((current) => (current === 'open' ? current : 'opening'))
// Why: opening reserves the layout width immediately so the terminal
// resizes once, then animates only the sidebar's inner content. This
// avoids the terminal blanking seen during continuous shell-width
// animation while still giving the sidebar a smooth visual transition.
openFrameRef.current = window.requestAnimationFrame(() => {
openFrameRef.current = null
setContentAnimationState('open')
})
return
}
setContentAnimationState((current) => (current === 'closed' ? current : 'closing'))
// Why: keep the sidebar's layout width alive during the exit animation so
// the user sees content slide/fade away first, then release the space at
// the end. The terminal only snaps once when the sidebar is fully gone.
closeTimerRef.current = window.setTimeout(() => {
closeTimerRef.current = null
setRenderedOpen(false)
setContentAnimationState('closed')
}, SIDEBAR_TOGGLE_ANIMATION_MS)
return () => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
if (openFrameRef.current !== null) {
cancelAnimationFrame(openFrameRef.current)
openFrameRef.current = null
}
}
}, [isOpen])
const stopResize = useCallback(() => {
if (!isResizingRef.current) {
return
@@ -162,6 +245,14 @@ export function useSidebarResize<T extends HTMLElement>({
frameRef.current = null
}
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
if (openFrameRef.current !== null) {
cancelAnimationFrame(openFrameRef.current)
openFrameRef.current = null
}
isResizingRef.current = false
resetDocumentStyles()
}
@@ -181,5 +272,5 @@ export function useSidebarResize<T extends HTMLElement>({
[width]
)
return { containerRef, isResizing, onResizeStart }
return { containerRef, isResizing, onResizeStart, renderedOpen, contentAnimationState }
}