(null);
+ const [terminalReady, setTerminalReady] = useState(false);
// The opener (`open` or the terminal gateway) is read through a ref so the
// effect does NOT depend on its identity. Otherwise every parent re-render
@@ -195,6 +196,7 @@ export function TerminalView({
// Fresh (re)mount: clear any prior failure banner before we try to open.
setOpenError(null);
+ setTerminalReady(false);
const term = new Terminal({
convertEol: false,
@@ -213,15 +215,13 @@ export function TerminalView({
term.dispose();
return;
}
- try {
- fit.fit();
- } catch {
- /* container not laid out yet; a resize will retry */
- }
-
let disposed = false;
let handle: TerminalHandle | null = null;
const encoder = new TextEncoder();
+ let rafId = 0;
+ let lastRows = term.rows;
+ let lastCols = term.cols;
+ let hasUsefulFit = false;
// Keystroke → PTY path. The agent cell is a **native terminal**
// (ARCHITECTURE §20): keystrokes reach the PTY exactly like a plain shell.
@@ -263,6 +263,11 @@ export function TerminalView({
// Adopt a freshly-established handle: flush buffered keystrokes. If the view
// was disposed before the promise resolved, just detach (NEVER close — the
// PTY must survive a transient mount/unmount).
+ const resizeHandleToCurrentGeometry = () => {
+ if (!handle) return;
+ if (term.rows <= 0 || term.cols <= 0) return;
+ void handle.resize(term.rows, term.cols);
+ };
const adopt = (h: TerminalHandle) => {
if (disposed) {
h.detach();
@@ -276,6 +281,7 @@ export function TerminalView({
void h.write(encoder.encode(pending));
pending = "";
}
+ if (hasUsefulFit) resizeHandleToCurrentGeometry();
};
const onOpenError = (e: unknown) => {
@@ -339,9 +345,6 @@ export function TerminalView({
// into a single `requestAnimationFrame` that runs after layout settles,
// (2) skip fitting while the container has no real size, and (3) push a PTY
// resize only when rows/cols actually change (avoids redundant reflows).
- let rafId = 0;
- let lastRows = term.rows;
- let lastCols = term.cols;
// A refit can land on a transient 0x0 container (mount, or a structural
// layout mutation, before the box has actually settled). Previously this
// just gave up — fine on desktop, where a later window resize always
@@ -368,10 +371,20 @@ export function TerminalView({
} catch {
return;
}
- if (handle && (term.rows !== lastRows || term.cols !== lastCols)) {
+ if (term.rows <= 0 || term.cols <= 0) return;
+
+ const isFirstUsefulFit = !hasUsefulFit;
+ if (isFirstUsefulFit) {
+ hasUsefulFit = true;
+ setTerminalReady(true);
+ }
+
+ if (term.rows !== lastRows || term.cols !== lastCols) {
lastRows = term.rows;
lastCols = term.cols;
- void handle.resize(term.rows, term.cols);
+ resizeHandleToCurrentGeometry();
+ } else if (isFirstUsefulFit) {
+ resizeHandleToCurrentGeometry();
}
};
const scheduleRefit = () => {
@@ -380,6 +393,7 @@ export function TerminalView({
};
const ro = new ResizeObserver(scheduleRefit);
ro.observe(container);
+ scheduleRefit();
// 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
@@ -439,6 +453,7 @@ export function TerminalView({
>
{/* xterm mounts into this inner node; the error banner is a sibling so
React never fights xterm over the same subtree. */}
+<<<<<<< HEAD
{showNetworkBanner && (
+ {!terminalReady && !openError && (
+
+ Préparation du terminal…
+>>>>>>> main
)}
{openError && (
diff --git a/frontend/src/features/web/WebAgentCell.test.tsx b/frontend/src/features/web/WebAgentCell.test.tsx
index c873084..48200ec 100644
--- a/frontend/src/features/web/WebAgentCell.test.tsx
+++ b/frontend/src/features/web/WebAgentCell.test.tsx
@@ -111,16 +111,14 @@ describe("WebAgentCell — refitSignal (ticket #61 web regression)", () => {
const { rerender } = renderCell(agent, seeded.id, { refitSignal: 1 });
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
-
- const fitCallsAtMount = fitSpy.mock.calls.length;
- expect(fitCallsAtMount).toBeGreaterThan(0);
+ fitSpy.mockClear();
// jsdom reports a zero-size layout box, which the coalesced refit
// deliberately skips — give the inner xterm container a real size so the
// refit below actually reaches `fit.fit()`.
const container = screen.getByTestId("web-agent-cell").querySelector(
- '[data-testid="terminal-view"]',
- )!.firstElementChild as HTMLElement;
+ '[data-testid="terminal-xterm-container"]',
+ ) as HTMLElement;
Object.defineProperty(container, "clientWidth", { value: 400, configurable: true });
Object.defineProperty(container, "clientHeight", { value: 200, configurable: true });
@@ -130,7 +128,7 @@ describe("WebAgentCell — refitSignal (ticket #61 web regression)", () => {
,
);
- await waitFor(() => expect(fitSpy.mock.calls.length).toBeGreaterThan(fitCallsAtMount));
+ await waitFor(() => expect(fitSpy).toHaveBeenCalled());
// The whole point: refit must never relaunch the agent's PTY.
expect(launchSpy).toHaveBeenCalledTimes(1);
diff --git a/frontend/src/features/web/WebWorkspaceLive.test.tsx b/frontend/src/features/web/WebWorkspaceLive.test.tsx
index 0868ccc..a650521 100644
--- a/frontend/src/features/web/WebWorkspaceLive.test.tsx
+++ b/frontend/src/features/web/WebWorkspaceLive.test.tsx
@@ -384,8 +384,6 @@ describe("LiveProjectPanel — cellLayoutVersion (ticket #61 web regression)", (
const archiRow = screen.getByText("Archi").closest("li")!;
fireEvent.click(within(archiRow).getByRole("button", { name: "Ouvrir" }));
await screen.findByTestId("web-agent-cell");
- // First cell mounted: at least the one guaranteed fit-on-mount happened.
- expect(fitSpy.mock.calls.length).toBeGreaterThan(0);
// Switch to a different agent's cell — the panel shows one cell at a time,
// so this replaces (unmounts A, mounts B) rather than adding a second.
@@ -398,8 +396,9 @@ describe("LiveProjectPanel — cellLayoutVersion (ticket #61 web regression)", (
// jsdom reports a zero-size layout box, which the refit deliberately skips
// — give the freshly-mounted cell's inner xterm container a real size so
// the refitSignal-driven refit actually reaches `fit.fit()`.
- const containerB = cellB.querySelector('[data-testid="terminal-view"]')!
- .firstElementChild as HTMLElement;
+ const containerB = cellB.querySelector(
+ '[data-testid="terminal-xterm-container"]',
+ ) as HTMLElement;
Object.defineProperty(containerB, "clientWidth", { value: 400, configurable: true });
Object.defineProperty(containerB, "clientHeight", { value: 200, configurable: true });