FN-7502: add pinned terminal layout
Adds a persisted pinned terminal mode that places the terminal below the dashboard instead of overlaying it. - Add a project-scoped below display mode with pin/unpin header controls and resize support. - Render below-mode terminal in the dashboard flow while preserving overlay behavior for docked, floating, and mobile modes. - Move terminal utility actions into the header so controls remain available across layouts. - Document the new terminal pin control and add release-note coverage plus regression tests. Files changed: .changeset/fn-7502-terminal-below-layout.md | 7 + docs/dashboard-guide.md | 16 +- packages/dashboard/app/App.tsx | 22 +- packages/dashboard/app/components/AppModals.tsx | 14 -- .../dashboard/app/components/ProjectSelector.css | 12 + .../dashboard/app/components/TerminalModal.css | 108 ++++++--- .../dashboard/app/components/TerminalModal.tsx | 246 +++++++++++---------- .../components/__tests__/TerminalModal.test.tsx | 100 ++++++++- 8 files changed, 351 insertions(+), 174 deletions(-) Fusion-Task-Id: FN-7502 Fusion-Task-Lineage: bb1b5e22-d19f-4af1-aa34-4213bc712384 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7502-terminal-below-layout.md
Normal file
7
.changeset/fn-7502-terminal-below-layout.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add a pinned below-application layout option for the dashboard terminal.
|
||||
category: feature
|
||||
dev: Terminal display mode now supports persisted docked, floating, and below layouts, with header controls replacing the footer shell.
|
||||
@@ -527,14 +527,16 @@ On Windows, the embedded terminal starts a supported shell inside Fusion, such a
|
||||
Use the terminal on desktop/tablet:
|
||||
|
||||
1. Select the **Terminal** button in the footer executor status bar.
|
||||
Expected outcome: the terminal opens as a bottom-docked panel with the active shell session and a draggable top resize handle.
|
||||
2. Drag the top edge of the docked panel.
|
||||
Expected outcome: the panel height changes within its viewport-safe bounds and persists per project.
|
||||
3. Select **Pop out** from the terminal header.
|
||||
Expected outcome: the terminal opens as a bottom-docked overlay panel with the active shell session, header controls for font size / clear / shortcuts / preferences, and a draggable top resize handle.
|
||||
2. Select **Pin terminal (push content)** from the terminal header.
|
||||
Expected outcome: the terminal moves into a persisted below-application panel that reserves space instead of covering the board, chat, or right sidebar. Select **Unpin terminal (overlay content)** to return to the overlay docked panel.
|
||||
3. Drag the top edge of the docked or pinned panel.
|
||||
Expected outcome: the panel height changes within its viewport-safe bounds and persists per project, with pinned mode clamped shorter so the application remains usable.
|
||||
4. Select **Pop out** from the terminal header.
|
||||
Expected outcome: the terminal switches to a floating window that can be dragged and freely resized; size, position, and display mode are saved per project.
|
||||
4. Select **Dock** in the floating terminal.
|
||||
Expected outcome: the terminal returns to the bottom docked panel using the saved docked height.
|
||||
5. Select the scripts chevron beside the footer **Terminal** button.
|
||||
5. Select **Dock** in the floating terminal.
|
||||
Expected outcome: the terminal returns to the bottom docked overlay panel using the saved docked height.
|
||||
6. Select the scripts chevron beside the footer **Terminal** button.
|
||||
Expected outcome: the quick scripts menu opens without toggling the terminal; choosing a script runs it in the terminal, and the menu footer opens script management.
|
||||
|
||||
Use the terminal on mobile:
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AppModals } from "./components/AppModals";
|
||||
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
|
||||
import { TopProgressBar } from "./components/TopProgressBar";
|
||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||
import { TerminalModal } from "./components/TerminalModal";
|
||||
import { type CliActionId } from "./components/SessionNotificationBanner";
|
||||
import {
|
||||
isOnboardingCompleted,
|
||||
@@ -937,15 +938,19 @@ function AppInner() {
|
||||
pushNav({ type: "modal", close: modalManager.closeGitHubImport });
|
||||
}, [modalManager, pushNav]);
|
||||
|
||||
const closeTerminalWithNav = useCallback(() => {
|
||||
removeNav(modalManager.closeTerminal);
|
||||
modalManager.closeTerminal();
|
||||
}, [modalManager, removeNav]);
|
||||
|
||||
const toggleTerminalWithNav = useCallback(() => {
|
||||
if (!modalManager.terminalOpen) {
|
||||
modalManager.toggleTerminal();
|
||||
pushNav({ type: "modal", close: modalManager.closeTerminal });
|
||||
} else {
|
||||
removeNav(modalManager.closeTerminal);
|
||||
modalManager.toggleTerminal();
|
||||
closeTerminalWithNav();
|
||||
}
|
||||
}, [modalManager, pushNav, removeNav]);
|
||||
}, [closeTerminalWithNav, modalManager, pushNav]);
|
||||
|
||||
const openFilesWithNav = useCallback((workspace?: string, initialFile?: string | null) => {
|
||||
modalManager.openFiles(workspace, initialFile);
|
||||
@@ -1475,6 +1480,7 @@ function AppInner() {
|
||||
}
|
||||
/>
|
||||
<DashboardBanners {...dashboardBannersProps} />
|
||||
<div className="dashboard-project-stack" data-testid="dashboard-project-stack">
|
||||
<div className={`dashboard-project-shell${sidebarActive ? " dashboard-project-shell--with-sidebar" : ""}${rightDockActive ? " dashboard-project-shell--with-right-dock" : ""}`} data-testid="dashboard-project-shell">
|
||||
{sidebarActive && (
|
||||
<LeftSidebarNav
|
||||
@@ -1511,6 +1517,16 @@ function AppInner() {
|
||||
</div>
|
||||
{rightDock.dock}
|
||||
</div>
|
||||
{currentProject && (
|
||||
<TerminalModal
|
||||
isOpen={modalManager.terminalOpen}
|
||||
onClose={closeTerminalWithNav}
|
||||
initialCommand={modalManager.terminalInitialCommand}
|
||||
initialCommandGeneration={modalManager.terminalInitialCommandGeneration}
|
||||
projectId={currentProject.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{rightDock.modal}
|
||||
{executorFooterVisible && currentProject && (
|
||||
<ExecutorStatusBar
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ModalErrorBoundary } from "./ErrorBoundary";
|
||||
import { TaskDetailModal } from "./TaskDetailModal";
|
||||
import { GitHubImportModal } from "./GitHubImportModal";
|
||||
import { SubtaskBreakdownModal } from "./SubtaskBreakdownModal";
|
||||
import { TerminalModal } from "./TerminalModal";
|
||||
import { ScriptsModal } from "./ScriptsModal";
|
||||
import { FileBrowserModal } from "./FileBrowserModal";
|
||||
import { UsageIndicator } from "./UsageIndicator";
|
||||
@@ -180,11 +179,6 @@ export function AppModals({
|
||||
modalManager.closeSubtask();
|
||||
}, [modalManager.closeSubtask, removeNav]);
|
||||
|
||||
const closeTerminalWithNav = useCallback(() => {
|
||||
removeNav(modalManager.closeTerminal);
|
||||
modalManager.closeTerminal();
|
||||
}, [modalManager.closeTerminal, removeNav]);
|
||||
|
||||
const closeScriptsWithNav = useCallback(() => {
|
||||
removeNav(modalManager.closeScripts);
|
||||
modalManager.closeScripts();
|
||||
@@ -408,14 +402,6 @@ export function AppModals({
|
||||
/>
|
||||
</ModalErrorBoundary>
|
||||
|
||||
<TerminalModal
|
||||
isOpen={modalManager.terminalOpen}
|
||||
onClose={closeTerminalWithNav}
|
||||
initialCommand={modalManager.terminalInitialCommand}
|
||||
initialCommandGeneration={modalManager.terminalInitialCommandGeneration}
|
||||
projectId={projectId}
|
||||
/>
|
||||
|
||||
<ScriptsModal
|
||||
isOpen={modalManager.scriptsOpen}
|
||||
onClose={closeScriptsWithNav}
|
||||
|
||||
@@ -566,10 +566,22 @@
|
||||
|
||||
/* === Project Content Wrapper (footer-safe layout) === */
|
||||
|
||||
.dashboard-project-stack {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-project-shell {
|
||||
/*
|
||||
FNXC:DashboardFooterLayout 2026-06-21-10:30:
|
||||
Keep the desktop footer-height token on the project shell for sibling consumers such as the left sidebar, which cannot inherit custom properties from project-content itself.
|
||||
|
||||
FNXC:TerminalLayout 2026-07-04-19:26:
|
||||
The optional pinned terminal sits below this shell inside .dashboard-project-stack, so this flex row must keep min-height:0 and relinquish vertical space instead of being covered by an overlay.
|
||||
*/
|
||||
--executor-footer-height: 36px;
|
||||
display: flex;
|
||||
|
||||
@@ -132,6 +132,57 @@ Larger grab target for the docked terminal top resize handle: it straddles the p
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
FNXC:TerminalLayout 2026-07-04-19:22:
|
||||
Pinned terminal mode is an in-flow project-shell child instead of a portaled overlay. Reserve a smaller, clamped panel below the application so the board, chat, and right dock stay visible and usable above it.
|
||||
*/
|
||||
.terminal-below-host {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.modal.terminal-modal.terminal-modal--below {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: var(--terminal-below-height);
|
||||
min-height: calc(var(--space-xl) * 7.5);
|
||||
max-width: none;
|
||||
max-height: min(35dvh, calc(100dvh - (var(--space-2xl) * 10)));
|
||||
resize: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.terminal-below-resize-handle {
|
||||
position: absolute;
|
||||
top: calc(var(--space-sm) * -1);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
height: calc(var(--space-md) + var(--space-sm));
|
||||
cursor: ns-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.terminal-below-resize-handle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: calc(var(--space-xs) / 2);
|
||||
width: calc(var(--space-xl) * 2);
|
||||
height: calc(var(--space-xs) / 2);
|
||||
transform: translateX(-50%);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.modal.terminal-modal.terminal-modal--floating {
|
||||
--floating-window-shadow: var(--shadow-lg);
|
||||
position: fixed;
|
||||
@@ -723,8 +774,12 @@ The terminal header pop-out/dock affordance is an icon-only utility control. It
|
||||
.terminal-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
flex: 0 0 auto;
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.terminal-output {
|
||||
@@ -1187,35 +1242,8 @@ The shortcut bar (modifier keys + arrow keys) must sit on ONE line, not stack in
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-06-23-00:15:
|
||||
Footer reads left-to-right: text-size control, then the relocated Clear/Shortcuts/Preferences action cluster, then connection status / exit code / zoom-hint pushed to the right edge (margin-left:auto on the connection status). flex-wrap keeps the action cluster from clipping when the docked/floating panel is narrow; gap replaces the old space-between distribution.
|
||||
*/
|
||||
.terminal-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* FNXC:Terminal 2026-06-23-00:15: Grouped cluster of the relocated Clear/Shortcuts/Preferences buttons; scrolls horizontally as a unit on very narrow footers so the buttons stay reachable instead of clipping. */
|
||||
.terminal-footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* FNXC:Terminal 2026-06-23-00:15: Connection status starts the right-aligned trailing group (status + exit code + zoom hint) so the left edge holds the text-size and action controls. */
|
||||
.terminal-connection-status {
|
||||
font-weight: 500;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.terminal-connection-status.connected {
|
||||
@@ -1224,7 +1252,7 @@ Footer reads left-to-right: text-size control, then the relocated Clear/Shortcut
|
||||
|
||||
.terminal-connection-status.connecting,
|
||||
.terminal-connection-status.reconnecting {
|
||||
color: var(--in-progress);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.terminal-connection-status.disconnected {
|
||||
@@ -1243,6 +1271,10 @@ Footer reads left-to-right: text-size control, then the relocated Clear/Shortcut
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.terminal-font-size-controls--header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.terminal-font-size-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1651,3 +1683,19 @@ The Android keyboard-open recurrence can start with a touch-primary visualViewpo
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.terminal-below-host {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.terminal-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.terminal-shortcuts--header,
|
||||
.terminal-connection-status {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
ChevronDown,
|
||||
FolderGit2,
|
||||
FolderRoot,
|
||||
Pin,
|
||||
PinOff,
|
||||
} from "lucide-react";
|
||||
import { useTerminal } from "../hooks/useTerminal";
|
||||
import { useTerminalSessions } from "../hooks/useTerminalSessions";
|
||||
@@ -57,11 +59,16 @@ const XTERM_INIT_TIMEOUT_MS = 10000;
|
||||
|
||||
const XTERM_IMPORT_RETRY_DELAYS_MS = [500, 1500, 3000] as const;
|
||||
|
||||
type TerminalDisplayMode = "docked" | "floating";
|
||||
export type TerminalDisplayMode = "docked" | "floating" | "below";
|
||||
|
||||
export const TERMINAL_DISPLAY_MODE_STORAGE_PREFIX = "fusion:terminal-display-mode-";
|
||||
|
||||
const TERMINAL_DOCKED_DEFAULT_HEIGHT = 360;
|
||||
const TERMINAL_DOCKED_MIN_HEIGHT = 240;
|
||||
const TERMINAL_DOCKED_VIEWPORT_MARGIN = 96;
|
||||
const TERMINAL_BELOW_DEFAULT_HEIGHT = 260;
|
||||
const TERMINAL_BELOW_MIN_HEIGHT = 180;
|
||||
const TERMINAL_BELOW_APP_MIN_HEIGHT = 320;
|
||||
const TERMINAL_FLOAT_DEFAULT_WIDTH = 960;
|
||||
const TERMINAL_FLOAT_DEFAULT_HEIGHT = 560;
|
||||
const TERMINAL_FLOAT_MIN_WIDTH = 480;
|
||||
@@ -88,15 +95,23 @@ interface TerminalWorkspaceMenuPosition {
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
function readTerminalDisplayMode(projectId?: string): TerminalDisplayMode {
|
||||
function terminalDisplayModeStorageKey(projectId?: string): string {
|
||||
return `${TERMINAL_DISPLAY_MODE_STORAGE_PREFIX}${projectId ?? "default"}`;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TerminalLayout 2026-07-04-19:08:
|
||||
Terminal display mode is a project-scoped, reversible layout preference. Missing or invalid storage must continue to use the original overlay docked terminal, while the new below mode persists only when the operator pins the terminal to push content.
|
||||
*/
|
||||
export function readTerminalDisplayMode(projectId?: string): TerminalDisplayMode {
|
||||
if (typeof window === "undefined") return "docked";
|
||||
const value = window.localStorage.getItem(`fusion:terminal-display-mode-${projectId ?? "default"}`);
|
||||
return value === "floating" ? "floating" : "docked";
|
||||
const value = window.localStorage.getItem(terminalDisplayModeStorageKey(projectId));
|
||||
return value === "floating" || value === "below" ? value : "docked";
|
||||
}
|
||||
|
||||
function writeTerminalDisplayMode(mode: TerminalDisplayMode, projectId?: string): TerminalDisplayMode {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(`fusion:terminal-display-mode-${projectId ?? "default"}`, mode);
|
||||
window.localStorage.setItem(terminalDisplayModeStorageKey(projectId), mode);
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
@@ -107,14 +122,22 @@ function readTerminalDockedHeight(projectId?: string): number {
|
||||
return Number.isFinite(parsed) ? parsed : TERMINAL_DOCKED_DEFAULT_HEIGHT;
|
||||
}
|
||||
|
||||
function clampTerminalDockedHeight(height: number): number {
|
||||
if (typeof window === "undefined") return Math.max(TERMINAL_DOCKED_MIN_HEIGHT, height);
|
||||
const maxHeight = Math.max(TERMINAL_DOCKED_MIN_HEIGHT, window.innerHeight - TERMINAL_DOCKED_VIEWPORT_MARGIN);
|
||||
return Math.min(Math.max(height, TERMINAL_DOCKED_MIN_HEIGHT), maxHeight);
|
||||
function clampTerminalPanelHeight(height: number, minHeight: number, viewportReserve: number): number {
|
||||
if (typeof window === "undefined") return Math.max(minHeight, height);
|
||||
const maxHeight = Math.max(minHeight, window.innerHeight - viewportReserve);
|
||||
return Math.min(Math.max(height, minHeight), maxHeight);
|
||||
}
|
||||
|
||||
function writeTerminalDockedHeight(height: number, projectId?: string): number {
|
||||
const clamped = clampTerminalDockedHeight(height);
|
||||
function clampTerminalDockedHeight(height: number): number {
|
||||
return clampTerminalPanelHeight(height, TERMINAL_DOCKED_MIN_HEIGHT, TERMINAL_DOCKED_VIEWPORT_MARGIN);
|
||||
}
|
||||
|
||||
function clampTerminalBelowHeight(height: number): number {
|
||||
return clampTerminalPanelHeight(height, TERMINAL_BELOW_MIN_HEIGHT, TERMINAL_BELOW_APP_MIN_HEIGHT);
|
||||
}
|
||||
|
||||
function writeTerminalDockedHeight(height: number, projectId?: string, mode: "docked" | "below" = "docked"): number {
|
||||
const clamped = mode === "below" ? clampTerminalBelowHeight(height) : clampTerminalDockedHeight(height);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(`fusion:terminal-docked-height-${projectId ?? "default"}`, String(Math.round(clamped)));
|
||||
}
|
||||
@@ -525,6 +548,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
const [isMobileTerminal, setIsMobileTerminal] = useState(() => isTerminalMobileViewport());
|
||||
const isDockedMode = !isMobileTerminal && displayMode === "docked";
|
||||
const isFloatingMode = !isMobileTerminal && displayMode === "floating";
|
||||
const isBelowMode = !isMobileTerminal && displayMode === "below";
|
||||
// FNXC:FloatingWindow 2026-06-22-21:30: The FLOATING terminal shares the SINGLE cross-type floating z-index stack (floatingWindowStack) so tapping it raises it above every other floating modal regardless of type. A fresh z is claimed each time the modal opens (see effect below); tapping the panel (pointerdown/focus capture) re-raises it. Docked/mobile modes ignore this z-index (full-width bottom panel / full-screen sheet).
|
||||
const [floatingZ, setFloatingZ] = useState<number>(() => nextFloatingZ());
|
||||
const bringFloatingToFront = useCallback(() => {
|
||||
@@ -604,6 +628,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
|
||||
const setDisplayMode = useCallback((mode: TerminalDisplayMode) => {
|
||||
setDisplayModeState(writeTerminalDisplayMode(mode, projectId));
|
||||
window.dispatchEvent(new CustomEvent("fusion:terminal-display-mode-change", { detail: { projectId, mode } }));
|
||||
}, [projectId]);
|
||||
|
||||
const persistFloatingSize = useCallback((size: TerminalFloatSize) => {
|
||||
@@ -626,7 +651,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
Docked top-edge resize, smooth on touch + desktop (same technique as the right-dock pop-out RightDockExpandModal). On pointerdown we setPointerCapture on the handle and attach pointermove/up/cancel to the CAPTURED element (`captureTarget` = event.currentTarget), NOT `document` — capture redirects the full pointer stream for this pointerId to that element so element-scoped listeners receive every move even when the finger drifts off the handle, and they pair cleanly with the handle's `touch-action: none` (CSS) without a non-passive document listener. Moves are filtered by pointerId and coalesced into one rAF, so we set height at most once per frame and never thrash layout on a flood of touch-move events. localStorage is written only on pointerup (existing behavior). Teardown (pointerup/cancel + unmount via dragTeardownRef) cancels the pending rAF, releases pointer capture, and detaches listeners.
|
||||
*/
|
||||
const handleDockedResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!isDockedMode) return;
|
||||
if (!isDockedMode && !isBelowMode) return;
|
||||
event.preventDefault();
|
||||
const captureTarget = event.currentTarget;
|
||||
const pointerId = event.pointerId;
|
||||
@@ -641,7 +666,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
if (moveEvent.pointerId !== pointerId) return;
|
||||
latestHeight = clampTerminalDockedHeight(startHeight + (startY - moveEvent.clientY));
|
||||
const nextHeight = isBelowMode ? startHeight + (moveEvent.clientY - startY) : startHeight + (startY - moveEvent.clientY);
|
||||
latestHeight = isBelowMode ? clampTerminalBelowHeight(nextHeight) : clampTerminalDockedHeight(nextHeight);
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
@@ -656,7 +682,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
};
|
||||
function handlePointerUp() {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
setDockedHeight(writeTerminalDockedHeight(latestHeight, projectId));
|
||||
setDockedHeight(writeTerminalDockedHeight(latestHeight, projectId, isBelowMode ? "below" : "docked"));
|
||||
document.body.style.userSelect = previousUserSelect;
|
||||
detachListeners();
|
||||
dragTeardownRef.current = null;
|
||||
@@ -673,7 +699,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
captureTarget.addEventListener("pointermove", handlePointerMove);
|
||||
captureTarget.addEventListener("pointerup", handlePointerUp);
|
||||
captureTarget.addEventListener("pointercancel", handlePointerUp);
|
||||
}, [dockedHeight, isDockedMode, projectId]);
|
||||
}, [dockedHeight, isBelowMode, isDockedMode, projectId]);
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-06-22-19:50:
|
||||
@@ -2007,6 +2033,13 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
setDisplayMode(displayMode === "floating" ? "docked" : "floating");
|
||||
}, [displayMode, setDisplayMode]);
|
||||
|
||||
const handleToggleBelowMode = useCallback(() => {
|
||||
setDisplayMode(displayMode === "below" ? "docked" : "below");
|
||||
if (displayMode !== "below") {
|
||||
setDockedHeight((current) => clampTerminalBelowHeight(current || TERMINAL_BELOW_DEFAULT_HEIGHT));
|
||||
}
|
||||
}, [displayMode, setDisplayMode]);
|
||||
|
||||
const handlePreferenceFontSizeChange = useCallback(
|
||||
(value: string) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
@@ -2112,7 +2145,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
const isLoading = !isReady || (!activeTab && !bootstrapError);
|
||||
// FNXC:Terminal 2026-06-23-04:30: Always carry the base `terminal-modal-overlay` class so the no-dim/no-blur rule applies in EVERY mode (docked, floating, AND the mobile/default sheet that is neither) — the terminal must never dim the page behind it.
|
||||
const overlayClassName = `modal-overlay open terminal-modal-overlay${isDockedMode ? " terminal-modal-overlay--docked" : ""}${isFloatingMode ? " terminal-modal-overlay--floating" : ""}`;
|
||||
const modalClassName = `modal terminal-modal${isMobileTerminal ? " terminal-modal--mobile" : ""}${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}`;
|
||||
const modalClassName = `modal terminal-modal${isMobileTerminal ? " terminal-modal--mobile" : ""}${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}${isBelowMode ? " terminal-modal--below" : ""}`;
|
||||
const modalStyle = {
|
||||
...(keyboardOverlap > 0
|
||||
? {
|
||||
@@ -2126,6 +2159,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
}
|
||||
: {}),
|
||||
...(isDockedMode ? { "--terminal-docked-height": `${dockedHeight}px` } : {}),
|
||||
...(isBelowMode ? { "--terminal-below-height": `${clampTerminalBelowHeight(dockedHeight || TERMINAL_BELOW_DEFAULT_HEIGHT)}px` } : {}),
|
||||
...(isFloatingMode
|
||||
? {
|
||||
"--terminal-float-x": `${floatingPosition.x}px`,
|
||||
@@ -2138,36 +2172,24 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
: {}),
|
||||
} as CSSProperties;
|
||||
|
||||
// FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so the terminal shares the ONE root stacking context with the other floating modals; the shared cross-type z stack only orders correctly when all panels live at the document root. Docked/floating/mobile are all position:fixed, so portaling does not change their placement.
|
||||
return createPortal(
|
||||
const terminalPanel = (
|
||||
<div
|
||||
className={overlayClassName}
|
||||
onMouseDown={handleOverlayMouseDown}
|
||||
onMouseUp={handleOverlayMouseUp}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="terminal-modal-overlay"
|
||||
style={{
|
||||
// FNXC:FloatingWindow 2026-06-22-23:00: In floating mode the z-index lives on the fixed overlay (it owns the stacking context); a panel z is trapped inside it and loses to page stacking contexts like the right dock (position:absolute z-index:20). Docked/mobile keep their CSS z.
|
||||
...(isFloatingMode ? { zIndex: floatingZ } : {}),
|
||||
...(keyboardOverlap > 0 ? { "--overlay-padding-top": "0px" } : {}),
|
||||
} as CSSProperties}
|
||||
ref={modalRef}
|
||||
className={modalClassName}
|
||||
data-testid="terminal-modal"
|
||||
style={modalStyle}
|
||||
onPointerDownCapture={isFloatingMode ? bringFloatingToFront : undefined}
|
||||
onFocusCapture={isFloatingMode ? bringFloatingToFront : undefined}
|
||||
role={isBelowMode ? "region" : undefined}
|
||||
aria-label={isBelowMode ? t("terminal.belowRegion", "Pinned terminal") : undefined}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={modalClassName}
|
||||
data-testid="terminal-modal"
|
||||
style={modalStyle}
|
||||
onPointerDownCapture={isFloatingMode ? bringFloatingToFront : undefined}
|
||||
onFocusCapture={isFloatingMode ? bringFloatingToFront : undefined}
|
||||
>
|
||||
{isDockedMode && (
|
||||
{(isDockedMode || isBelowMode) && (
|
||||
<div
|
||||
className="terminal-docked-resize-handle"
|
||||
className={isBelowMode ? "terminal-below-resize-handle" : "terminal-docked-resize-handle"}
|
||||
data-testid="terminal-docked-resize-handle"
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label={t("terminal.resizeDockedPanel", "Resize terminal panel")}
|
||||
aria-label={isBelowMode ? t("terminal.resizeBelowPanel", "Resize pinned terminal panel") : t("terminal.resizeDockedPanel", "Resize terminal panel")}
|
||||
onPointerDown={handleDockedResizePointerDown}
|
||||
/>
|
||||
)}
|
||||
@@ -2415,6 +2437,51 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
FNXC:TerminalHeader 2026-07-04-19:16:
|
||||
Footer controls live in the terminal header so docked, floating, pinned-below, and mobile layouts keep terminal actions reachable without spending a separate footer row. The pin button mirrors the right sidebar contract: aria-pressed means the persistent below-application push layout is active.
|
||||
*/}
|
||||
<span className="terminal-font-size-controls terminal-font-size-controls--header">
|
||||
<button type="button" className="terminal-font-size-btn" onClick={handleDecreaseFontSize} data-testid="terminal-font-size-decrease" aria-label={t("terminal.decreaseFontSize", "Decrease terminal font size")}>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className="terminal-font-size-value" data-testid="terminal-font-size-value">{fontSize}{TERMINAL_KEY_LABELS.pxUnit}</span>
|
||||
<button type="button" className="terminal-font-size-btn" onClick={handleIncreaseFontSize} data-testid="terminal-font-size-increase" aria-label={t("terminal.increaseFontSize", "Increase terminal font size")}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</span>
|
||||
<button className="terminal-clear-btn" onClick={handleClear} data-testid="terminal-clear-btn" title={t("terminal.clearTerminal", "Clear terminal")}>
|
||||
<Trash2 size={14} />
|
||||
<span className="terminal-action-label">{t("terminal.clear", "Clear")}</span>
|
||||
</button>
|
||||
<button className="terminal-clear-btn terminal-clear-btn--shortcut" onClick={() => setShowShortcuts((current) => !current)} data-testid="terminal-shortcut-toggle" title={t("terminal.shortcuts", "Shortcuts")} aria-pressed={showShortcuts}>
|
||||
<Keyboard size={14} />
|
||||
<span className="terminal-action-label">{t("terminal.shortcuts", "Shortcuts")}</span>
|
||||
</button>
|
||||
<button className="terminal-clear-btn terminal-clear-btn--shortcut" onClick={() => setShowPreferences((current) => !current)} data-testid="terminal-preferences-toggle" title={t("terminal.preferences", "Preferences")} aria-pressed={showPreferences}>
|
||||
<Settings size={14} />
|
||||
<span className="terminal-action-label">{t("terminal.preferences", "Preferences")}</span>
|
||||
</button>
|
||||
<span className={`terminal-connection-status ${connectionStatus}`}>
|
||||
{connectionStatus === "connected" && t("terminal.statusConnected", "Connected")}
|
||||
{connectionStatus === "connecting" && t("terminal.statusConnecting", "Connecting...")}
|
||||
{connectionStatus === "reconnecting" && t("terminal.statusReconnecting", "Reconnecting...")}
|
||||
{connectionStatus === "disconnected" && t("terminal.statusDisconnected", "Disconnected")}
|
||||
</span>
|
||||
{exitCode !== null && <span className="terminal-exit-code" data-testid="terminal-exit-code">{t("terminal.exitLabel", "Exit: {{code}}", { code: exitCode })}</span>}
|
||||
<span className="terminal-shortcuts terminal-shortcuts--header">{t("terminal.helpText", "Ctrl++/- zoom • ⌨ Shortcuts panel • Esc close")}</span>
|
||||
{!isMobileTerminal && (
|
||||
<button
|
||||
className="terminal-clear-btn terminal-clear-btn--shortcut terminal-clear-btn--icon"
|
||||
onClick={handleToggleBelowMode}
|
||||
data-testid="terminal-pin-toggle"
|
||||
title={isBelowMode ? t("terminal.unpinTerminal", "Unpin terminal (overlay content)") : t("terminal.pinTerminal", "Pin terminal (push content)")}
|
||||
aria-label={isBelowMode ? t("terminal.unpinTerminal", "Unpin terminal (overlay content)") : t("terminal.pinTerminal", "Pin terminal (push content)")}
|
||||
aria-pressed={isBelowMode}
|
||||
>
|
||||
{isBelowMode ? <PinOff size={14} /> : <Pin size={14} />}
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
FNXC:Terminal 2026-06-23-00:15:
|
||||
Clear / Shortcuts / Preferences moved OUT of the header actions and DOWN into the bottom status bar (footer) next to the text-size control, so the header keeps only contextual reconnect/restart, the icon-only pop-out toggle, and close.
|
||||
The pop-out/dock toggle is now ICON-ONLY (no visible "Pop out"/"Dock" text); the icon flips and the title/aria-label still announce the toggle target for accessibility.
|
||||
@@ -2709,82 +2776,33 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
FNXC:Terminal 2026-06-23-00:15:
|
||||
Footer is laid out left-to-right as a flex row: the text-size control sits at the LEFT, followed by the relocated Clear / Shortcuts / Preferences action buttons (a grouped cluster). The connection-status text and zoom-hint copy stay on the right and collapse first on narrow widths. The whole control cluster wraps/scrolls when the footer is too narrow so docked/floating/mobile layouts never clip the buttons.
|
||||
*/}
|
||||
<div className="terminal-status-bar" data-testid="terminal-status-bar">
|
||||
<span className="terminal-font-size-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-font-size-btn"
|
||||
onClick={handleDecreaseFontSize}
|
||||
data-testid="terminal-font-size-decrease"
|
||||
aria-label={t("terminal.decreaseFontSize", "Decrease terminal font size")}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className="terminal-font-size-value" data-testid="terminal-font-size-value">
|
||||
{fontSize}{TERMINAL_KEY_LABELS.pxUnit}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-font-size-btn"
|
||||
onClick={handleIncreaseFontSize}
|
||||
data-testid="terminal-font-size-increase"
|
||||
aria-label={t("terminal.increaseFontSize", "Increase terminal font size")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</span>
|
||||
{/* FNXC:Terminal 2026-06-23-00:15: Clear / Shortcuts / Preferences relocated here from the header actions; same handlers, testids, and labels preserved. */}
|
||||
<span className="terminal-footer-actions" data-testid="terminal-footer-actions">
|
||||
<button
|
||||
className="terminal-clear-btn"
|
||||
onClick={handleClear}
|
||||
data-testid="terminal-clear-btn"
|
||||
title={t("terminal.clearTerminal", "Clear terminal")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span className="terminal-action-label">{t("terminal.clear", "Clear")}</span>
|
||||
</button>
|
||||
<button
|
||||
className="terminal-clear-btn terminal-clear-btn--shortcut"
|
||||
onClick={() => setShowShortcuts((current) => !current)}
|
||||
data-testid="terminal-shortcut-toggle"
|
||||
title={t("terminal.shortcuts", "Shortcuts")}
|
||||
aria-pressed={showShortcuts}
|
||||
>
|
||||
<Keyboard size={14} />
|
||||
<span className="terminal-action-label">{t("terminal.shortcuts", "Shortcuts")}</span>
|
||||
</button>
|
||||
<button
|
||||
className="terminal-clear-btn terminal-clear-btn--shortcut"
|
||||
onClick={() => setShowPreferences((current) => !current)}
|
||||
data-testid="terminal-preferences-toggle"
|
||||
title={t("terminal.preferences", "Preferences")}
|
||||
aria-pressed={showPreferences}
|
||||
>
|
||||
<Settings size={14} />
|
||||
<span className="terminal-action-label">{t("terminal.preferences", "Preferences")}</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className={`terminal-connection-status ${connectionStatus}`}>
|
||||
{connectionStatus === "connected" && t("terminal.statusConnected", "Connected")}
|
||||
{connectionStatus === "connecting" && t("terminal.statusConnecting", "Connecting...")}
|
||||
{connectionStatus === "reconnecting" && t("terminal.statusReconnecting", "Reconnecting...")}
|
||||
{connectionStatus === "disconnected" && t("terminal.statusDisconnected", "Disconnected")}
|
||||
</span>
|
||||
{exitCode !== null && (
|
||||
<span className="terminal-exit-code" data-testid="terminal-exit-code">
|
||||
{t("terminal.exitLabel", "Exit: {{code}}", { code: exitCode })}
|
||||
</span>
|
||||
)}
|
||||
<span className="terminal-shortcuts">
|
||||
{t("terminal.helpText", "Ctrl++/- zoom • ⌨ Shortcuts panel • Esc close")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isBelowMode) {
|
||||
return (
|
||||
<div className="terminal-below-host" data-testid="terminal-below-host">
|
||||
{terminalPanel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so overlay terminal modes share the ONE root stacking context with other floating modals. Below mode intentionally skips the portal so it can reserve in-flow application space.
|
||||
return createPortal(
|
||||
<div
|
||||
className={overlayClassName}
|
||||
onMouseDown={handleOverlayMouseDown}
|
||||
onMouseUp={handleOverlayMouseUp}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="terminal-modal-overlay"
|
||||
style={{
|
||||
// FNXC:FloatingWindow 2026-06-22-23:00: In floating mode the z-index lives on the fixed overlay (it owns the stacking context); a panel z is trapped inside it and loses to page stacking contexts like the right dock (position:absolute z-index:20). Docked/mobile keep their CSS z.
|
||||
...(isFloatingMode ? { zIndex: floatingZ } : {}),
|
||||
...(keyboardOverlap > 0 ? { "--overlay-padding-top": "0px" } : {}),
|
||||
} as CSSProperties}
|
||||
>
|
||||
{terminalPanel}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -490,7 +490,8 @@ describe("TerminalModal", () => {
|
||||
expect(menuRule).toContain("max-height: min(var(--terminal-workspace-menu-height), calc(100dvh - (var(--space-md) * 2)));");
|
||||
expect(menuRule).toContain("overflow-y: auto;");
|
||||
expect(menuRule).toContain("overscroll-behavior: contain;");
|
||||
expect(actionsRule).toContain("flex: 0 0 auto;");
|
||||
expect(actionsRule).toContain("flex: 1 1 auto;");
|
||||
expect(actionsRule).toContain("overflow-x: auto;");
|
||||
expect(mobileHeaderRule).toContain("flex-wrap: wrap;");
|
||||
expect(mobileHeaderRule).toContain("overflow: hidden;");
|
||||
expect(mobileTerminalTabsRule).toContain("display: none;");
|
||||
@@ -551,6 +552,93 @@ describe("TerminalModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults missing and invalid display-mode storage to overlay docked mode", async () => {
|
||||
const missingProjectId = "missing-display-mode-test";
|
||||
window.localStorage.removeItem(`fusion:terminal-display-mode-${missingProjectId}`);
|
||||
|
||||
const { unmount } = render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={missingProjectId} />);
|
||||
expect(await screen.findByTestId("terminal-modal")).toHaveClass("terminal-modal--docked");
|
||||
expect(screen.getByTestId("terminal-modal-overlay")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("terminal-below-host")).toBeNull();
|
||||
unmount();
|
||||
|
||||
const invalidProjectId = "invalid-display-mode-test";
|
||||
window.localStorage.setItem(`fusion:terminal-display-mode-${invalidProjectId}`, "sideways");
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={invalidProjectId} />);
|
||||
|
||||
expect(await screen.findByTestId("terminal-modal")).toHaveClass("terminal-modal--docked");
|
||||
expect(screen.getByTestId("terminal-modal-overlay")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("terminal-below-host")).toBeNull();
|
||||
});
|
||||
|
||||
it("pins and persists the terminal below the application with right-dock-style labels", async () => {
|
||||
const projectId = "below-pin-test";
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} />);
|
||||
|
||||
const pin = await screen.findByTestId("terminal-pin-toggle");
|
||||
expect(pin).toHaveAttribute("aria-label", "Pin terminal (push content)");
|
||||
expect(pin).toHaveAttribute("title", "Pin terminal (push content)");
|
||||
expect(pin).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
fireEvent.click(pin);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.localStorage.getItem(`fusion:terminal-display-mode-${projectId}`)).toBe("below");
|
||||
expect(screen.getByTestId("terminal-below-host")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("terminal-modal-overlay")).toBeNull();
|
||||
expect(screen.getByTestId("terminal-modal")).toHaveClass("terminal-modal--below");
|
||||
});
|
||||
expect(screen.getByTestId("terminal-pin-toggle")).toHaveAttribute("aria-label", "Unpin terminal (overlay content)");
|
||||
expect(screen.getByTestId("terminal-pin-toggle")).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-pin-toggle"));
|
||||
await waitFor(() => {
|
||||
expect(window.localStorage.getItem(`fusion:terminal-display-mode-${projectId}`)).toBe("docked");
|
||||
expect(screen.getByTestId("terminal-modal-overlay")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("terminal-below-host")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps floating and mobile modes out of the below-layout shell", async () => {
|
||||
const floatingProjectId = "floating-no-below-shell";
|
||||
window.localStorage.setItem(`fusion:terminal-display-mode-${floatingProjectId}`, "floating");
|
||||
const { unmount } = render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={floatingProjectId} />);
|
||||
expect(await screen.findByTestId("terminal-modal")).toHaveClass("terminal-modal--floating");
|
||||
expect(screen.queryByTestId("terminal-below-host")).toBeNull();
|
||||
unmount();
|
||||
|
||||
const previousInnerWidth = window.innerWidth;
|
||||
const previousOntouchstart = window.ontouchstart;
|
||||
Object.defineProperty(window, "innerWidth", { value: 500, configurable: true });
|
||||
Object.defineProperty(window, "ontouchstart", { value: null, configurable: true });
|
||||
try {
|
||||
window.localStorage.setItem("fusion:terminal-display-mode-mobile-no-below-shell", "below");
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="mobile-no-below-shell" />);
|
||||
expect(await screen.findByTestId("terminal-modal")).not.toHaveClass("terminal-modal--below");
|
||||
expect(screen.queryByTestId("terminal-below-host")).toBeNull();
|
||||
expect(screen.queryByTestId("terminal-pin-toggle")).toBeNull();
|
||||
} finally {
|
||||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||||
if (previousOntouchstart === undefined) {
|
||||
delete (window as any).ontouchstart;
|
||||
} else {
|
||||
Object.defineProperty(window, "ontouchstart", { value: previousOntouchstart, configurable: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("encodes below-terminal in-flow layout without fixed overlay geometry", () => {
|
||||
const hostRule = terminalModalCss.match(/\.terminal-below-host\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||
const belowRule = terminalModalCss.match(/\.modal\.terminal-modal\.terminal-modal--below\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||
const footerShellRule = terminalModalCss.match(/\.terminal-status-bar\s*\{/);
|
||||
|
||||
expect(hostRule).toContain("display: flex;");
|
||||
expect(belowRule).toContain("position: relative;");
|
||||
expect(belowRule).not.toContain("position: fixed;");
|
||||
expect(belowRule).toContain("height: var(--terminal-below-height);");
|
||||
expect(footerShellRule).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes floating drag and resize handles and refits after floating resize", async () => {
|
||||
const projectId = "floating-resize-test";
|
||||
window.localStorage.setItem(`fusion:terminal-display-mode-${projectId}`, "floating");
|
||||
@@ -3147,14 +3235,14 @@ describe("TerminalModal — mobile layout contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("status-bar shows connection state text alongside tabs row", async () => {
|
||||
it("header actions show connection state without a footer status-bar shell", 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(screen.queryByTestId("terminal-status-bar")).toBeNull();
|
||||
expect(screen.queryByTestId("terminal-footer-actions")).toBeNull();
|
||||
const actions = screen.getByTestId("terminal-actions");
|
||||
const connectionStatus = actions.querySelector(".terminal-connection-status");
|
||||
expect(connectionStatus?.textContent).toBe("Disconnected");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user