Merge branch 'main' into feature/multi-profiles-codex-claude-model-catalogue
This commit is contained in:
@ -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(
|
||||
<DIProvider gateways={{ terminal: new MockTerminalGateway() } as unknown as Gateways}>
|
||||
<TerminalView cwd="/cwd" open={open} refitSignal={2} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<DIProvider gateways={{ terminal: new MockTerminalGateway() } as unknown as Gateways}>
|
||||
@ -341,9 +386,7 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@ -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<string | null>(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
|
||||
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||
{showNetworkBanner && (
|
||||
<div
|
||||
@ -466,6 +481,38 @@ export function TerminalView({
|
||||
? "Réseau verrouillé par le runtime."
|
||||
: "Réseau interdit pour cet agent."}{" "}
|
||||
{networkReason}
|
||||
=======
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="terminal-xterm-container"
|
||||
aria-hidden={!terminalReady}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
visibility: terminalReady ? "visible" : "hidden",
|
||||
}}
|
||||
/>
|
||||
{!terminalReady && !openError && (
|
||||
<div
|
||||
data-testid="terminal-boot-placeholder"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
background: "#101214",
|
||||
color: "#9ca3af",
|
||||
fontSize: 13,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
Préparation du terminal…
|
||||
>>>>>>> main
|
||||
</div>
|
||||
)}
|
||||
{openError && (
|
||||
|
||||
@ -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)", () => {
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user