fix(FN-759): restructure terminal header for mobile and add regression tests

- Restructure TerminalModal header layout to support mobile viewports with proper flex sizing
- Add CSS styles for responsive terminal header with title truncation and icon wrapping
- Add inline comment documenting mobile header layout contract
- Add comprehensive TerminalModal test suite covering mobile layout, resize behavior, and header interactions
- Clean up unrelated store test and changeset remnants from prior work
This commit is contained in:
gsxdsm
2026-04-02 22:59:37 -07:00
parent d8cc666964
commit be89cb3581
3 changed files with 204 additions and 16 deletions

View File

@@ -366,7 +366,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
data-testid="terminal-modal-overlay" data-testid="terminal-modal-overlay"
> >
<div className="modal terminal-modal" data-testid="terminal-modal"> <div className="modal terminal-modal" data-testid="terminal-modal">
{/* Header */} {/* Header — on mobile (≤768px) flex-wrap stacks tabs and actions on separate rows;
.terminal-title is hidden; action button labels are hidden (icons only) */}
<div className="terminal-header"> <div className="terminal-header">
{/* Tab Bar */} {/* Tab Bar */}
<div className="terminal-tabs" data-testid="terminal-tabs"> <div className="terminal-tabs" data-testid="terminal-tabs">
@@ -409,8 +410,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
{getStatusIndicator()} {getStatusIndicator()}
</div> </div>
{/* Actions */} {/* Actions — labels hidden on mobile via .terminal-action-label */}
<div className="terminal-actions"> <div className="terminal-actions" data-testid="terminal-actions">
{connectionStatus === "disconnected" && activeTab && ( {connectionStatus === "disconnected" && activeTab && (
<button <button
className="terminal-reconnect-btn" className="terminal-reconnect-btn"
@@ -419,7 +420,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
data-testid="terminal-reconnect-btn" data-testid="terminal-reconnect-btn"
> >
<RefreshCw size={14} /> <RefreshCw size={14} />
<span>Reconnect</span> <span className="terminal-action-label">Reconnect</span>
</button> </button>
)} )}
{exitCode !== null && ( {exitCode !== null && (
@@ -430,7 +431,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
data-testid="terminal-restart-btn" data-testid="terminal-restart-btn"
> >
<RefreshCw size={14} /> <RefreshCw size={14} />
<span>New Session</span> <span className="terminal-action-label">New Session</span>
</button> </button>
)} )}
<button <button
@@ -440,7 +441,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
title="Clear terminal" title="Clear terminal"
> >
<Trash2 size={14} /> <Trash2 size={14} />
<span>Clear</span> <span className="terminal-action-label">Clear</span>
</button> </button>
<button <button
className="terminal-close" className="terminal-close"

View File

@@ -453,3 +453,162 @@ describe("TerminalModal", () => {
expect(mockRestartActiveTab).toHaveBeenCalled(); expect(mockRestartActiveTab).toHaveBeenCalled();
}); });
}); });
// --- Mobile layout regression tests ---
describe("TerminalModal — mobile layout contract", () => {
const mockOnClose = vi.fn();
const mockSendInput = vi.fn();
const mockResize = vi.fn();
const mockReconnect = vi.fn();
// Helper: create 5+ tabs for the many-tabs scenario
const createManyTabs = () => [
{ id: "tab-1", sessionId: "s-1", title: "bash", isActive: true, createdAt: Date.now() },
{ id: "tab-2", sessionId: "s-2", title: "zsh", isActive: false, createdAt: Date.now() },
{ id: "tab-3", sessionId: "s-3", title: "node", isActive: false, createdAt: Date.now() },
{ id: "tab-4", sessionId: "s-4", title: "python3", isActive: false, createdAt: Date.now() },
{ id: "tab-5", sessionId: "s-5", title: "make test", isActive: false, createdAt: Date.now() },
{ id: "tab-6", sessionId: "s-6", title: "docker", isActive: false, createdAt: Date.now() },
];
const createMockTerminalState = (overrides = {}) => ({
connectionStatus: "disconnected" as const,
sendInput: mockSendInput,
resize: mockResize,
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
onConnect: vi.fn(() => vi.fn()),
onScrollback: vi.fn(() => vi.fn()),
reconnect: mockReconnect,
...overrides,
});
const manyTabsSessionState = {
tabs: createManyTabs(),
activeTab: createManyTabs()[0],
isReady: true,
createTab: vi.fn(),
closeTab: vi.fn(),
setActiveTab: vi.fn(),
updateTabTitle: vi.fn(),
restartActiveTab: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockUseTerminal.mockReturnValue(createMockTerminalState());
mockUseTerminalSessions.mockReturnValue(manyTabsSessionState);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders all 6 tabs inside terminal-tabs container with many tabs", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const tabsContainer = screen.getByTestId("terminal-tabs");
expect(tabsContainer).toBeTruthy();
// All 6 tab titles should be rendered
expect(screen.getByText("bash")).toBeTruthy();
expect(screen.getByText("zsh")).toBeTruthy();
expect(screen.getByText("node")).toBeTruthy();
expect(screen.getByText("python3")).toBeTruthy();
expect(screen.getByText("make test")).toBeTruthy();
expect(screen.getByText("docker")).toBeTruthy();
});
});
it("preserves header structure: tabs, title, and actions are present", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
// Verify the three structural sections of the header exist
expect(screen.getByTestId("terminal-tabs")).toBeTruthy();
expect(screen.getByTestId("terminal-title")).toBeTruthy();
expect(screen.getByTestId("terminal-actions")).toBeTruthy();
});
});
it("close button is clickable with many tabs", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const closeBtn = screen.getByTestId("terminal-close-btn");
expect(closeBtn).toBeTruthy();
fireEvent.click(closeBtn);
});
expect(mockOnClose).toHaveBeenCalled();
});
it("clear button is clickable with many tabs", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const clearBtn = screen.getByTestId("terminal-clear-btn");
expect(clearBtn).toBeTruthy();
fireEvent.click(clearBtn);
});
// Clear calls xtermRef.current?.clear() — just verify button is functional
expect(screen.getByTestId("terminal-clear-btn")).toBeTruthy();
});
it("reconnect button is clickable with many tabs when disconnected", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const reconnectBtn = screen.getByTestId("terminal-reconnect-btn");
expect(reconnectBtn).toBeTruthy();
fireEvent.click(reconnectBtn);
});
expect(mockReconnect).toHaveBeenCalled();
});
it("action buttons have .terminal-action-label spans for mobile CSS targeting", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
// The reconnect and clear buttons should have .terminal-action-label spans
const reconnectBtn = screen.getByTestId("terminal-reconnect-btn");
const labelSpan = reconnectBtn.querySelector(".terminal-action-label");
expect(labelSpan).toBeTruthy();
expect(labelSpan?.textContent).toBe("Reconnect");
const clearBtn = screen.getByTestId("terminal-clear-btn");
const clearLabel = clearBtn.querySelector(".terminal-action-label");
expect(clearLabel).toBeTruthy();
expect(clearLabel?.textContent).toBe("Clear");
});
});
it("terminal-title section contains the status indicator for connection state", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const titleSection = screen.getByTestId("terminal-title");
// Should contain the TerminalIcon (svg) and the status indicator span
expect(titleSection.querySelector("svg")).toBeTruthy();
const statusIndicator = titleSection.querySelector(".terminal-status");
expect(statusIndicator).toBeTruthy();
// Disconnected state should show disconnected class
expect(statusIndicator?.classList.contains("disconnected")).toBe(true);
});
});
it("status-bar shows connection state text alongside tabs row", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const statusBar = screen.getByTestId("terminal-status-bar");
expect(statusBar).toBeTruthy();
// Should contain connection status text
const connectionStatus = statusBar.querySelector(".terminal-connection-status");
expect(connectionStatus?.textContent).toBe("Disconnected");
});
});
});

View File

@@ -6360,6 +6360,7 @@ body {
.terminal-tabs { .terminal-tabs {
display: flex; display: flex;
flex: 1; flex: 1;
min-width: 0;
overflow-x: auto; overflow-x: auto;
gap: 0; gap: 0;
padding: 0 4px; padding: 0 4px;
@@ -6989,10 +6990,47 @@ body {
border: none; border: none;
} }
/* Stack tabs and actions on separate rows */
.terminal-header { .terminal-header {
flex-wrap: wrap;
padding-top: env(safe-area-inset-top, 0); padding-top: env(safe-area-inset-top, 0);
} }
/* Tabs get their own full-width row and remain scrollable */
.terminal-tabs {
flex: 1 1 100%;
min-width: 100%;
order: 1;
}
/* Hide the redundant title/status indicator on mobile — .terminal-status-bar shows connection state */
.terminal-title {
display: none;
}
/* Actions sit on a compact second row */
.terminal-actions {
flex: 1 1 100%;
order: 2;
justify-content: flex-end;
border-top: 1px solid var(--border);
padding: 0 4px;
}
/* Hide text labels on action buttons to save space */
.terminal-action-label {
display: none;
}
.terminal-reconnect-btn,
.terminal-restart-btn,
.terminal-clear-btn {
padding: 8px;
min-height: 36px;
min-width: 36px;
justify-content: center;
}
.terminal-tab { .terminal-tab {
min-height: 48px; min-height: 48px;
padding: 12px 14px; padding: 12px 14px;
@@ -7018,22 +7056,12 @@ body {
max-width: 200px; max-width: 200px;
} }
.terminal-clear-btn {
padding: var(--space-sm) var(--space-md);
min-height: 36px;
}
.terminal-empty-state { .terminal-empty-state {
padding: var(--space-xl); padding: var(--space-xl);
padding-bottom: max(24px, env(safe-area-inset-bottom, 0)); padding-bottom: max(24px, env(safe-area-inset-bottom, 0));
} }
/* Mobile styles for interactive terminal */ /* Mobile styles for interactive terminal */
.terminal-title {
padding: 0 12px;
font-size: 13px;
}
.terminal-output { .terminal-output {
padding: var(--space-md); padding: var(--space-md);
font-size: 12px; font-size: 12px;