FN-7282: fix mobile terminal workspace menu

Keep the terminal workspace picker visible and usable on narrow mobile screens.

- Portal the workspace listbox to the viewport and position it from the trigger with visual viewport bounds.
- Constrain the menu width and height with scroll-safe mobile CSS while preserving workspace selection actions.
- Cover mobile rendering, loading/error picker behavior, and document the viewport-safe menu expectation.

Files changed:
 .../fn-7282-mobile-terminal-worktree-menu.md       |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 .../dashboard/app/components/TerminalModal.css     |  21 +++--
 .../dashboard/app/components/TerminalModal.tsx     | 101 ++++++++++++++++++++-
 .../components/__tests__/TerminalModal.test.tsx    |  72 ++++++++++++++-
 5 files changed, 186 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-7282

Fusion-Task-Lineage: ca6f7b2b-2d04-41c3-b2eb-d3195e3a1ff5

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 09:13:48 -07:00
parent ac87b1e8e4
commit b450dd493d
5 changed files with 186 additions and 17 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix the mobile terminal workspace picker so its menu stays visible and reachable.
category: fix
dev: Portals and viewport-constrains the TerminalModal worktree listbox while preserving tab cwd semantics.

View File

@@ -511,7 +511,7 @@ Use the terminal on mobile:
Open a terminal in a specific workspace:
1. Open the terminal and use the workspace picker in the terminal header.
Expected outcome: **Project Root** is always available and opens a new tab in the repository root.
Expected outcome: **Project Root** is always available and opens a new tab in the repository root. On narrow mobile screens, the picker menu remains visible, viewport-safe, and scrollable instead of being clipped by the terminal header.
2. Select a task worktree from the **Task Worktrees** list, then choose **Open terminal in selected workspace**.
Expected outcome: Fusion opens a new terminal tab with the selected task label and starts the shell in that task worktree.
3. If a task is listed without a live worktree, the task remains visible but disabled and marked **No worktree**.

View File

@@ -357,6 +357,9 @@ Terminal worktree selection mirrors the file-browser workspace model while stayi
FNXC:TerminalWorkspaces 2026-06-29-00:00:
Terminal headers must survive many tabs, long task titles, floating narrow widths, and mobile touch controls. Bound tab/picker labels and make menus scroll within the viewport so close/reconnect/keyboard controls and the xterm viewport remain reachable.
FNXC:TerminalWorkspaces 2026-06-30-00:00:
The worktree listbox must escape the mobile terminal header's clipped overflow. It is portaled to the viewport, positioned from the trigger, and constrained with tokenized gutters so narrow phones can reach and scroll every workspace option without hiding the fast + terminal action.
*/
.terminal-workspace-picker {
position: relative;
@@ -427,11 +430,14 @@ Terminal headers must survive many tabs, long task titles, floating narrow width
}
.terminal-workspace-picker-menu {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
width: min(340px, calc(100vw - var(--space-xl)));
max-height: min(360px, calc(100dvh - 120px));
--terminal-workspace-menu-width: calc(var(--space-xl) * 14.167);
--terminal-workspace-menu-min-width: calc(var(--space-xl) * 9.167);
--terminal-workspace-menu-height: calc(var(--space-xl) * 15);
position: fixed;
top: calc(var(--space-xl) * 2);
left: var(--space-md);
width: min(var(--terminal-workspace-menu-width), calc(100vw - (var(--space-md) * 2)));
max-height: min(var(--terminal-workspace-menu-height), calc(100dvh - (var(--space-md) * 2)));
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--space-xs);
@@ -439,6 +445,7 @@ Terminal headers must survive many tabs, long task titles, floating narrow width
border-radius: var(--radius-md);
background: var(--card);
box-shadow: var(--shadow-lg);
z-index: 5000;
}
.terminal-workspace-picker-option {
@@ -1339,8 +1346,8 @@ Footer reads left-to-right: text-size control, then the relocated Clear/Shortcut
}
.terminal-workspace-picker-menu {
right: calc(var(--space-xs) * -1);
max-height: min(300px, calc(100dvh - 96px));
width: min(var(--terminal-workspace-menu-width), calc(100vw - (var(--space-sm) * 2)));
max-height: min(var(--terminal-workspace-menu-height), calc(100dvh - (var(--space-sm) * 2)));
-webkit-overflow-scrolling: touch;
}

View File

@@ -81,6 +81,13 @@ interface TerminalFloatPosition {
y: number;
}
interface TerminalWorkspaceMenuPosition {
top: number;
left: number;
width: number;
maxHeight: number;
}
function readTerminalDisplayMode(projectId?: string): TerminalDisplayMode {
if (typeof window === "undefined") return "docked";
const value = window.localStorage.getItem(`fusion:terminal-display-mode-${projectId ?? "default"}`);
@@ -469,6 +476,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
const terminalRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const terminalWorkspacePickerRef = useRef<HTMLDivElement>(null);
const terminalWorkspaceTriggerRef = useRef<HTMLButtonElement>(null);
const terminalWorkspaceMenuRef = useRef<HTMLDivElement>(null);
const overlayMouseDownRef = useRef(false);
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<ITerminalAddon | null>(null);
@@ -943,6 +952,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
error: terminalWorkspacesError,
} = useWorkspaces(projectId);
const [terminalWorkspaceMenuOpen, setTerminalWorkspaceMenuOpen] = useState(false);
const [terminalWorkspaceMenuPosition, setTerminalWorkspaceMenuPosition] = useState<TerminalWorkspaceMenuPosition | null>(null);
const [selectedTerminalWorkspaceId, setSelectedTerminalWorkspaceId] = useState("project");
const selectedTerminalWorkspace = useMemo(
@@ -974,22 +984,91 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
}
}, [selectedTerminalWorkspaceId, terminalWorkspaces]);
const getEffectiveViewport = useCallback(() => {
const visualViewport = window.visualViewport;
if (visualViewport && visualViewport.width > 0 && visualViewport.height > 0) {
return {
width: visualViewport.width,
height: visualViewport.height,
offsetTop: visualViewport.offsetTop,
offsetLeft: visualViewport.offsetLeft,
};
}
return { width: window.innerWidth, height: window.innerHeight, offsetTop: 0, offsetLeft: 0 };
}, []);
const updateTerminalWorkspaceMenuPosition = useCallback(() => {
const trigger = terminalWorkspaceTriggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const menu = terminalWorkspaceMenuRef.current;
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
const rootStyle = getComputedStyle(document.documentElement);
const horizontalGutter = Number.parseFloat(rootStyle.getPropertyValue("--space-md")) || 16;
const verticalGutter = horizontalGutter;
const gap = Number.parseFloat(rootStyle.getPropertyValue("--space-xs")) || 6;
const minWidth = Number.parseFloat(rootStyle.getPropertyValue("--terminal-workspace-menu-min-width")) || 220;
const preferredWidth = Number.parseFloat(rootStyle.getPropertyValue("--terminal-workspace-menu-width")) || 340;
const preferredHeight = Number.parseFloat(rootStyle.getPropertyValue("--terminal-workspace-menu-height")) || 360;
const measuredWidth = menu?.offsetWidth || Math.max(rect.width, preferredWidth);
const maxWidth = Math.max(viewportWidth - horizontalGutter * 2, minWidth);
const width = Math.min(Math.max(measuredWidth, minWidth), maxWidth);
const measuredHeight = menu?.offsetHeight || preferredHeight;
const maxHeight = Math.max(viewportHeight - verticalGutter * 2, minWidth);
const constrainedHeight = Math.min(measuredHeight, maxHeight);
const triggerTop = rect.top - offsetTop;
const triggerBottom = rect.bottom - offsetTop;
const triggerRight = rect.right - offsetLeft;
const spaceBelow = viewportHeight - triggerBottom;
const spaceAbove = triggerTop;
const openUpward = spaceBelow < constrainedHeight && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(triggerRight - width, horizontalGutter),
viewportWidth - horizontalGutter - width,
) + offsetLeft;
const top = openUpward
? Math.max(verticalGutter + offsetTop, triggerTop - constrainedHeight - gap + offsetTop)
: Math.min(triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalGutter - constrainedHeight);
setTerminalWorkspaceMenuPosition({ top, left, width, maxHeight: constrainedHeight });
}, [getEffectiveViewport]);
useEffect(() => {
if (!terminalWorkspaceMenuOpen) {
setTerminalWorkspaceMenuPosition(null);
return;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && terminalWorkspacePickerRef.current?.contains(target)) {
if (
target instanceof Node &&
(terminalWorkspacePickerRef.current?.contains(target) || terminalWorkspaceMenuRef.current?.contains(target))
) {
return;
}
setTerminalWorkspaceMenuOpen(false);
};
const handleReposition = () => updateTerminalWorkspaceMenuPosition();
const frame = requestAnimationFrame(handleReposition);
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [terminalWorkspaceMenuOpen]);
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
const visualViewport = window.visualViewport;
visualViewport?.addEventListener("resize", handleReposition);
visualViewport?.addEventListener("scroll", handleReposition);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
visualViewport?.removeEventListener("resize", handleReposition);
visualViewport?.removeEventListener("scroll", handleReposition);
};
}, [terminalWorkspaceMenuOpen, terminalWorkspaces.length, updateTerminalWorkspaceMenuPosition]);
const handleOpenSelectedTerminalWorkspace = useCallback(() => {
setTerminalWorkspaceMenuOpen(false);
@@ -2048,6 +2127,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
>
<button
type="button"
ref={terminalWorkspaceTriggerRef}
className="terminal-workspace-picker-trigger"
onClick={() => setTerminalWorkspaceMenuOpen((open) => !open)}
aria-haspopup="listbox"
@@ -2074,12 +2154,22 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
>
<Plus size={14} />
</button>
{terminalWorkspaceMenuOpen && (
{terminalWorkspaceMenuOpen && createPortal(
<div
ref={terminalWorkspaceMenuRef}
id="terminal-workspace-picker-menu"
className="terminal-workspace-picker-menu"
role="listbox"
aria-label={t("terminal.selectWorkspace", "Select terminal workspace")}
style={terminalWorkspaceMenuPosition
? {
top: terminalWorkspaceMenuPosition.top,
left: terminalWorkspaceMenuPosition.left,
width: terminalWorkspaceMenuPosition.width,
maxHeight: terminalWorkspaceMenuPosition.maxHeight,
}
: undefined}
onPointerDown={(event) => event.stopPropagation()}
>
<button
type="button"
@@ -2132,7 +2222,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
</button>
);
})}
</div>
</div>,
document.body,
)}
</div>
)}

View File

@@ -381,21 +381,48 @@ describe("TerminalModal", () => {
unmount();
const previousInnerWidth = window.innerWidth;
const previousInnerHeight = window.innerHeight;
const previousOntouchstart = window.ontouchstart;
Object.defineProperty(window, "innerWidth", { value: 500, configurable: true });
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
Object.defineProperty(window, "innerHeight", { value: 720, configurable: true });
Object.defineProperty(window, "ontouchstart", { value: null, configurable: true });
try {
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="mobile-picker" />);
const mobileModal = await screen.findByTestId("terminal-modal");
expect(mobileModal).not.toHaveClass("terminal-modal--docked");
expect(mobileModal).not.toHaveClass("terminal-modal--floating");
fireEvent.click(screen.getByLabelText("Select terminal workspace: Project Root"));
expect(screen.getByRole("listbox", { name: "Select terminal workspace" })).toBeInTheDocument();
const trigger = screen.getByLabelText("Select terminal workspace: Project Root");
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
x: 220,
y: 18,
top: 18,
left: 220,
right: 352,
bottom: 54,
width: 132,
height: 36,
toJSON: () => ({}),
} as DOMRect);
fireEvent.click(trigger);
const listbox = screen.getByRole("listbox", { name: "Select terminal workspace" });
expect(listbox).toBeInTheDocument();
expect(listbox.parentElement).toBe(document.body);
expect(listbox).toHaveTextContent("Project Root");
expect(listbox).toHaveTextContent("FN-7253");
await waitFor(() => {
expect(listbox).toHaveStyle({ position: "fixed" });
expect(Number.parseFloat(listbox.style.left)).toBeGreaterThanOrEqual(0);
expect(Number.parseFloat(listbox.style.top)).toBeGreaterThanOrEqual(0);
expect(Number.parseFloat(listbox.style.width)).toBeLessThanOrEqual(390);
expect(Number.parseFloat(listbox.style.maxHeight)).toBeLessThanOrEqual(720);
});
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("listbox", { name: "Select terminal workspace" })).toBeNull();
expect(trigger).not.toHaveAttribute("aria-controls");
expect(mockOnClose).not.toHaveBeenCalled();
} finally {
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
Object.defineProperty(window, "innerHeight", { value: previousInnerHeight, configurable: true });
if (previousOntouchstart === undefined) {
delete (window as any).ontouchstart;
} else {
@@ -404,20 +431,57 @@ describe("TerminalModal", () => {
}
});
it("avoids inert workspace picker shells while loading or after workspace fetch errors", async () => {
mockUseWorkspaces.mockReturnValue({
projectName: "kb",
workspaces: [
{ id: "FN-7253", label: "FN-7253", title: "Loading stays usable", worktree: "/repo/.worktrees/fn-7253", kind: "task" },
],
loading: true,
error: null,
});
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="loading-picker" />);
fireEvent.click(await screen.findByLabelText("Select terminal workspace: Project Root"));
expect(screen.getByText("Task worktrees (refreshing…)")).toBeInTheDocument();
expect(screen.getByLabelText("Open terminal in selected workspace")).toBeEnabled();
mockUseWorkspaces.mockReturnValue({
projectName: "kb",
workspaces: [
{ id: "FN-7253", label: "FN-7253", title: "Stale entry hidden on error", worktree: "/repo/.worktrees/fn-7253", kind: "task" },
],
loading: false,
error: "failed",
});
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="loading-picker" />);
expect(screen.queryByTestId("terminal-workspace-picker")).toBeNull();
fireEvent.click(screen.getByLabelText("New terminal"));
expect(defaultSessionState.createTab).toHaveBeenCalledWith();
});
it("bounds terminal worktree picker and tab labels so header actions stay reachable", () => {
const tabRule = terminalModalCss.match(/\.terminal-tab\s*\{([^}]*)\}/)?.[1] ?? "";
const tabLabelRule = terminalModalCss.match(/\.terminal-tab-label\s*\{([^}]*)\}/)?.[1] ?? "";
const triggerRule = terminalModalCss.match(/\.terminal-workspace-picker-trigger\s*\{([^}]*)\}/)?.[1] ?? "";
const menuRule = terminalModalCss.match(/\.terminal-workspace-picker-menu\s*\{([^}]*)\}/)?.[1] ?? "";
const actionsRule = terminalModalCss.match(/\.terminal-actions\s*\{([^}]*)\}/)?.[1] ?? "";
const mobileHeaderRule = terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-header\s*\{([^}]*)\}/)?.[1] ?? "";
const mobileRule = terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-workspace-picker-menu\s*\{([^}]*)\}/)?.[1] ?? "";
expect(tabRule).toContain("max-width: min(260px, 42vw);");
expect(tabLabelRule).toContain("text-overflow: ellipsis;");
expect(triggerRule).toContain("width: clamp(112px, 16vw, 220px);");
expect(menuRule).toContain("max-height: min(360px, calc(100dvh - 120px));");
expect(menuRule).toContain("position: fixed;");
expect(menuRule).toContain("width: min(var(--terminal-workspace-menu-width), calc(100vw - (var(--space-md) * 2)));");
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(mobileHeaderRule).toContain("overflow: hidden;");
expect(mobileRule).not.toContain("right:");
expect(mobileRule).toContain("width: min(var(--terminal-workspace-menu-width), calc(100vw - (var(--space-sm) * 2)));");
expect(mobileRule).toContain("-webkit-overflow-scrolling: touch;");
});