diff --git a/apps/frontend/src/renderer/components/Terminal.tsx b/apps/frontend/src/renderer/components/Terminal.tsx index f7910749..7feefd61 100644 --- a/apps/frontend/src/renderer/components/Terminal.tsx +++ b/apps/frontend/src/renderer/components/Terminal.tsx @@ -8,7 +8,6 @@ import { useSettingsStore } from '../stores/settings-store'; import { useToast } from '../hooks/use-toast'; import type { TerminalProps } from './terminal/types'; import type { TerminalWorktreeConfig } from '../../shared/types'; -import { TERMINAL_DOM_UPDATE_DELAY_MS } from '../../shared/constants'; import { TerminalHeader } from './terminal/TerminalHeader'; import { CreateWorktreeDialog } from './terminal/CreateWorktreeDialog'; import { useXterm } from './terminal/useXterm'; @@ -210,11 +209,113 @@ export const Terminal = forwardRef(function Termi }, [isActive, focus]); // Refit terminal when expansion state changes + // Uses transitionend event listener and RAF-based retry logic instead of fixed timeout + // for more reliable resizing after CSS transitions complete useEffect(() => { - const timeoutId = setTimeout(() => { - fit(); - }, TERMINAL_DOM_UPDATE_DELAY_MS); - return () => clearTimeout(timeoutId); + // RAF fallback for test environments where requestAnimationFrame may not be defined + const raf = typeof requestAnimationFrame !== 'undefined' + ? requestAnimationFrame + : (cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 0) as unknown as number; + + const cancelRaf = typeof cancelAnimationFrame !== 'undefined' + ? cancelAnimationFrame + : (id: number) => clearTimeout(id); + + let rafId: number | null = null; + let retryTimeoutId: ReturnType | null = null; + let fallbackTimeoutId: ReturnType | null = null; + let isCleanedUp = false; + let fitSucceeded = false; + let retryCount = 0; + const MAX_RETRIES = 5; + const RETRY_DELAY_MS = 50; + const FALLBACK_TIMEOUT_MS = 300; + + // Perform fit with RAF and retry logic, following the pattern from useXterm.ts performInitialFit + const performFit = () => { + if (isCleanedUp) return; + + // Cancel any existing RAF to prevent multiple concurrent fit attempts + if (rafId !== null) { + cancelRaf(rafId); + rafId = null; + } + + rafId = raf(() => { + if (isCleanedUp) return; + + // fit() returns boolean indicating success (true if container had valid dimensions) + const success = fit(); + + if (success) { + fitSucceeded = true; + } else if (retryCount < MAX_RETRIES) { + // Container not ready yet, retry after a short delay + retryCount++; + retryTimeoutId = setTimeout(performFit, RETRY_DELAY_MS); + } + }); + }; + + // Get terminal container element for transition listening + const container = terminalRef.current; + + // Handler for transitionend event - fits terminal after CSS transition completes + const handleTransitionEnd = (e: TransitionEvent) => { + // Only react to relevant transitions (height, width, flex changes) + const relevantProps = ['height', 'width', 'flex', 'max-height', 'max-width']; + if (relevantProps.some(prop => e.propertyName.includes(prop))) { + // Reset retry count and success flag for new transition + retryCount = 0; + fitSucceeded = false; + performFit(); + } + }; + + // Listen for transitionend on the terminal container and its parent + // (expansion may trigger transitions on either element) + if (container) { + container.addEventListener('transitionend', handleTransitionEnd); + container.parentElement?.addEventListener('transitionend', handleTransitionEnd); + } + + // Start the fit process immediately with RAF-based retry + // This handles cases where expansion is instant (no CSS transition) + performFit(); + + // Fallback timeout to ensure fit happens even if transitionend doesn't fire + // This is a safety net for edge cases + fallbackTimeoutId = setTimeout(() => { + if (!isCleanedUp && !fitSucceeded) { + retryCount = 0; + performFit(); + } + }, FALLBACK_TIMEOUT_MS); + + return () => { + isCleanedUp = true; + + // Clean up RAF + if (rafId !== null) { + cancelRaf(rafId); + } + + // Clean up retry timeout + if (retryTimeoutId !== null) { + clearTimeout(retryTimeoutId); + } + + // Clean up fallback timeout + if (fallbackTimeoutId !== null) { + clearTimeout(fallbackTimeoutId); + } + + // Remove event listeners + if (container) { + container.removeEventListener('transitionend', handleTransitionEnd); + container.parentElement?.removeEventListener('transitionend', handleTransitionEnd); + } + }; }, [isExpanded, fit]); // Trigger deferred Claude resume when terminal becomes active diff --git a/apps/frontend/src/renderer/components/terminal/useXterm.ts b/apps/frontend/src/renderer/components/terminal/useXterm.ts index 391aeb32..a9996b3e 100644 --- a/apps/frontend/src/renderer/components/terminal/useXterm.ts +++ b/apps/frontend/src/renderer/components/terminal/useXterm.ts @@ -24,6 +24,38 @@ interface UseXtermOptions { onDimensionsReady?: (cols: number, rows: number) => void; } +/** + * Return type for the useXterm hook. + * Provides terminal control methods and state. + */ +export interface UseXtermReturn { + /** Ref to attach to the terminal container div */ + terminalRef: React.RefObject; + /** Ref to the xterm.js Terminal instance */ + xtermRef: React.MutableRefObject; + /** Ref to the FitAddon instance */ + fitAddonRef: React.MutableRefObject; + /** + * Fit the terminal content to the container dimensions. + * @returns boolean indicating whether fit was successful (had valid dimensions) + */ + fit: () => boolean; + /** Write data to the terminal */ + write: (data: string) => void; + /** Write a line to the terminal */ + writeln: (data: string) => void; + /** Focus the terminal */ + focus: () => void; + /** Dispose of the terminal and clean up resources */ + dispose: () => void; + /** Current number of columns */ + cols: number; + /** Current number of rows */ + rows: number; + /** Whether dimensions have been measured and are ready */ + dimensionsReady: boolean; +} + // Debounce helper function function debounce void>(fn: T, ms: number): T { let timeoutId: ReturnType | null = null; @@ -33,7 +65,7 @@ function debounce void>(fn: T, ms: number): T }) as T; } -export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsReady }: UseXtermOptions) { +export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsReady }: UseXtermOptions): UseXtermReturn { const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); @@ -379,10 +411,23 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea return () => window.removeEventListener('terminal-refit-all', handleRefitAll); }, []); - const fit = useCallback(() => { - if (fitAddonRef.current && xtermRef.current) { - fitAddonRef.current.fit(); + /** + * Fit the terminal content to the container dimensions. + * @returns boolean indicating whether fit was successful (had valid dimensions) + */ + const fit = useCallback((): boolean => { + if (fitAddonRef.current && xtermRef.current && terminalRef.current) { + // Validate container has valid dimensions before fitting + const rect = terminalRef.current.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + fitAddonRef.current.fit(); + const cols = xtermRef.current.cols; + const rows = xtermRef.current.rows; + setDimensions({ cols, rows }); + return true; + } } + return false; }, []); const write = useCallback((data: string) => {