diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4fa2418fa50..989d7abb862 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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' ? : !activeWorktreeId ? : null} - {showSidebar && rightSidebarOpen ? : 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 ? : null} diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index 482f6961f2a..a3227964f30 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -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({ - isOpen: rightSidebarOpen, - width: rightSidebarWidth, - minWidth: MIN_WIDTH, - maxWidth: MAX_WIDTH, - deltaSign: -1, - renderedExtraWidth: activityBarSideWidth, - setWidth: setRightSidebarWidth - }) + const { containerRef, onResizeStart, renderedOpen, contentAnimationState } = + useSidebarResize({ + isOpen: rightSidebarOpen, + width: rightSidebarWidth, + minWidth: MIN_WIDTH, + maxWidth: MAX_WIDTH, + deltaSign: -1, + renderedExtraWidth: activityBarSideWidth, + setWidth: setRightSidebarWidth + }) const panelContent = (
@@ -171,54 +172,73 @@ export default function RightSidebar(): React.JSX.Element { return (
{/* Panel content area */}
- {activityBarPosition === 'top' ? ( - /* ── Top activity bar: horizontal icon row ── */ - - -
- {activityBarIcons} -
-
- -
- ) : ( - /* ── Side layout: static title header ── */ -
- - {visibleItems.find((item) => item.id === effectiveTab)?.title ?? ''} - -
- )} +
+ {activityBarPosition === 'top' ? ( + /* ── Top activity bar: horizontal icon row ── */ + + +
+ {activityBarIcons} +
+
+ +
+ ) : ( + /* ── Side layout: static title header ── */ +
+ + {visibleItems.find((item) => item.id === effectiveTab)?.title ?? ''} + +
+ )} - {panelContent} + {panelContent} +
{/* Resize handle on LEFT side */} -
+ {renderedOpen ? ( +
+ ) : null}
{/* Side Activity Bar (icon strip on right edge) — only for 'side' position */} {activityBarPosition === 'side' && ( -
+
{activityBarIcons}
diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index fdf184e9065..b9eef83f899 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -33,43 +33,52 @@ export default function Sidebar(): React.JSX.Element { } }, [repoCount, fetchAllWorktrees]) - const { containerRef, isResizing, onResizeStart } = useSidebarResize({ - isOpen: sidebarOpen, - width: sidebarWidth, - minWidth: MIN_WIDTH, - maxWidth: MAX_WIDTH, - deltaSign: 1, - setWidth: setSidebarWidth - }) + const { containerRef, onResizeStart, renderedOpen, contentAnimationState } = + useSidebarResize({ + isOpen: sidebarOpen, + width: sidebarWidth, + minWidth: MIN_WIDTH, + maxWidth: MAX_WIDTH, + deltaSign: 1, + setWidth: setSidebarWidth + }) return (
- {/* Fixed controls */} - - - +
+ {/* Fixed controls */} + + + - {/* Virtualized scrollable list */} - + {/* Virtualized scrollable list */} + - {/* Fixed bottom toolbar */} - + {/* Fixed bottom toolbar */} + +
{/* Resize handle */} -
+ {renderedOpen ? ( +
+ ) : null}
{/* Dialog (rendered outside sidebar to avoid clipping) */} diff --git a/src/renderer/src/components/terminal-pane/pane-helpers.ts b/src/renderer/src/components/terminal-pane/pane-helpers.ts index 5a78c18e42f..f70c0e09120 100644 --- a/src/renderer/src/components/terminal-pane/pane-helpers.ts +++ b/src/renderer/src/components/terminal-pane/pane-helpers.ts @@ -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) } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index f6efa8d638b..5666abb5899 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -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') { diff --git a/src/renderer/src/hooks/useSidebarResize.test.ts b/src/renderer/src/hooks/useSidebarResize.test.ts index 916b941b2a2..6ffb1759292 100644 --- a/src/renderer/src/hooks/useSidebarResize.test.ts +++ b/src/renderer/src/hooks/useSidebarResize.test.ts @@ -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) + }) }) diff --git a/src/renderer/src/hooks/useSidebarResize.ts b/src/renderer/src/hooks/useSidebarResize.ts index 437f444b1c5..a57bb1f74fa 100644 --- a/src/renderer/src/hooks/useSidebarResize.ts +++ b/src/renderer/src/hooks/useSidebarResize.ts @@ -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 = { containerRef: React.RefObject 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({ const startWidthRef = useRef(width) const draftWidthRef = useRef(width) const frameRef = useRef(null) + const closeTimerRef = useRef(null) + const openFrameRef = useRef(null) const [isResizing, setIsResizing] = useState(false) + const [renderedOpen, setRenderedOpen] = useState(isOpen) + const [contentAnimationState, setContentAnimationState] = useState( + isOpen ? 'open' : 'closed' + ) const resetDocumentStyles = useCallback(() => { document.body.style.cursor = '' @@ -70,7 +103,7 @@ export function useSidebarResize({ }, []) const applyRenderedWidth = useCallback( - (nextWidth: number) => { + (nextWidth: number, nextIsOpen: boolean = renderedOpen) => { const container = containerRef.current if (!container) { return @@ -80,9 +113,13 @@ export function useSidebarResize({ // 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({ 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({ 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({ [width] ) - return { containerRef, isResizing, onResizeStart } + return { containerRef, isResizing, onResizeStart, renderedOpen, contentAnimationState } }