FN-8633: improve tablet terminal dragging
Give floating tablet terminals a dedicated drag grip while preserving tab-strip panning. - add a touch-sized tablet-only header drag grip and pop-out hit target - preserve floating geometry at the tablet breakpoint and document the gesture - cover grip availability, dragging, and horizontal tab-panning CSS isolation Files changed: .changeset/fn-8633-tablet-terminal-drag.md | 7 ++ docs/dashboard-guide.md | 4 +- packages/dashboard/app/components/TerminalModal.css | 61 +++++++++++++ packages/dashboard/app/components/TerminalModal.tsx | 16 ++++ packages/dashboard/app/components/__tests__/TerminalModal.test.tsx | 99 ++++++++++++++++++++++ 5 files changed, 185 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-8633 Fusion-Task-Lineage: f1c442a8-3302-4f6a-98e9-f1efa4083c12 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8633-tablet-terminal-drag.md
Normal file
7
.changeset/fn-8633-tablet-terminal-drag.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Make floating terminal windows reliably draggable on touch tablets.
|
||||
category: fix
|
||||
dev: Adds a reserved tablet-only terminal header drag grip and touch-sized pop-out target.
|
||||
@@ -817,7 +817,7 @@ Mailbox view shows inbox/outbox communication threads and unread state. When an
|
||||
|
||||
## Interactive Terminal
|
||||
|
||||
Fusion embeds a terminal using xterm.js. Desktop and tablet use the footer status bar as the terminal launcher; mobile keeps the full-screen terminal path. Known touch tablets, including at the 768px responsive boundary, retain docked/floating presentation rather than falling back to the phone sheet. Their saved floating size and position and touch drag/edge-or-corner resize controls stay available when a software keyboard shortens the visual viewport. True narrow phones, including folded panes and short phone landscapes, intentionally remain full-screen.
|
||||
Fusion embeds a terminal using xterm.js. Desktop and tablet use the footer status bar as the terminal launcher; mobile keeps the full-screen terminal path. Known touch tablets, including at the 768px responsive boundary, retain docked/floating presentation rather than falling back to the phone sheet. Their saved floating size and position and touch drag/edge-or-corner resize controls stay available when a software keyboard shortens the visual viewport. In tablet floating mode, use the reserved grip at the left side of the terminal header to move the window; it stays available even when the tab strip overflows. True narrow phones, including folded panes and short phone landscapes, intentionally remain full-screen.
|
||||
|
||||
<!-- FNXC:Terminal 2026-07-11-18:20: FN-7824 first-launch terminal sockets auto-retry with capped backoff until the first successful open, so the manual Reconnect affordance is reserved for terminal sessions that already connected and then exhaust their mid-session reconnect budget. -->
|
||||
On first launch or first open, the terminal keeps reconnecting automatically until its initial WebSocket opens; it should show **Reconnecting...** during that cold-start recovery rather than requiring a manual **Reconnect** click. If an already-connected terminal drops and exhausts its bounded reconnect budget, Fusion then parks it as **Disconnected** and surfaces the manual **Reconnect** control.
|
||||
@@ -838,7 +838,7 @@ Use the terminal on desktop/tablet:
|
||||
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.
|
||||
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.
|
||||
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. On touch tablets, drag the reserved header grip rather than the horizontally scrollable tab strip.
|
||||
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.
|
||||
|
||||
@@ -241,6 +241,67 @@ FN-7897: reserve space for the fixed ExecutorStatusBar footer when it is rendere
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TerminalModalControls 2026-07-31-22:41:
|
||||
The tablet classifier, rather than the legacy 769px media query, owns this grip so a known
|
||||
768px touch tablet remains a floating window instead of receiving phone-sheet chrome. Only the
|
||||
reserved grip disables browser gesture handling: applying `touch-action: none` to the panel or
|
||||
header prevents native horizontal panning in the descendant overflowing tab strip.
|
||||
*/
|
||||
.modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-header__drag-grip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-inline-size: var(--modal-resize-touch-target);
|
||||
min-block-size: var(--modal-resize-touch-target);
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-header__drag-grip::before {
|
||||
content: "";
|
||||
inline-size: var(--space-lg);
|
||||
block-size: var(--space-xs);
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--text-muted) 55%, transparent);
|
||||
}
|
||||
|
||||
.modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-header__drag-grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TerminalModalControls 2026-07-31-22:41:
|
||||
The tab strip must retain native horizontal touch panning while the sibling grip owns window
|
||||
movement. This explicit contract prevents future tablet drag rules from suppressing tab scrolling.
|
||||
*/
|
||||
.modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-tabs {
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TerminalModalControls 2026-07-31-22:19:
|
||||
Tablets start docked, so the footer pop-out control needs the same touch-sized reach without
|
||||
changing its established 14px icon or footer layout. Its pseudo-element only enlarges the button
|
||||
hit area; it is not a second terminal control.
|
||||
*/
|
||||
.modal.terminal-modal.terminal-modal--tablet .terminal-clear-btn.terminal-clear-btn--icon[data-testid="terminal-popout-toggle"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal.terminal-modal.terminal-modal--tablet .terminal-clear-btn.terminal-clear-btn--icon[data-testid="terminal-popout-toggle"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inline-size: var(--modal-resize-touch-target);
|
||||
block-size: var(--modal-resize-touch-target);
|
||||
inset-inline-start: 50%;
|
||||
inset-block-start: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.terminal-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -2426,12 +2426,18 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
</>
|
||||
);
|
||||
|
||||
/*
|
||||
FNXC:TerminalModalControls 2026-07-31-22:19:
|
||||
The tab scroller owns tab presses and horizontal panning; floating-window drags begin only on
|
||||
the reserved grip so a tab interaction cannot leave a captured header drag behind.
|
||||
*/
|
||||
const renderTerminalTabStrip = (measuring = false) => (
|
||||
<div
|
||||
ref={measuring || !tabsOverflow ? terminalTabsMeasureRef : undefined}
|
||||
className={`terminal-tabs${measuring ? " terminal-tabs--measuring" : ""}`}
|
||||
data-testid={measuring ? "terminal-tabs-measuring" : "terminal-tabs"}
|
||||
aria-hidden={measuring || undefined}
|
||||
onPointerDown={measuring ? undefined : (event) => event.stopPropagation()}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
@@ -2541,6 +2547,16 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
{/* Header — on mobile (≤768px) use compact selector/actions;
|
||||
.terminal-title is hidden; action button labels are hidden (icons only) */}
|
||||
<div className="terminal-header">
|
||||
{/*
|
||||
FNXC:TerminalModalControls 2026-07-31-22:19:
|
||||
Tablet floating terminals need a reserved, real pointer target because the flexing tab
|
||||
strip otherwise consumes the entire delegated header handle. This plain element—not a
|
||||
pseudo-element—is the testable drag target and remains outside the interactive-element
|
||||
suppression filter in FloatingWindow.
|
||||
*/}
|
||||
{isTabletTerminal && !isMobileTerminal && !embedded && isFloatingMode && (
|
||||
<div className="terminal-header__drag-grip" data-testid="terminal-drag-grip" aria-hidden="true" />
|
||||
)}
|
||||
{/* Tab Bar */}
|
||||
{isMobileTerminal ? renderTerminalMobileTabs() : (
|
||||
<div className="terminal-tab-region" ref={terminalTabRegionRef}>
|
||||
|
||||
@@ -504,6 +504,7 @@ describe("TerminalModal", () => {
|
||||
expect(screen.queryByTestId("terminal-close-btn")).toBeNull();
|
||||
expect(screen.queryByTestId("terminal-popout-toggle")).toBeNull();
|
||||
expect(screen.queryByTestId("terminal-pin-toggle")).toBeNull();
|
||||
expect(screen.queryByTestId("terminal-drag-grip")).toBeNull();
|
||||
expect(screen.getByTestId("terminal-tabs")).toBeTruthy();
|
||||
expect(mockUseTerminalSessions).toHaveBeenCalledWith("proj-123", {
|
||||
storageScope: "task:FN-7813",
|
||||
@@ -959,6 +960,7 @@ describe("TerminalModal", () => {
|
||||
|
||||
const modal = await screen.findByTestId("terminal-modal");
|
||||
expect(modal).toHaveClass("terminal-modal--docked");
|
||||
expect(screen.queryByTestId("terminal-drag-grip")).toBeNull();
|
||||
expect(screen.getByTestId("terminal-docked-resize-handle")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-popout-toggle"));
|
||||
@@ -966,6 +968,7 @@ describe("TerminalModal", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-modal")).toHaveClass("terminal-modal--floating");
|
||||
expect(screen.getByTestId("terminal-modal")).not.toHaveClass("terminal-modal--docked");
|
||||
expect(screen.queryByTestId("terminal-drag-grip")).toBeNull();
|
||||
expect(screen.getByTestId("floating-window-resize-se")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1351,6 +1354,8 @@ describe("TerminalModal", () => {
|
||||
const modal = await screen.findByTestId("terminal-modal");
|
||||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||||
expect(modal).toHaveClass("terminal-modal--tablet", "terminal-modal--floating");
|
||||
expect(modal).not.toHaveClass("terminal-modal--mobile");
|
||||
expect(screen.getByTestId("terminal-drag-grip")).toBeInTheDocument();
|
||||
// The 768px CSS fallback is full-screen only for true phones. A known
|
||||
// tablet must win that cascade with its stored floating geometry.
|
||||
const modalStyle = screen.getByTestId(`floating-window-terminal-${projectId}`).style;
|
||||
@@ -1391,6 +1396,99 @@ describe("TerminalModal", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("gives tablet floating terminals a real touch drag grip without affecting other presentations", async () => {
|
||||
const projectId = "tablet-terminal-drag-grip";
|
||||
const previousInnerWidth = window.innerWidth;
|
||||
const previousInnerHeight = window.innerHeight;
|
||||
const previousScreen = Object.getOwnPropertyDescriptor(window, "screen");
|
||||
const previousMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
|
||||
const setActiveTab = vi.fn();
|
||||
const tabs = [
|
||||
defaultTab,
|
||||
{ ...defaultTab, id: "tab-2", title: "a deliberately long second terminal tab", isActive: false },
|
||||
{ ...defaultTab, id: "tab-3", title: "a deliberately long third terminal tab", isActive: false },
|
||||
];
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: 768 });
|
||||
Object.defineProperty(window, "screen", { configurable: true, value: { width: 1024, height: 768 } });
|
||||
Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 });
|
||||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
matches: query === "(min-width: 769px) and (max-width: 1024px)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
window.localStorage.setItem(`fusion:terminal-display-mode-${projectId}`, "floating");
|
||||
mockUseTerminalSessions.mockReturnValue({ ...defaultSessionState, tabs, activeTab: tabs[0], setActiveTab });
|
||||
|
||||
try {
|
||||
const { unmount } = render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} />);
|
||||
const modal = await screen.findByTestId("terminal-modal");
|
||||
const panel = screen.getByTestId(`floating-window-terminal-${projectId}`) as HTMLElement & {
|
||||
setPointerCapture: (pointerId: number) => void;
|
||||
releasePointerCapture: (pointerId: number) => void;
|
||||
};
|
||||
const grip = screen.getByTestId("terminal-drag-grip");
|
||||
panel.setPointerCapture = vi.fn();
|
||||
panel.releasePointerCapture = vi.fn();
|
||||
|
||||
expect(modal).toHaveClass("terminal-modal--tablet", "terminal-modal--floating");
|
||||
expect(modal).not.toHaveClass("terminal-modal--mobile");
|
||||
expect(grip).toHaveAttribute("aria-hidden", "true");
|
||||
expect(grip.previousElementSibling).toBeNull();
|
||||
expect(grip.tagName).toBe("DIV");
|
||||
|
||||
const initialLeft = panel.style.left;
|
||||
const initialTop = panel.style.top;
|
||||
fireEvent.pointerDown(grip, { pointerId: 63, pointerType: "touch", clientX: 100, clientY: 100 });
|
||||
fireEvent.pointerMove(grip, { pointerId: 63, pointerType: "touch", clientX: 20, clientY: 20 });
|
||||
fireEvent.pointerUp(grip, { pointerId: 63, pointerType: "touch" });
|
||||
await waitFor(() => {
|
||||
expect(panel.setPointerCapture).toHaveBeenCalledWith(63);
|
||||
expect(panel.style.left).not.toBe(initialLeft);
|
||||
expect(panel.style.top).not.toBe(initialTop);
|
||||
expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-geometry-${projectId}`) ?? "{}").position).toEqual({ x: 16, y: 80 });
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(screen.getAllByRole("tab")[1], { pointerId: 64, pointerType: "touch" });
|
||||
fireEvent.click(screen.getAllByRole("tab")[1]);
|
||||
expect(setActiveTab).toHaveBeenCalledWith("tab-2");
|
||||
expect(panel.setPointerCapture).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
} finally {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: previousInnerWidth });
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: previousInnerHeight });
|
||||
if (previousScreen) Object.defineProperty(window, "screen", previousScreen);
|
||||
if (previousMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", previousMaxTouchPoints);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the tablet drag grip isolated from the horizontally pannable tab strip", () => {
|
||||
const gripSelector = ".modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-header__drag-grip";
|
||||
const tabsSelector = ".modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-tabs";
|
||||
const gripSelectorIndex = terminalModalCss.indexOf(gripSelector);
|
||||
const gripRuleEnd = terminalModalCss.indexOf("}", gripSelectorIndex);
|
||||
const gripRule = terminalModalCss.slice(gripSelectorIndex, gripRuleEnd);
|
||||
const tabsSelectorIndex = terminalModalCss.indexOf(tabsSelector);
|
||||
const tabsRuleEnd = terminalModalCss.indexOf("}", tabsSelectorIndex);
|
||||
const tabsRule = terminalModalCss.slice(tabsSelectorIndex, tabsRuleEnd);
|
||||
|
||||
expect(gripSelectorIndex).toBeGreaterThan(-1);
|
||||
expect(gripRule).toContain("min-block-size: var(--modal-resize-touch-target);");
|
||||
expect(gripRule).toContain("min-inline-size: var(--modal-resize-touch-target);");
|
||||
expect(gripRule).toContain("touch-action: none;");
|
||||
expect(tabsSelectorIndex).toBeGreaterThan(-1);
|
||||
expect(tabsRule).toContain("touch-action: pan-x;");
|
||||
expect(terminalModalCss).not.toContain(".modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating {\n touch-action: none;");
|
||||
expect(terminalModalCss).not.toContain(".modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-header {\n touch-action: none;");
|
||||
expect(terminalModalCss.slice(0, gripSelectorIndex)).not.toContain("@media (min-width: 769px)");
|
||||
expect(loadAllAppCss()).toContain(gripSelector);
|
||||
});
|
||||
|
||||
it("keeps the floating terminal touch-draggable with theme-controlled shadow", () => {
|
||||
const floatingWindowCss = readAppFile("components/FloatingWindow.css");
|
||||
expect(floatingWindowCss).toContain("box-shadow: var(--floating-window-shadow, var(--shadow-lg));");
|
||||
@@ -1412,6 +1510,7 @@ describe("TerminalModal", () => {
|
||||
expect(modal).not.toHaveClass("terminal-modal--floating");
|
||||
expect(screen.queryByTestId("terminal-docked-resize-handle")).toBeNull();
|
||||
expect(screen.queryByTestId("terminal-popout-toggle")).toBeNull();
|
||||
expect(screen.queryByTestId("terminal-drag-grip")).toBeNull();
|
||||
expect(screen.queryByTestId("floating-window-resize-se")).toBeNull();
|
||||
} finally {
|
||||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||||
|
||||
Reference in New Issue
Block a user