FN-7897: reserve footer space for pinned below-mode terminal

Fix the pinned (below-mode) terminal panel rendering underneath the fixed ExecutorStatusBar footer, so its bottom action-control row stays visible on desktop and mobile alike.

- Add a footerVisible prop to TerminalModal, wired from App.tsx's executorFooterVisible state
- Add .terminal-below-host--with-footer CSS modifier that redeclares --executor-footer-height and reserves padding-bottom (with the Android Chrome ICB offset), matching the .project-content--with-footer/.left-sidebar-nav--with-footer/.right-dock--with-footer precedent
- Update dashboard-guide.md terminal walkthrough to describe the new footer-avoidance behavior
- Add/extend TerminalModal tests covering the footerVisible prop and CSS modifier
- Add a patch changeset for @runfusion/fusion documenting the fix

Files changed:
 .../fn-7897-pinned-terminal-footer-overlap.md      |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 packages/dashboard/app/App.tsx                     |   1 +
 .../dashboard/app/components/TerminalModal.css     |  12 ++
 .../dashboard/app/components/TerminalModal.tsx     |  17 ++-
 .../components/__tests__/TerminalModal.test.tsx    | 135 ++++++++++++++++++++-
 6 files changed, 170 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-7897

Fusion-Task-Lineage: e22db6a9-35a8-46a1-8c15-186eaf5267fd

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 19:00:16 -07:00
parent f7e678bc8d
commit 02fdb4c089
6 changed files with 170 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix pinned terminal rendering underneath the status footer.
category: fix
dev: `.terminal-below-host` now reserves `--executor-footer-height` via a new `footerVisible` prop + `.terminal-below-host--with-footer` CSS modifier (matching `.project-content--with-footer`/`.left-sidebar-nav--with-footer`/`.right-dock--with-footer`), so the pinned/below terminal panel no longer sits underneath the fixed `ExecutorStatusBar`.

View File

@@ -651,7 +651,7 @@ 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 overlay panel with the active shell session and a draggable top resize handle. The font size / clear / shortcuts / preferences controls, connection status, pin, and pop-out controls render in the terminal's bottom action-control footer at every desktop/tablet width, so the header only has to carry the tab affordance, title/status, workspace picker, and close button.
2. Select **Pin terminal (push content)** from the bottom action-control footer.
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.
Expected outcome: the terminal moves into a persisted below-application panel that reserves space instead of covering the board, chat, or right sidebar. The pinned panel also reserves space above the fixed status footer (the executor status bar), so the terminal's own bottom action-control footer (font size, Clear, Shortcuts, Preferences, connection status, pin/pop-out) stays fully visible instead of being covered by it. 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 bottom action-control footer.

View File

@@ -1635,6 +1635,7 @@ function AppInner() {
initialCommand={modalManager.terminalInitialCommand}
initialCommandGeneration={modalManager.terminalInitialCommandGeneration}
projectId={currentProject.id}
footerVisible={executorFooterVisible}
/>
)}
</div>

View File

@@ -141,6 +141,9 @@ Larger grab target for the docked terminal top resize handle: it straddles the p
/*
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.
FNXC:TerminalLayout 2026-07-12-18:50:
FN-7897 fixed the pinned terminal rendering underneath the fixed ExecutorStatusBar footer. .terminal-below-host is a sibling of .dashboard-project-shell inside .dashboard-project-stack (not a descendant), so unlike .left-sidebar-nav--with-footer/.right-dock--with-footer it cannot rely on --executor-footer-height inherited from the shell; the --with-footer modifier below redeclares the token at this consumer, matching the .project-content--with-footer precedent.
*/
.terminal-below-host {
display: flex;
@@ -151,6 +154,15 @@ Pinned terminal mode is an in-flow project-shell child instead of a portaled ove
background: var(--surface);
}
/*
FNXC:TerminalLayout 2026-07-12-18:50:
FN-7897: reserve space for the fixed ExecutorStatusBar footer when it is rendered (footerVisible prop), so the pinned/below terminal's own bottom .terminal-status-bar action row stays fully visible above the footer instead of being painted over. --executor-footer-height is redeclared here (not inherited — see comment above) to match the .project-content--with-footer precedent; --icb-bottom-offset mirrors the same Android Chrome ICB compensation already applied to .terminal-modal--docked's bottom calc.
*/
.terminal-below-host--with-footer {
--executor-footer-height: 36px;
padding-bottom: calc(var(--icb-bottom-offset, 0px) + var(--executor-footer-height));
}
.modal.terminal-modal.terminal-modal--below {
position: relative;
width: 100%;

View File

@@ -528,6 +528,8 @@ interface TerminalModalProps {
defaultCwd?: string;
/** Optional terminal-session namespace, usually the owning task id. */
scopeId?: string;
/** Whether the fixed ExecutorStatusBar footer is currently rendered; reserves space for it in below-mode. */
footerVisible?: boolean;
}
/**
@@ -545,7 +547,7 @@ interface TerminalModalProps {
*
* The terminal spawns a real shell (bash/zsh/powershell based on platform).
*/
export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandGeneration = 0, projectId, embedded = false, defaultCwd, scopeId }: TerminalModalProps) {
export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandGeneration = 0, projectId, embedded = false, defaultCwd, scopeId, footerVisible = false }: TerminalModalProps) {
const { t } = useTranslation("app");
const [error, setError] = useState<string | null>(null);
const [exitCode, setExitCode] = useState<number | null>(null);
@@ -3256,8 +3258,19 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
}
if (isBelowMode) {
/*
FNXC:TerminalLayout 2026-07-12-18:50:
FN-7897 fixed the pinned terminal rendering underneath the fixed ExecutorStatusBar footer.
.terminal-below-host is a sibling of .dashboard-project-shell inside .dashboard-project-stack
(not a descendant), so it cannot rely on --executor-footer-height inherited from the shell — it
must reserve the footer's height itself via the --with-footer modifier, following the same
footerVisible-prop convention used by .project-content--with-footer/.left-sidebar-nav--with-footer/.right-dock--with-footer.
*/
return (
<div className="terminal-below-host" data-testid="terminal-below-host">
<div
className={`terminal-below-host${footerVisible ? " terminal-below-host--with-footer" : ""}`}
data-testid="terminal-below-host"
>
{terminalPanel}
</div>
);

View File

@@ -879,6 +879,111 @@ describe("TerminalModal", () => {
});
});
// FN-7897: below-mode is reachable at both true desktop (>1024px) and tablet
// (769-1024px) widths — isBelowMode has no additional breakpoint gating beyond
// "not mobile". Exercise both explicitly (rather than relying on jsdom's default
// 1024px width, which sits exactly on the tablet/desktop boundary) so the fix is
// proven at a clearly-desktop viewport, not just the ambiguous default.
it.each([
["desktop", 1440],
["tablet", 900],
])(
"reserves executor footer height on the pinned terminal host when footerVisible is true (%s width, FN-7897)",
async (_label, width) => {
const previousInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
try {
const projectId = `below-pin-footer-visible-${width}`;
render(
<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} footerVisible={true} />,
);
const pin = await screen.findByTestId("terminal-pin-toggle");
fireEvent.click(pin);
await waitFor(() => {
const host = screen.getByTestId("terminal-below-host");
expect(host).toHaveClass("terminal-below-host");
expect(host).toHaveClass("terminal-below-host--with-footer");
});
} finally {
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
}
},
);
it("does not reserve executor footer height on the pinned terminal host when footerVisible is false or omitted (FN-7897)", async () => {
// Default width (no explicit resize) — the negative-case default/omitted-prop path.
const projectId = "below-pin-footer-hidden";
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} />);
const pin = await screen.findByTestId("terminal-pin-toggle");
fireEvent.click(pin);
await waitFor(() => {
const host = screen.getByTestId("terminal-below-host");
expect(host).toHaveClass("terminal-below-host");
expect(host).not.toHaveClass("terminal-below-host--with-footer");
});
// Explicit footerVisible={false} behaves identically to the omitted-prop default.
const explicitFalseProjectId = "below-pin-footer-explicit-false";
render(
<TerminalModal
isOpen={true}
onClose={mockOnClose}
projectId={explicitFalseProjectId}
footerVisible={false}
/>,
);
const pins = await screen.findAllByTestId("terminal-pin-toggle");
fireEvent.click(pins[pins.length - 1]);
await waitFor(() => {
const hosts = screen.getAllByTestId("terminal-below-host");
const lastHost = hosts[hosts.length - 1];
expect(lastHost).not.toHaveClass("terminal-below-host--with-footer");
});
});
it("tracks footerVisible across re-renders with no stale --with-footer class (pin \u2192 unpin \u2192 re-pin, FN-7897)", async () => {
const projectId = "below-pin-footer-toggle-sequence";
const { rerender } = render(
<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} footerVisible={true} />,
);
const pin = await screen.findByTestId("terminal-pin-toggle");
fireEvent.click(pin);
await waitFor(() => {
expect(screen.getByTestId("terminal-below-host")).toHaveClass("terminal-below-host--with-footer");
});
// Simulate navigating away from "project" view mode while pinned: footerVisible flips to false.
rerender(
<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} footerVisible={false} />,
);
await waitFor(() => {
expect(screen.getByTestId("terminal-below-host")).not.toHaveClass("terminal-below-host--with-footer");
});
// Unpin (back to overlay docked mode), then re-pin with footerVisible restored to true.
fireEvent.click(screen.getByTestId("terminal-pin-toggle"));
await waitFor(() => {
expect(screen.queryByTestId("terminal-below-host")).toBeNull();
});
rerender(
<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} footerVisible={true} />,
);
fireEvent.click(screen.getByTestId("terminal-pin-toggle"));
await waitFor(() => {
const host = screen.getByTestId("terminal-below-host");
expect(host).toHaveClass("terminal-below-host--with-footer");
// No stale/duplicate class fragments from prior mount/unmount cycles.
expect(host.className.match(/terminal-below-host--with-footer/g)?.length ?? 0).toBe(1);
});
});
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");
@@ -893,7 +998,16 @@ describe("TerminalModal", () => {
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" />);
// FN-7897: footerVisible={true} must not resurrect the below-layout shell (or its
// --with-footer reservation) on the mobile fullscreen-sheet fallback path.
render(
<TerminalModal
isOpen={true}
onClose={mockOnClose}
projectId="mobile-no-below-shell"
footerVisible={true}
/>,
);
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();
@@ -922,6 +1036,25 @@ describe("TerminalModal", () => {
expect(footerRule).toContain("min-width: 0;");
expect(footerRule).toContain("overflow-x: auto;");
expect(footerRule).toContain("touch-action: pan-x pan-y;");
// FN-7897: the base .terminal-below-host rule must NOT reserve footer space unconditionally
// (that would leave a dead gap when the footer is not visible) — only the --with-footer
// modifier below reserves it.
expect(hostRule).not.toContain("padding-bottom");
});
it("reserves executor footer height on the pinned terminal host only when footerVisible (FN-7897)", () => {
// .terminal-below-host is a SIBLING of .dashboard-project-shell inside .dashboard-project-stack
// (not a descendant), so it cannot rely on --executor-footer-height being inherited from the
// shell the way .left-sidebar-nav--with-footer/.right-dock--with-footer do — it must redeclare
// the token at this consumer, matching the .project-content--with-footer precedent.
const withFooterRule =
terminalModalCss.match(/\.terminal-below-host--with-footer\s*\{([^}]*)\}/)?.[1] ?? "";
expect(withFooterRule).toContain("--executor-footer-height: 36px;");
expect(withFooterRule).toContain(
"padding-bottom: calc(var(--icb-bottom-offset, 0px) + var(--executor-footer-height));",
);
});
it("exposes floating drag and resize handles and refits after floating resize", async () => {