fix: terminal resize propagation and redraw

- Frontend: Add ResizeObserver with dimension tracking for accurate resize detection
- Frontend: Fix cleanup function to properly disconnect ResizeObserver
- Frontend: Use CSS grid for terminal wrapper layout
- Backend: Add duplicate dimension check to avoid unnecessary resizes
- Backend: Ensure stty command is sent correctly to container shell
This commit is contained in:
Fusion
2026-05-24 17:44:00 +02:00
parent e7a89a853f
commit 4de312c170
3 changed files with 32 additions and 19 deletions
@@ -142,6 +142,11 @@ class TerminalSession:
if self._closed: if self._closed:
logger.warning("Cannot resize: session is closed") logger.warning("Cannot resize: session is closed")
return return
# Only resize if dimensions actually changed
if cols == self._cols and rows == self._rows:
return
self._cols = cols self._cols = cols
self._rows = rows self._rows = rows
logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}") logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}")
+18 -7
View File
@@ -260,18 +260,29 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
ws.send(data); ws.send(data);
}); });
// Handle window resize with debounce - use rAF to ensure layout settled // Handle container resize with ResizeObserver for accurate dimension tracking
let resizeTimeout: ReturnType<typeof setTimeout>; let resizeTimeout: ReturnType<typeof setTimeout>;
const handleResize = () => { let lastWidth = 0;
let lastHeight = 0;
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
// Only trigger if dimensions actually changed
if (width === lastWidth && height === lastHeight) return;
lastWidth = width;
lastHeight = height;
clearTimeout(resizeTimeout); clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => { resizeTimeout = setTimeout(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!container.isConnected) return;
fitTerminal(); fitTerminal();
}); });
}, 100); }, 50);
}; });
resizeObserver.observe(container);
window.addEventListener("resize", handleResize);
// Refit after mobile header auto-hides (3s delay + 0.3s transition) // Refit after mobile header auto-hides (3s delay + 0.3s transition)
const headerHideTimeout = setTimeout(() => { const headerHideTimeout = setTimeout(() => {
@@ -306,7 +317,7 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
return () => { return () => {
clearTimeout(resizeTimeout); clearTimeout(resizeTimeout);
clearTimeout(headerHideTimeout); clearTimeout(headerHideTimeout);
window.removeEventListener("resize", handleResize); resizeObserver.disconnect();
document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
if (ws) { if (ws) {
ws.close(); ws.close();
+9 -12
View File
@@ -2506,8 +2506,8 @@ a.nav-item,
} }
.terminal-wrapper { .terminal-wrapper {
display: flex; display: grid;
flex-direction: column; grid-template-rows: auto 1fr;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -2592,22 +2592,19 @@ a.nav-item,
} }
.terminal-container { .terminal-container {
flex: 1;
min-height: 0;
padding: var(--space-2);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: var(--space-2);
overflow: hidden;
} }
.terminal-container .xterm { .terminal-container .xterm {
flex: 1; position: absolute;
min-height: 0; top: 0;
width: 100%; left: 0;
height: 100%; right: 0;
bottom: 0;
} }
.terminal-container canvas { .terminal-container canvas {