feat: improve terminal creation and resizing logic

- Added a debouncing mechanism for terminal creation to prevent rapid requests.
- Enhanced terminal resizing with rate limiting and suppression of output during resize to avoid duplicates.
- Updated scrollback handling to clear pending output when establishing new WebSocket connections.
- Improved stability of terminal fitting logic by ensuring dimensions are stable before fitting.
This commit is contained in:
SuperComboGamer
2025-12-14 14:40:34 -05:00
parent 480589510e
commit a5c61b0546
4 changed files with 180 additions and 55 deletions

View File

@@ -147,6 +147,18 @@ export function TerminalView() {
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation
// Helper to check if terminal creation should be debounced
const canCreateTerminal = (debounceMessage: string): boolean => {
const now = Date.now();
if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) {
console.log(debounceMessage);
return false;
}
lastCreateTimeRef.current = now;
isCreatingRef.current = true;
return true;
};
// Get active tab
const activeTab = terminalState.tabs.find(t => t.id === terminalState.activeTabId);
@@ -263,14 +275,9 @@ export function TerminalView() {
// Create a new terminal session
// targetSessionId: the terminal to split (if splitting an existing terminal)
const createTerminal = async (direction?: "horizontal" | "vertical", targetSessionId?: string) => {
// Debounce: prevent rapid terminal creation
const now = Date.now();
if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) {
console.log("[Terminal] Debounced terminal creation");
if (!canCreateTerminal("[Terminal] Debounced terminal creation")) {
return;
}
lastCreateTimeRef.current = now;
isCreatingRef.current = true;
try {
const headers: Record<string, string> = {
@@ -305,14 +312,9 @@ export function TerminalView() {
// Create terminal in new tab
const createTerminalInNewTab = async () => {
// Debounce: prevent rapid terminal creation
const now = Date.now();
if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) {
console.log("[Terminal] Debounced terminal tab creation");
if (!canCreateTerminal("[Terminal] Debounced terminal tab creation")) {
return;
}
lastCreateTimeRef.current = now;
isCreatingRef.current = true;
const tabId = addTerminalTab();
try {
@@ -424,12 +426,22 @@ export function TerminalView() {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [terminalState.isUnlocked, terminalState.activeSessionId, shortcuts]);
// Get a stable key for a panel
// Collect all terminal IDs from a panel tree in order
const getTerminalIds = (panel: TerminalPanelContent): string[] => {
if (panel.type === "terminal") {
return [panel.sessionId];
}
return panel.panels.flatMap(getTerminalIds);
};
// Get a STABLE key for a panel - based only on terminal IDs, not tree structure
// This prevents unnecessary remounts when layout structure changes
const getPanelKey = (panel: TerminalPanelContent): string => {
if (panel.type === "terminal") {
return panel.sessionId;
}
return `split-${panel.direction}-${panel.panels.map(getPanelKey).join("-")}`;
// Use joined terminal IDs - stable regardless of nesting depth
return `group-${getTerminalIds(panel).join("-")}`;
};
// Render panel content recursively
@@ -465,10 +477,12 @@ export function TerminalView() {
? panel.size
: defaultSizePerPanel;
const panelKey = getPanelKey(panel);
return (
<React.Fragment key={getPanelKey(panel)}>
<React.Fragment key={panelKey}>
{index > 0 && (
<PanelResizeHandle
key={`handle-${panelKey}`}
className={
isHorizontal
? "w-1 h-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500"
@@ -476,7 +490,7 @@ export function TerminalView() {
}
/>
)}
<Panel defaultSize={panelSize} minSize={25}>
<Panel id={panelKey} order={index} defaultSize={panelSize} minSize={30}>
{renderPanelContent(panel)}
</Panel>
</React.Fragment>

View File

@@ -21,6 +21,9 @@ const MIN_FONT_SIZE = 8;
const MAX_FONT_SIZE = 32;
const DEFAULT_FONT_SIZE = 14;
// Resize constraints
const RESIZE_DEBOUNCE_MS = 100; // Short debounce for responsive feel
interface TerminalPanelProps {
sessionId: string;
authToken: string | null;
@@ -169,20 +172,41 @@ export function TerminalPanel({
console.warn("[Terminal] WebGL addon not available, falling back to canvas");
}
// Fit terminal to container using requestAnimationFrame for better timing
requestAnimationFrame(() => {
if (fitAddon && terminalRef.current) {
const rect = terminalRef.current.getBoundingClientRect();
// Only fit if container has valid dimensions
if (rect.width >= 100 && rect.height >= 50) {
try {
fitAddon.fit();
} catch (err) {
console.error("[Terminal] Initial fit error:", err);
}
// Fit terminal to container - wait for stable dimensions
// Use multiple RAFs to let react-resizable-panels finish layout
let fitAttempts = 0;
const MAX_FIT_ATTEMPTS = 5;
let lastWidth = 0;
let lastHeight = 0;
const attemptFit = () => {
if (!fitAddon || !terminalRef.current || fitAttempts >= MAX_FIT_ATTEMPTS) return;
const rect = terminalRef.current.getBoundingClientRect();
fitAttempts++;
// Check if dimensions are stable (same as last attempt) and valid
if (
rect.width === lastWidth &&
rect.height === lastHeight &&
rect.width > 0 &&
rect.height > 0
) {
try {
fitAddon.fit();
} catch (err) {
console.error("[Terminal] Initial fit error:", err);
}
return;
}
});
// Dimensions still changing or too small, try again
lastWidth = rect.width;
lastHeight = rect.height;
requestAnimationFrame(attemptFit);
};
requestAnimationFrame(attemptFit);
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
@@ -299,10 +323,11 @@ export function TerminalPanel({
terminal.write(msg.data);
break;
case "scrollback":
// Clear terminal before replaying scrollback to prevent duplicates on reconnection
terminal.clear();
// Replay scrollback buffer (previous terminal output)
if (msg.data) {
// Only process scrollback if there's actual data
// Don't clear if empty - prevents blank terminal issue
if (msg.data && msg.data.length > 0) {
// Use reset() which is more reliable than clear() or escape sequences
terminal.reset();
terminal.write(msg.data);
}
break;
@@ -371,7 +396,7 @@ export function TerminalPanel({
};
}, [sessionId, authToken, wsUrl, isTerminalReady]);
// Handle resize with debouncing and validation
// Handle resize with debouncing
const handleResize = useCallback(() => {
// Clear any pending resize
if (resizeDebounceRef.current) {
@@ -382,14 +407,11 @@ export function TerminalPanel({
resizeDebounceRef.current = setTimeout(() => {
if (!fitAddonRef.current || !xtermRef.current || !terminalRef.current) return;
// Validate minimum dimensions before resizing
const container = terminalRef.current;
const rect = container.getBoundingClientRect();
const MIN_WIDTH = 100;
const MIN_HEIGHT = 50;
if (rect.width < MIN_WIDTH || rect.height < MIN_HEIGHT) {
console.log("[Terminal] Container too small to resize:", rect.width, "x", rect.height);
// Only skip if container has no size at all
if (rect.width <= 0 || rect.height <= 0) {
return;
}
@@ -404,7 +426,7 @@ export function TerminalPanel({
} catch (err) {
console.error("[Terminal] Resize error:", err);
}
}, 100); // 100ms debounce
}, RESIZE_DEBOUNCE_MS);
}, []);
// Resize observer
@@ -439,12 +461,16 @@ export function TerminalPanel({
if (xtermRef.current && isTerminalReady) {
xtermRef.current.options.fontSize = fontSize;
// Refit after font size change
if (fitAddonRef.current) {
fitAddonRef.current.fit();
// Notify server of new dimensions
const { cols, rows } = xtermRef.current;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "resize", cols, rows }));
if (fitAddonRef.current && terminalRef.current) {
const rect = terminalRef.current.getBoundingClientRect();
// Only fit if container has any size
if (rect.width > 0 && rect.height > 0) {
fitAddonRef.current.fit();
// Notify server of new dimensions
const { cols, rows } = xtermRef.current;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "resize", cols, rows }));
}
}
}
}