fix(FN-2500): improve terminal modal sizing and input behavior

- Widen the desktop terminal modal to improve usability and align with UX review feedback
- Stabilize terminal input lifecycle handling to avoid focus and interaction regressions
- Add regression coverage for terminal modal input behavior and mobile keyboard layout scenarios
- Include a changeset documenting the terminal modal desktop width and input fixes
This commit is contained in:
Fusion
2026-04-25 11:58:19 -07:00
committed by gsxdsm
parent de6cca1a0c
commit 1b3994f1e0
10 changed files with 196 additions and 56 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix the dashboard terminal modal desktop width contract so large displays use a broad viewport-based layout, and harden terminal input lifecycle handling so xterm keyboard input continues forwarding reliably after rerenders.

View File

@@ -153,13 +153,24 @@ describe("terminal mobile keyboard layout CSS contract", () => {
return match?.[1] ?? "";
}
it("has min-height: 80vh on desktop", () => {
it("uses viewport-based width with desktop side margins", () => {
const ruleBody = findDesktopTerminalModalRule();
expect(ruleBody).toContain("min-height: 80vh");
expect(ruleBody).toContain(
"width: min(1800px, calc(100vw - (var(--space-xl) * 2)))",
);
expect(ruleBody).toContain(
"max-width: calc(100vw - (var(--space-xl) * 2))",
);
});
it("has max-height: 85vh on desktop", () => {
it("does not cap desktop width to the old narrow 1600px max", () => {
const ruleBody = findDesktopTerminalModalRule();
expect(ruleBody).not.toContain("max-width: 1600px");
});
it("keeps desktop height constraints", () => {
const ruleBody = findDesktopTerminalModalRule();
expect(ruleBody).toContain("min-height: 80vh");
expect(ruleBody).toContain("max-height: 85vh");
});
});

View File

@@ -7,8 +7,8 @@
}
.modal.terminal-modal {
width: 90vw;
max-width: 1600px;
width: min(1800px, calc(100vw - (var(--space-xl) * 2)));
max-width: calc(100vw - (var(--space-xl) * 2));
min-height: 80vh;
max-height: 85vh;
background: var(--card);
@@ -409,7 +409,7 @@
}
.terminal-output-error {
background: rgba(239, 68, 68, 0.1);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border-left: 3px solid var(--failed);
}
@@ -678,7 +678,7 @@
.terminal-error {
padding: var(--space-md) var(--space-lg);
background: rgba(179, 38, 38, 0.1);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border-bottom: 1px solid var(--border);
color: var(--error);
font-size: 13px;
@@ -762,7 +762,7 @@
justify-content: flex-end;
border-top: 1px solid var(--border);
padding: 0 4px;
min-height: 32px;
min-height: 36px;
}
/* Hide text labels on action buttons to save space */
@@ -774,8 +774,8 @@
.terminal-restart-btn,
.terminal-clear-btn {
padding: 8px;
min-height: 32px;
min-width: 32px;
min-height: 36px;
min-width: 36px;
justify-content: center;
}

View File

@@ -305,12 +305,15 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
// without needing resize as a dependency (avoids ordering issues).
resizeRef.current = resize;
// Initialize xterm.js when session is ready
// Depends on `isReady`, `activeTab`, and xtermReady to properly reinitialize on tab switch
// Initialize xterm.js when session is ready.
// Keying this effect by active session id (not full activeTab object) avoids
// tearing down xterm lifecycle wiring during unrelated tab metadata updates
// such as title changes.
useEffect(() => {
if (!isOpen || !isReady || !activeTab) return;
if (!isOpen || !isReady) return;
const currentSessionId = activeTab.sessionId;
const currentSessionId = activeTab?.sessionId;
if (!currentSessionId) return;
// Detect project switch: if projectId changed, invalidate xterm even if sessionId is the same.
// This ensures xterm content from the previous project is not displayed in the new project.
@@ -335,7 +338,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
}
let mounted = true;
let watchdogTimer: ReturnType<typeof setTimeout>;
let watchdogTimer: ReturnType<typeof setTimeout> | undefined;
const initTerminal = async () => {
// Dynamically import xterm modules with watchdog timeout
@@ -414,7 +417,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
terminal.open(terminalRef.current);
// Clear watchdog — imports and open() succeeded within deadline
clearTimeout(watchdogTimer);
if (watchdogTimer) {
clearTimeout(watchdogTimer);
}
// Ensure xterm's textarea receives focus for keyboard input.
// xterm.js creates a hidden textarea that captures keyboard events.
@@ -470,54 +475,73 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
});
}
// Signal that xterm is ready so the subscription effect re-runs
// Signal that xterm is ready so lifecycle effects can subscribe.
setXtermReady(true);
// Clear any prior xterm init error
setXtermInitError(null);
// Handle data from terminal (user input)
const dataHandler = terminal.onData((data) => {
sendInput(data);
});
// Handle resize
const resizeHandler = () => {
if (fitAddonRef.current && xtermRef.current) {
try {
(fitAddonRef.current as InstanceType<typeof FitAddon>).fit();
const { cols, rows } = xtermRef.current;
resize(cols, rows);
} catch {
// Ignore fit errors
}
}
};
window.addEventListener("resize", resizeHandler);
return () => {
dataHandler.dispose();
window.removeEventListener("resize", resizeHandler);
};
} catch (err) {
clearTimeout(watchdogTimer);
if (watchdogTimer) {
clearTimeout(watchdogTimer);
}
if (!mounted) return;
const message = err instanceof Error ? err.message : "xterm initialization failed";
setXtermInitError(message);
}
};
const cleanupPromise = initTerminal();
void initTerminal();
return () => {
mounted = false;
clearTimeout(watchdogTimer);
cleanupPromise.then((cleanup) => cleanup?.());
if (watchdogTimer) {
clearTimeout(watchdogTimer);
}
// Don't dispose xterm here - it should persist across tab switches
// Only dispose when the modal is fully closed
};
}, [fitAndResizeForSession, isOpen, isReady, activeTab, activeTab?.sessionId, sendInput, resize]);
}, [fitAndResizeForSession, isOpen, isReady, activeTab?.sessionId, projectId]);
// Keep user input forwarding and resize publishing attached to the current
// xterm instance/session. This prevents unrelated rerenders (tab title or
// status updates) from silently dropping onData -> sendInput wiring.
useEffect(() => {
if (!isOpen || !xtermReady || !activeTab?.sessionId || !xtermRef.current) {
return;
}
const expectedSessionId = activeTab.sessionId;
const terminal = xtermRef.current;
const dataHandler = terminal.onData((data) => {
if (xtermInitializedRef.current !== expectedSessionId) {
return;
}
sendInput(data);
});
const resizeHandler = () => {
if (xtermInitializedRef.current !== expectedSessionId) {
return;
}
if (fitAddonRef.current && xtermRef.current) {
try {
(fitAddonRef.current as InstanceType<typeof FitAddon>).fit();
const { cols, rows } = xtermRef.current;
resize(cols, rows);
} catch {
// Ignore fit errors
}
}
};
window.addEventListener("resize", resizeHandler);
return () => {
dataHandler.dispose();
window.removeEventListener("resize", resizeHandler);
};
}, [isOpen, xtermReady, activeTab?.sessionId, sendInput, resize]);
// Cleanup xterm when modal closes
useEffect(() => {

View File

@@ -198,7 +198,7 @@ describe("AgentsView", () => {
});
// Ensure the single-load path still powers dependent UI sections.
expect(screen.getByText("Active Agents (1)")).toBeTruthy();
expect(await screen.findByText("Active Agents (1)")).toBeTruthy();
});
it("renders token stats derived from the currently displayed agents", async () => {

View File

@@ -950,11 +950,11 @@ describe("MissionManager", () => {
fireEvent.click(screen.getByTestId("mission-activity-load-more"));
await waitFor(() => {
expect(screen.getByText("Mission event 65")).toBeDefined();
}, { timeout: 5000 });
await screen.findByText("Mission event 65", undefined, { timeout: 10_000 });
expect(screen.queryByTestId("mission-activity-load-more")).toBeNull();
await waitFor(() => {
expect(screen.queryByTestId("mission-activity-load-more")).toBeNull();
});
}, 15000);
it("auto-scrolls to latest mission activity on initial load", async () => {

View File

@@ -3600,6 +3600,7 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTerminalInstance.onData.mockImplementation(() => ({ dispose: vi.fn() }));
mockCreateTerminalSession.mockResolvedValue({
sessionId: "test-session-123",
shell: "/bin/bash",
@@ -3687,6 +3688,105 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
});
});
it("forwards xterm onData input to sendInput", async () => {
let terminalInputCallback: ((data: string) => void) | null = null;
mockTerminalInstance.onData.mockImplementation((cb: (data: string) => void) => {
terminalInputCallback = cb;
return { dispose: vi.fn() };
});
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(terminalInputCallback).not.toBeNull();
});
act(() => {
terminalInputCallback?.("echo hello\r");
});
expect(mockSendInput).toHaveBeenCalledWith("echo hello\r");
});
it("keeps terminal input forwarding active after active-tab title rerenders", async () => {
let terminalInputCallback: ((data: string) => void) | null = null;
const disposeInputHandler = vi.fn();
mockTerminalInstance.onData.mockImplementation((cb: (data: string) => void) => {
terminalInputCallback = cb;
return { dispose: disposeInputHandler };
});
const { rerender } = render(
<TerminalModal isOpen={true} onClose={mockOnClose} />,
);
await waitFor(() => {
expect(terminalInputCallback).not.toBeNull();
});
// Simulate tab metadata update (title change) that should not tear down
// input forwarding for the same session.
const renamedTab = {
...defaultTab,
title: "bash (connected)",
isActive: true,
};
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [renamedTab],
activeTab: renamedTab,
});
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(disposeInputHandler).not.toHaveBeenCalled();
expect(mockTerminalInstance.onData).toHaveBeenCalledTimes(1);
act(() => {
terminalInputCallback?.("pwd\r");
});
expect(mockSendInput).toHaveBeenCalledWith("pwd\r");
});
it("keeps terminal input forwarding active after connection status transitions", async () => {
let terminalInputCallback: ((data: string) => void) | null = null;
const disposeInputHandler = vi.fn();
mockTerminalInstance.onData.mockImplementation((cb: (data: string) => void) => {
terminalInputCallback = cb;
return { dispose: disposeInputHandler };
});
mockUseTerminal.mockReturnValue(
createMockTerminalState({ connectionStatus: "disconnected" }),
);
const { rerender } = render(
<TerminalModal isOpen={true} onClose={mockOnClose} />,
);
await waitFor(() => {
expect(terminalInputCallback).not.toBeNull();
});
mockUseTerminal.mockReturnValue(
createMockTerminalState({ connectionStatus: "connected" }),
);
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(disposeInputHandler).not.toHaveBeenCalled();
expect(mockTerminalInstance.onData).toHaveBeenCalledTimes(1);
act(() => {
terminalInputCallback?.("ls\r");
});
expect(mockSendInput).toHaveBeenCalledWith("ls\r");
});
it("focuses xterm helper textarea on user pointer gesture", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);

View File

@@ -743,7 +743,7 @@ describe("POST /api/projects route handler", () => {
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
}, 15_000);
it("returns clone failure and skips registration when git clone fails", async () => {
const store = new MockStoreForRoutes();

View File

@@ -297,5 +297,5 @@ describe("diff-base parity between dashboard and merger", () => {
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});
}, 15_000);
});

View File

@@ -10057,7 +10057,7 @@ describe("Saturated-slot regression: heartbeat wake routes", () => {
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
}, 15_000);
});
});