FN-7354: add mobile terminal tab selector

Add a compact mobile terminal tab selector while preserving desktop tab behavior.

- Replace the mobile terminal tab strip with a native dropdown, new-tab action, and current-tab close action.
- Update mobile terminal header styles so tab, workspace, and session controls wrap cleanly on narrow screens.
- Cover mobile selector behavior with dashboard tests and document the mobile terminal tab workflow.
- Add a release changeset for the published Fusion package.

Files changed:
 .changeset/mobile-terminal-tabs-dropdown.md        |   7 ++
 docs/dashboard-guide.md                            |   8 +-
 .../__tests__/terminal-mobile-header-row.test.ts   |  22 ++--
 .../dashboard/app/components/TerminalModal.css     |  69 +++++++++++-
 .../dashboard/app/components/TerminalModal.tsx     | 119 +++++++++++++++------
 .../components/__tests__/TerminalModal.test.tsx    | 107 ++++++++++++++++++
 6 files changed, 282 insertions(+), 50 deletions(-)

Fusion-Task-Id: FN-7354

Fusion-Task-Lineage: 20b5b7d5-5795-4cc6-9975-c363954adb97

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 00:58:55 -07:00
parent c93f2d4da0
commit 03160ebac0
6 changed files with 282 additions and 50 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a mobile terminal tab dropdown for switching and closing terminal sessions.
category: feature
dev: Mobile terminal headers now use a native tab selector while desktop keeps the tab strip.

View File

@@ -511,8 +511,10 @@ Use the terminal on mobile:
1. Open the bottom navigation **More** sheet and select **Terminal**.
Expected outcome: the terminal opens as a full-screen, keyboard-aware modal rather than the desktop/tablet docked or floating surface.
2. Use the mobile terminal controls and close the modal when finished.
Expected outcome: terminal sessions reconnect/recover normally without desktop dock state affecting the mobile layout.
2. Use the **Terminal tab** selector to switch between terminal tabs, or use the adjacent **+** action to open another Project Root terminal.
Expected outcome: every terminal tab appears in the dropdown, switching preserves the active session, and the desktop horizontal tab strip is not shown on mobile.
3. When multiple tabs are open, use **Close current tab** beside the selector, then close the modal when finished.
Expected outcome: mobile can close the active terminal tab without exposing a cramped horizontal tab strip, and terminal sessions reconnect/recover normally without desktop dock state affecting the mobile layout.
Open a terminal in a specific workspace:
@@ -523,7 +525,7 @@ Open a terminal in a specific workspace:
3. If a task is listed without a live worktree, the task remains visible but disabled and marked **No worktree**.
Expected outcome: no empty action button or arbitrary path field is shown; create or restore the task worktree first, then refresh/open the terminal again.
The picker follows the same workspace metadata as the Files modal. The server accepts terminal working directories only for the project root or registered project worktrees; rejected, missing, or unsafe explicit worktree paths fail the new-tab request rather than opening a mislabeled Project Root shell or an arbitrary location. The existing **+** new-tab action remains a fast Project Root terminal, and reconnect, restart, resize, scrollback, initial-command, and tab-persistence flows continue to use server-confirmed session metadata.
The picker follows the same workspace metadata as the Files modal. The server accepts terminal working directories only for the project root or registered project worktrees; rejected, missing, or unsafe explicit worktree paths fail the new-tab request rather than opening a mislabeled Project Root shell or an arbitrary location. The existing **+** new-tab action remains a fast Project Root terminal on both desktop and mobile, and reconnect, restart, resize, scrollback, initial-command, and tab-persistence flows continue to use server-confirmed session metadata.
Features:

View File

@@ -14,25 +14,29 @@ function findRuleBody(selector: RegExp): string {
}
describe("terminal mobile header row CSS contract", () => {
it("keeps the mobile terminal header on one row", () => {
it("lets the mobile terminal header wrap intentionally without clipping actions", () => {
const ruleBody = findRuleBody(/\.terminal-header/);
expect(ruleBody).toContain("flex-wrap: nowrap");
expect(ruleBody).toContain("flex-wrap: wrap");
expect(ruleBody).toContain("row-gap: var(--space-xs)");
expect(ruleBody).toContain("overflow: hidden");
});
it("keeps tabs flexible instead of forcing them onto a full-width row", () => {
const ruleBody = findRuleBody(/\.terminal-tabs/);
it("hides the desktop tab strip and exposes the mobile selector surface", () => {
const desktopTabsRule = findRuleBody(/\.terminal-tabs/);
const mobileSelectorRule = findRuleBody(/\.terminal-mobile-tabs/);
expect(ruleBody).toContain("flex: 1 1 auto");
expect(ruleBody).toContain("min-width: 0");
expect(ruleBody).not.toContain("flex: 1 1 100%");
expect(ruleBody).not.toContain("min-width: 100%");
expect(desktopTabsRule).toContain("display: none");
expect(mobileSelectorRule).toContain("display: flex");
expect(mobileSelectorRule).toContain("min-width: 0");
expect(mobileSelectorRule).not.toContain("flex: 1 1 100%");
expect(mobileSelectorRule).not.toContain("min-width: 100%");
});
it("keeps the action cluster on the same row without a second-row divider", () => {
it("keeps the action cluster reachable without a second-row divider", () => {
const ruleBody = findRuleBody(/\.terminal-actions/);
expect(ruleBody).toContain("order: 3");
expect(ruleBody).toContain("flex: 0 0 auto");
expect(ruleBody).toContain("border-top: none");
expect(ruleBody).not.toContain("flex: 1 1 100%");

View File

@@ -351,6 +351,57 @@ The floating-mode header is the move grip. `touch-action: none` is required so a
color: var(--text-muted);
}
.terminal-mobile-tabs {
display: none;
align-items: center;
flex: 1 1 auto;
min-width: 0;
gap: var(--space-xs);
padding: 0 var(--space-xs);
}
.terminal-mobile-tabs-label {
display: none;
}
.terminal-mobile-tab-select {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.terminal-mobile-tab-action {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: calc(var(--space-xl) + var(--space-lg));
min-width: calc(var(--space-xl) + var(--space-lg));
min-height: calc(var(--space-xl) + var(--space-lg));
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--card);
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.terminal-mobile-tab-action:hover:not(:disabled) {
background: var(--card-hover);
color: var(--text);
border-color: var(--text-muted);
}
.terminal-mobile-tab-action:disabled {
cursor: not-allowed;
opacity: var(--opacity-disabled, 0.5);
}
/*
FNXC:TerminalWorkspaces 2026-06-29-00:00:
Terminal worktree selection mirrors the file-browser workspace model while staying header-sized for docked, floating, and mobile terminals. Render the picker only when task workspace entries exist so failures or empty worktree lists never leave an inert button shell next to the always-fast + terminal affordance.
@@ -1314,20 +1365,27 @@ Footer reads left-to-right: text-size control, then the relocated Clear/Shortcut
resize: none;
}
/* Keep tabs and header actions on a single row */
/* Mobile swaps the desktop tab strip for a selector; wrapping is intentional on very narrow phones so worktree and session actions remain reachable. */
.terminal-header {
flex-wrap: nowrap;
flex-wrap: wrap;
row-gap: var(--space-xs);
padding-top: env(safe-area-inset-top, 0);
overflow: hidden;
}
/* Tabs stay scrollable and yield space to the fixed action cluster */
.terminal-tabs {
flex: 1 1 auto;
display: none;
}
.terminal-mobile-tabs {
display: flex;
order: 1;
flex: 1 1 40%;
min-width: 0;
}
.terminal-workspace-picker {
order: 2;
margin-left: 0;
padding-left: 0;
border-left: none;
@@ -1360,8 +1418,9 @@ Footer reads left-to-right: text-size control, then the relocated Clear/Shortcut
display: none;
}
/* Actions stay pinned to the right edge of the header row */
/* Actions stay reachable at the end of the header row and wrap as a unit after the selector/picker when space is exhausted. */
.terminal-actions {
order: 3;
flex: 0 0 auto;
justify-content: flex-end;
border-top: none;

View File

@@ -2119,44 +2119,97 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
onPointerDown={(event) => handleFloatingResizePointerDown(event, direction)}
/>
))}
{/* Header — on mobile (≤768px) keep tabs and actions on one row;
{/* Header — on mobile (≤768px) use compact selector/actions;
.terminal-title is hidden; action button labels are hidden (icons only) */}
<div className={`terminal-header${isFloatingMode ? " terminal-header--draggable" : ""}`} onPointerDown={handleFloatingDragPointerDown}>
{/* Tab Bar */}
<div className="terminal-tabs" data-testid="terminal-tabs">
{tabs.map((tab) => (
<div
key={tab.id}
className={`terminal-tab ${tab.isActive ? "terminal-tab--active" : ""}`}
onClick={() => setActiveTab(tab.id)}
title={tab.title}
role="tab"
aria-selected={tab.isActive}
{!isMobileTerminal && (
<div className="terminal-tabs" data-testid="terminal-tabs">
{tabs.map((tab) => (
<div
key={tab.id}
className={`terminal-tab ${tab.isActive ? "terminal-tab--active" : ""}`}
onClick={() => setActiveTab(tab.id)}
title={tab.title}
role="tab"
aria-selected={tab.isActive}
>
<span className="terminal-tab-label">{tab.title}</span>
{tabs.length > 1 && (
<button
className="terminal-tab-close"
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
title={t("terminal.closeTab", "Close tab")}
>
×
</button>
)}
</div>
))}
<button
className="terminal-tab terminal-tab--new"
onClick={() => void createTab()}
title={t("terminal.newTerminal", "New terminal")}
aria-label={t("terminal.newTerminal", "New terminal")}
>
<span className="terminal-tab-label">{tab.title}</span>
{tabs.length > 1 && (
<button
className="terminal-tab-close"
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
title={t("terminal.closeTab", "Close tab")}
>
×
</button>
+
</button>
</div>
)}
{/*
FNXC:TerminalTabs 2026-07-01-00:00:
Mobile terminal headers use a native tab dropdown because horizontal tab strips crowd worktree, session, and close controls on narrow screens. Desktop and floating layouts keep the existing tab buttons so fast tab switching, per-tab close, and the + action stay unchanged.
*/}
{isMobileTerminal && (
<div className="terminal-mobile-tabs" data-testid="terminal-mobile-tabs">
<label className="terminal-mobile-tabs-label" htmlFor="terminal-mobile-tab-select">
{t("terminal.selectTab", "Terminal tab")}
</label>
<select
id="terminal-mobile-tab-select"
className="input terminal-mobile-tab-select"
data-testid="terminal-mobile-tab-select"
value={activeTab?.id ?? ""}
onChange={(event) => {
if (event.currentTarget.value) setActiveTab(event.currentTarget.value);
}}
disabled={tabs.length === 0}
aria-label={t("terminal.selectTab", "Terminal tab")}
>
{tabs.length === 0 && (
<option value="">{t("terminal.noTabs", "No terminal tabs")}</option>
)}
</div>
))}
<button
className="terminal-tab terminal-tab--new"
onClick={() => void createTab()}
title={t("terminal.newTerminal", "New terminal")}
aria-label={t("terminal.newTerminal", "New terminal")}
>
+
</button>
</div>
{tabs.map((tab) => (
<option key={tab.id} value={tab.id}>{tab.title}</option>
))}
</select>
<button
type="button"
className="terminal-mobile-tab-action terminal-mobile-tab-action--new"
onClick={() => void createTab()}
aria-label={t("terminal.newTerminal", "New terminal")}
data-testid="terminal-mobile-new-tab"
>
<Plus size={14} />
</button>
{tabs.length > 1 && activeTab && (
<button
type="button"
className="terminal-mobile-tab-action terminal-mobile-tab-action--close"
onClick={() => closeTab(activeTab.id)}
title={t("terminal.closeCurrentTab", "Close current tab")}
aria-label={t("terminal.closeCurrentTab", "Close current tab")}
data-testid="terminal-mobile-close-tab"
>
<Trash2 size={14} />
</button>
)}
</div>
)}
{shouldShowTerminalWorkspacePicker && (
<div

View File

@@ -467,11 +467,17 @@ describe("TerminalModal", () => {
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 mobileTabsRule = terminalModalCss.match(/\.terminal-mobile-tabs\s*\{([^}]*)\}/)?.[1] ?? "";
const mobileSelectRule = terminalModalCss.match(/\.terminal-mobile-tab-select\s*\{([^}]*)\}/)?.[1] ?? "";
const mobileHeaderRule = terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-header\s*\{([^}]*)\}/)?.[1] ?? "";
const mobileTerminalTabsRule = terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-tabs\s*\{([^}]*)\}/)?.[1] ?? "";
const mobileSelectorRule = terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-mobile-tabs\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(mobileTabsRule).toContain("display: none;");
expect(mobileSelectRule).toContain("text-overflow: ellipsis;");
expect(triggerRule).toContain("width: clamp(112px, 16vw, 220px);");
expect(menuRule).toContain("position: fixed;");
expect(menuRule).toContain("width: min(var(--terminal-workspace-menu-width), calc(100vw - (var(--space-md) * 2)));");
@@ -479,7 +485,11 @@ describe("TerminalModal", () => {
expect(menuRule).toContain("overflow-y: auto;");
expect(menuRule).toContain("overscroll-behavior: contain;");
expect(actionsRule).toContain("flex: 0 0 auto;");
expect(mobileHeaderRule).toContain("flex-wrap: wrap;");
expect(mobileHeaderRule).toContain("overflow: hidden;");
expect(mobileTerminalTabsRule).toContain("display: none;");
expect(mobileSelectorRule).toContain("display: flex;");
expect(mobileSelectorRule).toContain("min-width: 0;");
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;");
@@ -894,6 +904,103 @@ describe("TerminalModal", () => {
expect(mockCreateTab).toHaveBeenCalled();
});
it("keeps desktop tab buttons and close buttons as the accessible tab surface", async () => {
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [
{ ...defaultTab, isActive: true },
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
],
});
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(await screen.findByTitle("bash")).toHaveAttribute("role", "tab");
expect(screen.getByTitle("zsh")).toHaveAttribute("role", "tab");
expect(screen.getAllByTitle("Close tab")).toHaveLength(2);
expect(screen.queryByTestId("terminal-mobile-tabs")).toBeNull();
});
it("renders a mobile tab selector with every tab and switches by tab id", async () => {
const previousInnerWidth = window.innerWidth;
const previousInnerHeight = window.innerHeight;
const mockSetActiveTab = vi.fn();
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [
{ ...defaultTab, title: "duplicate", isActive: true },
{ id: "tab-2", sessionId: "test-session-456", title: "duplicate", isActive: false, createdAt: Date.now() },
{ id: "tab-3", sessionId: "test-session-789", title: "very-long-active-terminal-tab-title-that-should-not-push-actions", isActive: false, createdAt: Date.now() },
],
setActiveTab: mockSetActiveTab,
});
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
Object.defineProperty(window, "innerHeight", { value: 720, configurable: true });
try {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const select = await screen.findByLabelText("Terminal tab") as HTMLSelectElement;
const options = Array.from(select.options);
expect(options.map((option) => option.value)).toEqual(["tab-1", "tab-2", "tab-3"]);
expect(options.map((option) => option.textContent)).toEqual([
"duplicate",
"duplicate",
"very-long-active-terminal-tab-title-that-should-not-push-actions",
]);
expect(screen.queryByRole("tab", { name: "duplicate" })).toBeNull();
expect(screen.queryByTestId("terminal-tabs")).toBeNull();
fireEvent.change(select, { target: { value: "tab-2" } });
expect(mockSetActiveTab).toHaveBeenCalledWith("tab-2");
} finally {
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
Object.defineProperty(window, "innerHeight", { value: previousInnerHeight, configurable: true });
}
});
it("keeps mobile new-terminal and close-current-tab controls reachable", async () => {
const previousInnerWidth = window.innerWidth;
const mockCreateTab = vi.fn().mockResolvedValue(defaultTab);
const mockCloseTab = vi.fn();
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [
{ ...defaultTab, isActive: true },
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
],
createTab: mockCreateTab,
closeTab: mockCloseTab,
});
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
try {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.click(await screen.findByTestId("terminal-mobile-new-tab"));
expect(mockCreateTab).toHaveBeenCalledWith();
fireEvent.click(screen.getByLabelText("Close current tab"));
expect(mockCloseTab).toHaveBeenCalledWith("tab-1");
} finally {
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
}
});
it("omits the mobile close-current-tab control when only one tab exists", async () => {
const previousInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { value: 360, configurable: true });
try {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(await screen.findByLabelText("Terminal tab")).toBeInTheDocument();
expect(screen.queryByLabelText("Close current tab")).toBeNull();
} finally {
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
}
});
it("sessions are NOT killed when modal closes (session persistence)", async () => {
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);