diff --git a/frontend/src/features/terminals/TerminalView.test.tsx b/frontend/src/features/terminals/TerminalView.test.tsx index 136583d..01fcf1b 100644 --- a/frontend/src/features/terminals/TerminalView.test.tsx +++ b/frontend/src/features/terminals/TerminalView.test.tsx @@ -263,6 +263,13 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () = globalThis.ResizeObserver = savedResizeObserver; }); + function setTerminalBoxSize(width: number, height: number) { + const container = screen.getByTestId("terminal-xterm-container"); + Object.defineProperty(container, "clientWidth", { value: width, configurable: true }); + Object.defineProperty(container, "clientHeight", { value: height, configurable: true }); + return container; + } + it("sanity: with the polyfills xterm mounts and the opener runs", async () => { // Guards the premise of the tests below: if this fails, the opener never // fired and the error assertions would be vacuous. @@ -309,6 +316,48 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () = expect(screen.queryByTestId("terminal-error")).toBeNull(); }); + it("keeps a boot placeholder while the terminal box has no usable size", async () => { + const handle = makeHandle({ sessionId: "boot-zero-1" }); + const open = vi.fn(async () => handle); + + renderView(new MockTerminalGateway(), "/cwd", { open, refitSignal: 1 }); + await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => requestAnimationFrame(resolve)); + + expect(screen.getByTestId("terminal-boot-placeholder")).toBeTruthy(); + expect(screen.getByTestId("terminal-xterm-container").style.visibility).toBe( + "hidden", + ); + expect(handle.resize).not.toHaveBeenCalled(); + }); + + it("reveals xterm and resizes the handle after the first useful fit", async () => { + const handle = makeHandle({ sessionId: "boot-ready-1" }); + const open = vi.fn(async () => handle); + + const { rerender } = renderView(new MockTerminalGateway(), "/cwd", { + open, + refitSignal: 1, + }); + await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId("terminal-boot-placeholder")).toBeTruthy(); + + setTerminalBoxSize(480, 240); + rerender( + + + , + ); + + await waitFor(() => + expect(screen.queryByTestId("terminal-boot-placeholder")).toBeNull(), + ); + expect(screen.getByTestId("terminal-xterm-container").style.visibility).toBe( + "visible", + ); + await waitFor(() => expect(handle.resize).toHaveBeenCalled()); + }); + describe("refitSignal (ticket #61 — refit after split/merge)", () => { it("refits WITHOUT reopening the terminal when refitSignal changes", async () => { // Simulates LayoutGrid bumping `useLayout`'s layout version after a @@ -322,18 +371,14 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () = refitSignal: 1, }); await waitFor(() => expect(open).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 (the same guard that protects the resize-observer // path from fitting to a transient zero size). Stub a real size on the // inner xterm container so the refit triggered below actually reaches // `fit.fit()` instead of bailing on the zero-size guard. - const container = screen.getByTestId("terminal-view").firstElementChild as HTMLElement; - Object.defineProperty(container, "clientWidth", { value: 400, configurable: true }); - Object.defineProperty(container, "clientHeight", { value: 200, configurable: true }); + setTerminalBoxSize(400, 200); rerender( @@ -341,9 +386,7 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () = , ); - await waitFor(() => - expect(fitSpy.mock.calls.length).toBeGreaterThan(fitCallsAtMount), - ); + await waitFor(() => expect(fitSpy).toHaveBeenCalled()); // The structural-mutation refit must never reopen the PTY. expect(open).toHaveBeenCalledTimes(1); @@ -365,7 +408,7 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () = await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); fitSpy.mockClear(); - const container = screen.getByTestId("terminal-view").firstElementChild as HTMLElement; + const container = screen.getByTestId("terminal-xterm-container"); // jsdom's default layout box is 0x0 — exactly the transient-zero case: // left as-is, the container "hasn't settled" yet. diff --git a/frontend/src/features/terminals/TerminalView.tsx b/frontend/src/features/terminals/TerminalView.tsx index 27009c6..75b2eeb 100644 --- a/frontend/src/features/terminals/TerminalView.tsx +++ b/frontend/src/features/terminals/TerminalView.tsx @@ -157,6 +157,7 @@ export function TerminalView({ // buffer (which is invisible to assistive tech and absent when xterm can't // mount). `null` ⇒ no error. The cell stays mounted and IdeA stays usable. const [openError, setOpenError] = useState(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 });