fix(frontend): refit différé des cellules terminal après mutation de layout (#61)

Le ResizeObserver de TerminalView ne déclenche pas toujours un événement
utile quand une cellule voisine apparaît/disparaît (split/merge), forçant
l'utilisateur à redimensionner la fenêtre pour rafraîchir le scaling xterm.
useLayout expose un layoutVersion bumpé à chaque commit d'arbre (chargement
initial inclus), relayé par LayoutGrid comme refitSignal à chaque
TerminalView survivant pour déclencher fit.fit() sans rouvrir le PTY.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 09:41:39 +02:00
parent 17d6baf15a
commit 445ecaf82e
4 changed files with 130 additions and 8 deletions

View File

@ -102,6 +102,17 @@ interface TerminalViewProps {
* the mobile key toolbar. Not called when xterm fails to mount (headless).
*/
onReady?: (api: TerminalInputApi) => void;
/**
* Bumped by the caller after a structural layout mutation (split/merge,
* ticket #61) so surviving terminals refit even when their container's
* `ResizeObserver` doesn't fire a useful event for the mutation (e.g. a
* sibling appearing/disappearing without this cell's own box changing size
* in a way the observer flags in time). Any value change schedules exactly
* one coalesced refit on the next animation frame, reusing the same
* zero-size guard and rows/cols-changed check as the resize-observer path —
* it never remounts/reopens the terminal.
*/
refitSignal?: number;
}
/**
@ -132,6 +143,7 @@ export function TerminalView({
agentMode = false,
portal,
onReady,
refitSignal,
}: TerminalViewProps) {
const { terminal } = useGateways();
const containerRef = useRef<HTMLDivElement | null>(null);
@ -165,6 +177,10 @@ export function TerminalView({
portalRef.current = portal;
const onReadyRef = useRef(onReady);
onReadyRef.current = onReady;
// Holds the mounted instance's `refit` closure so the `refitSignal` effect
// below (a separate effect, since it must NOT re-run/reopen the terminal on
// every parent render) can trigger it without depending on `cwd`'s effect.
const refitRef = useRef<(() => void) | null>(null);
useEffect(() => {
const container = containerRef.current;
@ -337,14 +353,21 @@ export function TerminalView({
void handle.resize(term.rows, term.cols);
}
};
const ro = new ResizeObserver(() => {
const scheduleRefit = () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(refit);
});
};
const ro = new ResizeObserver(scheduleRefit);
ro.observe(container);
// Let the `refitSignal` effect below trigger the SAME coalesced refit after
// a structural layout mutation (split/merge, ticket #61) — surviving cells
// don't always get a timely useful ResizeObserver event from a sibling
// appearing/disappearing.
refitRef.current = scheduleRefit;
return () => {
disposed = true;
refitRef.current = null;
if (rafId) cancelAnimationFrame(rafId);
ro.disconnect();
onKey.dispose();
@ -360,6 +383,20 @@ export function TerminalView({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cwd]);
// Ticket #61 — explicit refit after a structural layout mutation (split /
// merge). A survivor's own container may not fire a timely, useful
// ResizeObserver event when a SIBLING cell appears/disappears, so the caller
// (LayoutGrid, via `useLayout`'s layout-tree version) bumps `refitSignal` on
// every successful mutation and we reuse the mounted instance's coalesced
// `refit` (same zero-size guard, same rows/cols-changed check) instead of
// remounting/reopening the terminal. Deliberately a SEPARATE effect from the
// `[cwd]` one above so bumping the signal never re-runs the open/reattach
// logic.
useEffect(() => {
if (refitSignal === undefined) return;
refitRef.current?.();
}, [refitSignal]);
return (
<div
data-testid="terminal-view"