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:
gsxdsm
2026-07-31 15:44:39 -07:00
parent 1660b136d7
commit 37d891e879
5 changed files with 185 additions and 2 deletions

View 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.

View File

@@ -817,7 +817,7 @@ Mailbox view shows inbox/outbox communication threads and unread state. When an
## Interactive Terminal ## 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. --> <!-- 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. 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. 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. 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. 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. 5. Select **Dock** in the floating terminal.
Expected outcome: the terminal returns to the bottom docked overlay panel using the saved docked height. 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. 6. Select the scripts chevron beside the footer **Terminal** button.

View File

@@ -241,6 +241,67 @@ FN-7897: reserve space for the fixed ExecutorStatusBar footer when it is rendere
border-radius: 2px; 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 { .terminal-tab {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -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) => ( const renderTerminalTabStrip = (measuring = false) => (
<div <div
ref={measuring || !tabsOverflow ? terminalTabsMeasureRef : undefined} ref={measuring || !tabsOverflow ? terminalTabsMeasureRef : undefined}
className={`terminal-tabs${measuring ? " terminal-tabs--measuring" : ""}`} className={`terminal-tabs${measuring ? " terminal-tabs--measuring" : ""}`}
data-testid={measuring ? "terminal-tabs-measuring" : "terminal-tabs"} data-testid={measuring ? "terminal-tabs-measuring" : "terminal-tabs"}
aria-hidden={measuring || undefined} aria-hidden={measuring || undefined}
onPointerDown={measuring ? undefined : (event) => event.stopPropagation()}
> >
{tabs.map((tab) => ( {tabs.map((tab) => (
<div <div
@@ -2541,6 +2547,16 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
{/* Header — on mobile (≤768px) use compact selector/actions; {/* Header — on mobile (≤768px) use compact selector/actions;
.terminal-title is hidden; action button labels are hidden (icons only) */} .terminal-title is hidden; action button labels are hidden (icons only) */}
<div className="terminal-header"> <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 */} {/* Tab Bar */}
{isMobileTerminal ? renderTerminalMobileTabs() : ( {isMobileTerminal ? renderTerminalMobileTabs() : (
<div className="terminal-tab-region" ref={terminalTabRegionRef}> <div className="terminal-tab-region" ref={terminalTabRegionRef}>

View File

@@ -504,6 +504,7 @@ describe("TerminalModal", () => {
expect(screen.queryByTestId("terminal-close-btn")).toBeNull(); expect(screen.queryByTestId("terminal-close-btn")).toBeNull();
expect(screen.queryByTestId("terminal-popout-toggle")).toBeNull(); expect(screen.queryByTestId("terminal-popout-toggle")).toBeNull();
expect(screen.queryByTestId("terminal-pin-toggle")).toBeNull(); expect(screen.queryByTestId("terminal-pin-toggle")).toBeNull();
expect(screen.queryByTestId("terminal-drag-grip")).toBeNull();
expect(screen.getByTestId("terminal-tabs")).toBeTruthy(); expect(screen.getByTestId("terminal-tabs")).toBeTruthy();
expect(mockUseTerminalSessions).toHaveBeenCalledWith("proj-123", { expect(mockUseTerminalSessions).toHaveBeenCalledWith("proj-123", {
storageScope: "task:FN-7813", storageScope: "task:FN-7813",
@@ -959,6 +960,7 @@ describe("TerminalModal", () => {
const modal = await screen.findByTestId("terminal-modal"); const modal = await screen.findByTestId("terminal-modal");
expect(modal).toHaveClass("terminal-modal--docked"); expect(modal).toHaveClass("terminal-modal--docked");
expect(screen.queryByTestId("terminal-drag-grip")).toBeNull();
expect(screen.getByTestId("terminal-docked-resize-handle")).toBeInTheDocument(); expect(screen.getByTestId("terminal-docked-resize-handle")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("terminal-popout-toggle")); fireEvent.click(screen.getByTestId("terminal-popout-toggle"));
@@ -966,6 +968,7 @@ describe("TerminalModal", () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId("terminal-modal")).toHaveClass("terminal-modal--floating"); expect(screen.getByTestId("terminal-modal")).toHaveClass("terminal-modal--floating");
expect(screen.getByTestId("terminal-modal")).not.toHaveClass("terminal-modal--docked"); 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(); expect(screen.getByTestId("floating-window-resize-se")).toBeInTheDocument();
}); });
@@ -1351,6 +1354,8 @@ describe("TerminalModal", () => {
const modal = await screen.findByTestId("terminal-modal"); const modal = await screen.findByTestId("terminal-modal");
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled()); await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
expect(modal).toHaveClass("terminal-modal--tablet", "terminal-modal--floating"); 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 // The 768px CSS fallback is full-screen only for true phones. A known
// tablet must win that cascade with its stored floating geometry. // tablet must win that cascade with its stored floating geometry.
const modalStyle = screen.getByTestId(`floating-window-terminal-${projectId}`).style; 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", () => { it("keeps the floating terminal touch-draggable with theme-controlled shadow", () => {
const floatingWindowCss = readAppFile("components/FloatingWindow.css"); const floatingWindowCss = readAppFile("components/FloatingWindow.css");
expect(floatingWindowCss).toContain("box-shadow: var(--floating-window-shadow, var(--shadow-lg));"); 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(modal).not.toHaveClass("terminal-modal--floating");
expect(screen.queryByTestId("terminal-docked-resize-handle")).toBeNull(); expect(screen.queryByTestId("terminal-docked-resize-handle")).toBeNull();
expect(screen.queryByTestId("terminal-popout-toggle")).toBeNull(); expect(screen.queryByTestId("terminal-popout-toggle")).toBeNull();
expect(screen.queryByTestId("terminal-drag-grip")).toBeNull();
expect(screen.queryByTestId("floating-window-resize-se")).toBeNull(); expect(screen.queryByTestId("floating-window-resize-se")).toBeNull();
} finally { } finally {
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true }); Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });