Fix Panel Constraints Error During Terminal Exit (#1757)

* auto-claude: subtask-1-1 - Add PANEL_CLEANUP_GRACE_PERIOD_MS constant

* auto-claude: subtask-1-2 - Implement deferred filtering logic in TerminalGrid

* fix: use ref to track cleanup timers preventing effect dependency cycle

The useEffect for grace-period cleanup had pendingCleanup in its
dependency array while also calling setPendingCleanup, causing timers
to be immediately cancelled on re-run and never fire. Replace the
state-based check with a useRef<Map> to track scheduled timers,
removing pendingCleanup from the dependency array.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: split cleanup effect so timers survive dependency changes

The useEffect cleanup runs on every dependency change, not just
unmount. This caused all grace-period timers to be cleared whenever
allTerminals changed. Split into a scheduling effect (no cleanup) and
a separate unmount-only effect. Extract shared timer-clearing logic
into a reusable callback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Test User <test@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-02-06 23:50:00 +01:00
committed by GitHub
parent 2db36982fb
commit 32bf353da3
2 changed files with 77 additions and 7 deletions
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Group, Group,
@@ -36,7 +36,7 @@ import { cn } from '../lib/utils';
import { useTerminalStore } from '../stores/terminal-store'; import { useTerminalStore } from '../stores/terminal-store';
import { useTaskStore } from '../stores/task-store'; import { useTaskStore } from '../stores/task-store';
import { useFileExplorerStore } from '../stores/file-explorer-store'; import { useFileExplorerStore } from '../stores/file-explorer-store';
import { TERMINAL_DOM_UPDATE_DELAY_MS } from '../../shared/constants'; import { TERMINAL_DOM_UPDATE_DELAY_MS, PANEL_CLEANUP_GRACE_PERIOD_MS } from '../../shared/constants';
import type { SessionDateInfo } from '../../shared/types'; import type { SessionDateInfo } from '../../shared/types';
interface TerminalGridProps { interface TerminalGridProps {
@@ -48,16 +48,81 @@ interface TerminalGridProps {
export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }: TerminalGridProps) { export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }: TerminalGridProps) {
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const allTerminals = useTerminalStore((state) => state.terminals); const allTerminals = useTerminalStore((state) => state.terminals);
// Track terminals that are in the grace period before being filtered out
// Map of terminal ID -> timestamp when it was marked for cleanup
const [pendingCleanup, setPendingCleanup] = useState<Map<string, number>>(new Map());
// Ref to track active cleanup timers — avoids including pendingCleanup in effect deps
const cleanupTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
// Helper to clear all active cleanup timers
const clearAllCleanupTimers = useCallback(() => {
for (const timer of cleanupTimersRef.current.values()) {
clearTimeout(timer);
}
cleanupTimersRef.current.clear();
}, []);
// Filter terminals to show only those belonging to the current project // Filter terminals to show only those belonging to the current project
// Also include legacy terminals without projectPath (created before this change) // Also include legacy terminals without projectPath (created before this change)
// Exclude exited terminals as they are no longer functional // Keep exited terminals in DOM during grace period to allow react-resizable-panels to reconcile
const terminals = useMemo(() => { const terminals = useMemo(() => {
const filtered = projectPath const filtered = projectPath
? allTerminals.filter(t => t.projectPath === projectPath || !t.projectPath) ? allTerminals.filter(t => t.projectPath === projectPath || !t.projectPath)
: allTerminals; : allTerminals;
// Exclude exited terminals from the visible list
return filtered.filter(t => t.status !== 'exited'); // Filter out exited terminals UNLESS they are still in the grace period
return filtered.filter(t => {
if (t.status !== 'exited') {
return true; // Keep all non-exited terminals
}
// Check if this exited terminal is in grace period
const cleanupTime = pendingCleanup.get(t.id);
if (cleanupTime) {
const now = Date.now();
return now < cleanupTime; // Keep if still within grace period
}
return false; // Remove if not in grace period
});
}, [allTerminals, projectPath, pendingCleanup]);
// Manage grace period timers for exited terminals
// When a terminal exits, add it to pendingCleanup and schedule its removal
// Uses cleanupTimersRef to track scheduled timers, avoiding pendingCleanup in deps
// No cleanup function here — timers must survive dependency changes
useEffect(() => {
const filtered = projectPath
? allTerminals.filter(t => t.projectPath === projectPath || !t.projectPath)
: allTerminals;
const exitedTerminals = filtered.filter(t => t.status === 'exited');
for (const terminal of exitedTerminals) {
// Check ref (not state) to see if a timer is already scheduled
if (!cleanupTimersRef.current.has(terminal.id)) {
const cleanupTime = Date.now() + PANEL_CLEANUP_GRACE_PERIOD_MS;
setPendingCleanup(prev => new Map(prev).set(terminal.id, cleanupTime));
const timer = setTimeout(() => {
cleanupTimersRef.current.delete(terminal.id);
setPendingCleanup(prev => {
const next = new Map(prev);
next.delete(terminal.id);
return next;
});
}, PANEL_CLEANUP_GRACE_PERIOD_MS);
cleanupTimersRef.current.set(terminal.id, timer);
}
}
}, [allTerminals, projectPath]); }, [allTerminals, projectPath]);
// Clear all cleanup timers on unmount
useEffect(() => {
return clearAllCleanupTimers;
}, [clearAllCleanupTimers]);
const activeTerminalId = useTerminalStore((state) => state.activeTerminalId); const activeTerminalId = useTerminalStore((state) => state.activeTerminalId);
const addTerminal = useTerminalStore((state) => state.addTerminal); const addTerminal = useTerminalStore((state) => state.addTerminal);
const removeTerminal = useTerminalStore((state) => state.removeTerminal); const removeTerminal = useTerminalStore((state) => state.removeTerminal);
@@ -81,10 +146,12 @@ export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }:
// Expanded terminal state - when set, this terminal takes up the full grid space // Expanded terminal state - when set, this terminal takes up the full grid space
const [expandedTerminalId, setExpandedTerminalId] = useState<string | null>(null); const [expandedTerminalId, setExpandedTerminalId] = useState<string | null>(null);
// Reset expanded terminal when project changes // Reset expanded terminal and clear pending cleanup when project changes
useEffect(() => { useEffect(() => {
setExpandedTerminalId(null); setExpandedTerminalId(null);
}, []); setPendingCleanup(new Map());
clearAllCleanupTimers();
}, [projectPath, clearAllCleanupTimers]);
// Fetch available session dates when project changes // Fetch available session dates when project changes
useEffect(() => { useEffect(() => {
@@ -10,6 +10,9 @@
/** Delay for DOM updates before terminal operations (refit, resize) */ /** Delay for DOM updates before terminal operations (refit, resize) */
export const TERMINAL_DOM_UPDATE_DELAY_MS = 50; export const TERMINAL_DOM_UPDATE_DELAY_MS = 50;
/** Grace period before cleaning up error panel constraints after panel removal */
export const PANEL_CLEANUP_GRACE_PERIOD_MS = 150;
// ============================================ // ============================================
// UI Scale Constants // UI Scale Constants
// ============================================ // ============================================