diff --git a/.changeset/mobile-terminal-tabs-dropdown.md b/.changeset/mobile-terminal-tabs-dropdown.md new file mode 100644 index 0000000000..4290d2f939 --- /dev/null +++ b/.changeset/mobile-terminal-tabs-dropdown.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 673d23277c..b9ced851c9 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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: diff --git a/packages/dashboard/app/__tests__/terminal-mobile-header-row.test.ts b/packages/dashboard/app/__tests__/terminal-mobile-header-row.test.ts index 77c048d550..279bf72bed 100644 --- a/packages/dashboard/app/__tests__/terminal-mobile-header-row.test.ts +++ b/packages/dashboard/app/__tests__/terminal-mobile-header-row.test.ts @@ -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%"); diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index 8b5674bf3f..82ce19424c 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -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; diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 5b31b5907f..730e8e6f3d 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -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) */}
{/* Tab Bar */} -
- {tabs.map((tab) => ( -
setActiveTab(tab.id)} - title={tab.title} - role="tab" - aria-selected={tab.isActive} + {!isMobileTerminal && ( +
+ {tabs.map((tab) => ( +
setActiveTab(tab.id)} + title={tab.title} + role="tab" + aria-selected={tab.isActive} + > + {tab.title} + {tabs.length > 1 && ( + + )} +
+ ))} + + + + +
+ )} + + {/* + 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 && ( +
+ + + + {tabs.length > 1 && activeTab && ( + + )} +
+ )} {shouldShowTerminalWorkspacePicker && (
{ 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(); + + 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(); + + 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(); + + 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(); + + 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();