diff --git a/.changeset/fn-7253-terminal-worktree-picker.md b/.changeset/fn-7253-terminal-worktree-picker.md new file mode 100644 index 0000000000..b410b5c068 --- /dev/null +++ b/.changeset/fn-7253-terminal-worktree-picker.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a terminal worktree picker for opening shells in task worktrees. +category: feature +dev: Dashboard terminal sessions now pass an authorized cwd for selected project worktrees. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index f31257a781..a46fca49fd 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -500,9 +500,20 @@ Use the terminal on mobile: 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. +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. +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**. + 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. + Features: -- Multiple terminal tabs +- Multiple terminal tabs, including Project Root tabs and task-worktree tabs - PTY-backed shell sessions - Ctrl/Cmd+C copies the current terminal selection, while plain Ctrl+C with no selection still sends SIGINT - Ctrl/Cmd+V pastes clipboard text into the active terminal session diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index c529b684b3..bc3b862462 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -258,6 +258,9 @@ The floating-mode header is the move grip. `touch-action: none` is required so a transition: color var(--transition-fast), border-color var(--transition-fast); position: relative; min-height: 44px; + max-width: min(260px, 42vw); + min-width: 0; + flex: 0 1 auto; } .terminal-tab:hover { @@ -285,6 +288,10 @@ The floating-mode header is the move grip. `touch-action: none` is required so a } .terminal-tab-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-family: var(--font-mono); font-size: 12px; } @@ -344,6 +351,156 @@ The floating-mode header is the move grip. `touch-action: none` is required so a color: var(--text-muted); } +/* +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. + +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. +*/ +.terminal-workspace-picker { + position: relative; + display: flex; + align-items: center; + flex: 0 0 auto; + margin-left: var(--space-xs); + border-left: 1px solid var(--border); + padding-left: var(--space-xs); + z-index: 3; +} + +.terminal-workspace-picker-trigger, +.terminal-workspace-picker-open { + display: flex; + align-items: center; + justify-content: center; + min-height: 34px; + border: 1px solid var(--border); + background: var(--card); + color: var(--text-muted); + cursor: pointer; + transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast); +} + +.terminal-workspace-picker-trigger:hover, +.terminal-workspace-picker-open:hover:not(:disabled) { + color: var(--text); + background: var(--card-hover); + border-color: var(--text-muted); +} + +.terminal-workspace-picker-trigger { + gap: var(--space-xs); + width: clamp(112px, 16vw, 220px); + max-width: 220px; + min-width: 0; + padding: 0 var(--space-sm); + border-radius: var(--radius-sm) 0 0 var(--radius-sm); +} + +.terminal-workspace-picker-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 500; +} + +.terminal-workspace-picker-chevron { + transition: transform var(--transition-fast); +} + +.terminal-workspace-picker-chevron.open { + transform: rotate(180deg); +} + +.terminal-workspace-picker-open { + width: 34px; + margin-left: -1px; + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} + +.terminal-workspace-picker-open:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.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)); + overflow-y: auto; + overscroll-behavior: contain; + padding: var(--space-xs); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--card); + box-shadow: var(--shadow-lg); +} + +.terminal-workspace-picker-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + width: 100%; + padding: var(--space-sm); + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text); + text-align: left; + cursor: pointer; +} + +.terminal-workspace-picker-option:hover:not(:disabled), +.terminal-workspace-picker-option.active { + background: var(--card-hover); +} + +.terminal-workspace-picker-option:disabled, +.terminal-workspace-picker-option.disabled { + cursor: not-allowed; + color: var(--text-muted); + opacity: 0.7; +} + +.terminal-workspace-picker-option-main { + display: flex; + align-items: center; + gap: var(--space-xs); + min-width: 0; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; +} + +.terminal-workspace-picker-option-main span, +.terminal-workspace-picker-option-meta { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.terminal-workspace-picker-option-meta { + color: var(--text-muted); + font-size: 11px; + max-width: 180px; +} + +.terminal-workspace-picker-group-label { + padding: var(--space-sm) var(--space-sm) var(--space-xs); + color: var(--text-muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + .terminal-close { display: flex; align-items: center; @@ -501,13 +658,15 @@ The terminal header pop-out/dock affordance is an icon-only utility control. It font-size: 14px; font-weight: 500; color: var(--text); - flex: 1; + flex: 0 1 auto; + min-width: 0; } .terminal-actions { display: flex; align-items: center; gap: 0; + flex: 0 0 auto; } .terminal-output { @@ -1161,6 +1320,34 @@ Footer reads left-to-right: text-size control, then the relocated Clear/Shortcut min-width: 0; } + .terminal-workspace-picker { + margin-left: 0; + padding-left: 0; + border-left: none; + } + + .terminal-workspace-picker-trigger { + width: clamp(92px, 28vw, 132px); + max-width: 132px; + min-height: 36px; + padding: 0 var(--space-xs); + } + + .terminal-workspace-picker-open { + width: 36px; + min-height: 36px; + } + + .terminal-workspace-picker-menu { + right: calc(var(--space-xs) * -1); + max-height: min(300px, calc(100dvh - 96px)); + -webkit-overflow-scrolling: touch; + } + + .terminal-workspace-picker-option-meta { + max-width: 150px; + } + /* Hide the redundant title/status indicator on mobile — .terminal-status-bar shows connection state */ .terminal-title { display: none; diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 59a1807898..40abf261df 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useCallback, + useMemo, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, @@ -23,9 +24,13 @@ import { Settings, Maximize2, Minimize2, + ChevronDown, + FolderGit2, + FolderRoot, } from "lucide-react"; import { useTerminal } from "../hooks/useTerminal"; import { useTerminalSessions } from "../hooks/useTerminalSessions"; +import { useWorkspaces } from "../hooks/useWorkspaces"; import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack"; import { getPathBasename } from "../utils/pathDisplay"; import { @@ -416,6 +421,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const terminalRef = useRef(null); const modalRef = useRef(null); + const terminalWorkspacePickerRef = useRef(null); const overlayMouseDownRef = useRef(false); const xtermRef = useRef(null); const fitAddonRef = useRef(null); @@ -883,6 +889,78 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG replaceActiveTabSession, } = useTerminalSessions(projectId); + const { + projectName: terminalWorkspaceProjectName, + workspaces: terminalWorkspaces, + loading: terminalWorkspacesLoading, + error: terminalWorkspacesError, + } = useWorkspaces(projectId); + const [terminalWorkspaceMenuOpen, setTerminalWorkspaceMenuOpen] = useState(false); + const [selectedTerminalWorkspaceId, setSelectedTerminalWorkspaceId] = useState("project"); + + const selectedTerminalWorkspace = useMemo( + () => terminalWorkspaces.find((workspace) => workspace.id === selectedTerminalWorkspaceId) ?? null, + [selectedTerminalWorkspaceId, terminalWorkspaces], + ); + const shouldShowTerminalWorkspacePicker = terminalWorkspaces.length > 0 && !terminalWorkspacesError; + const selectedTerminalWorkspaceCanOpen = selectedTerminalWorkspaceId === "project" || Boolean(selectedTerminalWorkspace?.worktree); + const selectedTerminalWorkspaceLabel = + selectedTerminalWorkspaceId === "project" + ? t("terminal.projectRoot", "Project Root") + : (selectedTerminalWorkspace?.label ?? selectedTerminalWorkspaceId); + + /* + FNXC:TerminalWorkspaces 2026-06-29-00:00: + Terminal worktree selection follows the file-browser workspace model: Project Root opens the default terminal cwd, and task entries use only registered WorkspaceInfo.worktree paths. Keep this header affordance compact and available in docked, floating, and mobile terminal modes without replacing the fast + new-terminal path. + + FNXC:TerminalWorkspaces 2026-06-29-00:00: + The picker is a header menu, not terminal input: Escape and outside clicks close the listbox first so users do not accidentally close the whole terminal while navigating worktrees with keyboard or touch. + */ + useEffect(() => { + if (selectedTerminalWorkspaceId === "project") { + return; + } + const stillAvailable = terminalWorkspaces.some((workspace) => workspace.id === selectedTerminalWorkspaceId); + if (!stillAvailable) { + setSelectedTerminalWorkspaceId("project"); + setTerminalWorkspaceMenuOpen(false); + } + }, [selectedTerminalWorkspaceId, terminalWorkspaces]); + + useEffect(() => { + if (!terminalWorkspaceMenuOpen) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (target instanceof Node && terminalWorkspacePickerRef.current?.contains(target)) { + return; + } + setTerminalWorkspaceMenuOpen(false); + }; + + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); + }, [terminalWorkspaceMenuOpen]); + + const handleOpenSelectedTerminalWorkspace = useCallback(() => { + setTerminalWorkspaceMenuOpen(false); + if (selectedTerminalWorkspaceId === "project") { + void createTab(); + return; + } + + if (!selectedTerminalWorkspace?.worktree) { + return; + } + + void createTab({ + cwd: selectedTerminalWorkspace.worktree, + title: selectedTerminalWorkspace.label, + }); + }, [createTab, selectedTerminalWorkspace, selectedTerminalWorkspaceId]); + // Get the WebSocket connection for the active session const { connectionStatus, sendInput, resize, onData, onConnect, onExit, onScrollback, reconnect, onSessionInvalid } = useTerminal(activeTab?.sessionId ?? null, projectId); @@ -1493,18 +1571,24 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG return () => window.removeEventListener("keydown", handleKeyDown); }, [isOpen, setFontSize]); - // Handle escape key to close + // Handle escape key to close the open worktree menu before closing the terminal. useEffect(() => { if (!isOpen) return; const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") { + if (terminalWorkspaceMenuOpen) { + e.preventDefault(); + e.stopPropagation(); + setTerminalWorkspaceMenuOpen(false); + return; + } onClose(); } }; document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); - }, [isOpen, onClose]); + }, [isOpen, onClose, terminalWorkspaceMenuOpen]); // Focus terminal when connected useEffect(() => { @@ -1899,12 +1983,112 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG ))} + + {shouldShowTerminalWorkspacePicker && ( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + + + {terminalWorkspaceMenuOpen && ( +
+ +
+ {terminalWorkspacesLoading + ? t("terminal.loadingWorkspaces", "Task worktrees (refreshing…)") + : t("terminal.taskWorktrees", "Task Worktrees")} +
+ {terminalWorkspaces.map((workspace) => { + const disabled = !workspace.worktree; + return ( + + ); + })} +
+ )} +
+ )} {/* Status indicator */}
diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index fb3a2dde7c..a71826aa92 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -16,6 +16,7 @@ import { } from "../../utils/terminalPreferences"; import * as useTerminalModule from "../../hooks/useTerminal"; import * as useTerminalSessionsModule from "../../hooks/useTerminalSessions"; +import * as useWorkspacesModule from "../../hooks/useWorkspaces"; import * as apiModule from "../../api"; const terminalModalCss = readFileSync("app/components/TerminalModal.css", "utf8"); @@ -42,6 +43,10 @@ vi.mock("../../hooks/useTerminalSessions", () => ({ useTerminalSessions: vi.fn(), })); +vi.mock("../../hooks/useWorkspaces", () => ({ + useWorkspaces: vi.fn(), +})); + vi.mock("../../api", () => ({ createTerminalSession: vi.fn(), killPtyTerminalSession: vi.fn(), @@ -109,6 +114,7 @@ vi.mock("@xterm/xterm/css/xterm.css", () => ({})); const mockUseTerminal = vi.mocked(useTerminalModule.useTerminal); const mockUseTerminalSessions = vi.mocked(useTerminalSessionsModule.useTerminalSessions); +const mockUseWorkspaces = vi.mocked(useWorkspacesModule.useWorkspaces); const mockCreateTerminalSession = vi.mocked(apiModule.createTerminalSession); const mockKillPtyTerminalSession = vi.mocked(apiModule.killPtyTerminalSession); const TERMINAL_FONT_SIZE_KEY = LEGACY_TERMINAL_FONT_SIZE_KEY; @@ -232,6 +238,12 @@ describe("TerminalModal", () => { mockKillPtyTerminalSession.mockResolvedValue({ killed: true }); mockUseTerminal.mockReturnValue(createMockTerminalState()); mockUseTerminalSessions.mockReturnValue(defaultSessionState); + mockUseWorkspaces.mockReturnValue({ + projectName: "kb", + workspaces: [], + loading: false, + error: null, + }); }); afterEach(() => { @@ -252,6 +264,163 @@ describe("TerminalModal", () => { expect(container.firstChild).toBeNull(); }); + it("keeps the fast new-terminal button and hides the workspace picker when no task worktrees exist", async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId("terminal-modal")).toBeTruthy(); + }); + + expect(screen.queryByTestId("terminal-workspace-picker")).toBeNull(); + fireEvent.click(screen.getByLabelText("New terminal")); + expect(defaultSessionState.createTab).toHaveBeenCalledWith(); + }); + + it("opens a new terminal in the selected task worktree", async () => { + const createTab = vi.fn().mockResolvedValue(defaultTab); + mockUseTerminalSessions.mockReturnValue({ + ...defaultSessionState, + createTab, + }); + mockUseWorkspaces.mockReturnValue({ + projectName: "kb", + workspaces: [ + { id: "FN-7253", label: "FN-7253", title: "Add worktree picker", worktree: "/repo/.worktrees/fn-7253", kind: "task" }, + { id: "FN-0000", label: "FN-0000", title: "Missing worktree", kind: "task" }, + ], + loading: false, + error: null, + }); + + render(); + + fireEvent.click(screen.getByTitle("Select terminal workspace")); + expect(screen.getByText("No worktree").closest("button")).toBeDisabled(); + fireEvent.click(screen.getByText("FN-7253")); + fireEvent.click(screen.getByLabelText("Open terminal in selected workspace")); + + expect(createTab).toHaveBeenCalledWith({ + cwd: "/repo/.worktrees/fn-7253", + title: "FN-7253", + }); + }); + + it("keeps the docked worktree picker accessible with duplicate, missing, and long workspace data", async () => { + const createTab = vi.fn().mockResolvedValue(defaultTab); + const longTitle = "FN-9999 — Implement a very long terminal worktree picker title that must truncate before actions"; + mockUseTerminalSessions.mockReturnValue({ + ...defaultSessionState, + tabs: [ + { ...defaultTab, title: "very-long-active-terminal-tab-title-that-should-not-push-actions" }, + { ...defaultTab, id: "tab-2", title: "another-long-tab", isActive: false }, + { ...defaultTab, id: "tab-3", title: "third-long-tab", isActive: false }, + ], + createTab, + }); + mockUseWorkspaces.mockReturnValue({ + projectName: "kb", + workspaces: [ + { id: "FN-9999", label: "FN-9999", title: longTitle, worktree: "/repo/.worktrees/duplicate", kind: "task" }, + { id: "FN-9998", label: "FN-9998", title: "Duplicate path", worktree: "/repo/.worktrees/duplicate", kind: "task" }, + { id: "FN-0000", label: "FN-0000", title: "Missing worktree", kind: "task" }, + { id: "FN-0001", label: "FN-0001", title: "Undefined worktree", worktree: undefined, kind: "task" }, + ], + loading: false, + error: null, + }); + + render(); + + const modal = await screen.findByTestId("terminal-modal"); + expect(modal).toHaveClass("terminal-modal--docked"); + const trigger = screen.getByLabelText("Select terminal workspace: Project Root"); + expect(trigger).toHaveAttribute("aria-haspopup", "listbox"); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(screen.getByLabelText("Open terminal in selected workspace")).toBeEnabled(); + + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + expect(trigger).toHaveAttribute("aria-controls", "terminal-workspace-picker-menu"); + expect(screen.getByRole("listbox", { name: "Select terminal workspace" })).toBeInTheDocument(); + expect(screen.getByText("FN-9999")).toBeInTheDocument(); + expect(screen.getByText("FN-9998")).toBeInTheDocument(); + expect(screen.getAllByText("No worktree")).toHaveLength(2); + for (const missingOption of screen.getAllByText("No worktree")) { + const option = missingOption.closest("button"); + expect(option).toBeDisabled(); + expect(option).toHaveAttribute("aria-disabled", "true"); + } + + fireEvent.click(screen.getByText("FN-9998")); + fireEvent.click(screen.getByLabelText("Open terminal in selected workspace")); + expect(createTab).toHaveBeenCalledWith({ cwd: "/repo/.worktrees/duplicate", title: "FN-9998" }); + }); + + it("keeps floating and mobile worktree menus reachable and dismissible without orphaned controls", async () => { + const createTab = vi.fn().mockResolvedValue(defaultTab); + mockUseTerminalSessions.mockReturnValue({ + ...defaultSessionState, + createTab, + }); + mockUseWorkspaces.mockReturnValue({ + projectName: "kb", + workspaces: [ + { id: "FN-7253", label: "FN-7253", title: "Add worktree picker", worktree: "/repo/.worktrees/fn-7253", kind: "task" }, + ], + loading: false, + error: null, + }); + window.localStorage.setItem("fusion:terminal-display-mode-floating-picker", "floating"); + + const { unmount } = render(); + expect(await screen.findByTestId("terminal-modal")).toHaveClass("terminal-modal--floating"); + fireEvent.click(screen.getByLabelText("Select terminal workspace: Project Root")); + expect(screen.getByRole("listbox", { name: "Select terminal workspace" })).toBeInTheDocument(); + fireEvent.pointerDown(document.body); + expect(screen.queryByRole("listbox", { name: "Select terminal workspace" })).toBeNull(); + unmount(); + + const previousInnerWidth = window.innerWidth; + const previousOntouchstart = window.ontouchstart; + Object.defineProperty(window, "innerWidth", { value: 500, configurable: true }); + Object.defineProperty(window, "ontouchstart", { value: null, configurable: true }); + try { + render(); + 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(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("listbox", { name: "Select terminal workspace" })).toBeNull(); + expect(mockOnClose).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true }); + if (previousOntouchstart === undefined) { + delete (window as any).ontouchstart; + } else { + Object.defineProperty(window, "ontouchstart", { value: previousOntouchstart, configurable: true }); + } + } + }); + + 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 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("overscroll-behavior: contain;"); + expect(actionsRule).toContain("flex: 0 0 auto;"); + expect(mobileRule).toContain("-webkit-overflow-scrolling: touch;"); + }); + it("renders desktop terminal as a docked bottom panel and refits after top-handle resize", async () => { const projectId = "docked-resize-test"; window.localStorage.removeItem(`fusion:terminal-docked-height-${projectId}`); diff --git a/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts b/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts index 9b8a1b904d..fafcedefcd 100644 --- a/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts @@ -66,7 +66,7 @@ describe("useTerminalSessions", () => { expect(result.current.activeTab).not.toBeNull(); }); - expect(mockCreateTerminalSession).toHaveBeenCalled(); + expect(mockCreateTerminalSession).toHaveBeenCalledWith(undefined, undefined, undefined, TEST_PROJECT_ID); }); it("restores tabs from localStorage on mount", async () => { @@ -136,6 +136,52 @@ describe("useTerminalSessions", () => { expect(result.current.activeTab?.id).toBe("tab-2"); }); + it("filters stale sessions while preserving mixed project-root and worktree tabs", async () => { + const storedTabs = [ + { + id: "tab-root", + sessionId: "session-root", + title: "Terminal 1", + isActive: true, + createdAt: Date.now(), + }, + { + id: "tab-worktree", + sessionId: "session-worktree", + title: "FN-7253", + cwd: "/project/.worktrees/FN-7253", + isActive: false, + createdAt: Date.now(), + }, + { + id: "tab-stale-worktree", + sessionId: "session-stale-worktree", + title: "FN-0000", + cwd: "/project/.worktrees/FN-0000", + isActive: false, + createdAt: Date.now(), + }, + ]; + localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs)); + + mockListTerminalSessions.mockResolvedValue([ + { id: "session-root", shell: "/bin/bash", cwd: "/project", createdAt: "2026-01-01T00:00:00.000Z" }, + { id: "session-worktree", shell: "/bin/bash", cwd: "/project/.worktrees/FN-7253", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + + const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID)); + + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + expect(result.current.tabs.map((tab) => tab.id)).toEqual(["tab-root", "tab-worktree"]); + expect(result.current.tabs[0].cwd).toBeUndefined(); + expect(result.current.tabs[1].cwd).toBe("/project/.worktrees/FN-7253"); + expect(result.current.activeTab?.id).toBe("tab-root"); + expect(mockCreateTerminalSession).not.toHaveBeenCalled(); + }); + it("creates new tab if all stored sessions are stale", async () => { const storedTabs = [ { @@ -203,6 +249,127 @@ describe("useTerminalSessions", () => { // First tab should be deactivated expect(result.current.tabs[0].isActive).toBe(false); expect(result.current.tabs[1].isActive).toBe(true); + expect(mockCreateTerminalSession).toHaveBeenLastCalledWith(undefined, undefined, undefined, TEST_PROJECT_ID); + }); + + it("passes an explicit cwd when creating a worktree tab", async () => { + localStorageMock.getItem.mockReturnValue(null); + mockListTerminalSessions.mockResolvedValue([]); + + mockCreateTerminalSession + .mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" }) + .mockResolvedValueOnce({ sessionId: "session-worktree", shell: "/bin/bash", cwd: "/project/.worktrees/FN-7253" }); + + const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID)); + + await waitFor(() => { + expect(result.current.tabs.length).toBe(1); + }); + + await act(async () => { + await result.current.createTab({ cwd: "/project/.worktrees/FN-7253" }); + }); + + expect(mockCreateTerminalSession).toHaveBeenLastCalledWith( + "/project/.worktrees/FN-7253", + undefined, + undefined, + TEST_PROJECT_ID + ); + expect(result.current.activeTab?.title).toBe("FN-7253"); + expect(result.current.activeTab?.cwd).toBe("/project/.worktrees/FN-7253"); + }); + + it("persists the server-confirmed cwd for worktree tabs", async () => { + localStorageMock.getItem.mockReturnValue(null); + mockListTerminalSessions.mockResolvedValue([]); + + mockCreateTerminalSession + .mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" }) + .mockResolvedValueOnce({ sessionId: "session-worktree", shell: "/bin/bash", cwd: "/project/.worktrees/FN-7253" }); + + const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID)); + + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + await act(async () => { + await result.current.createTab({ cwd: "/project/.worktrees/FN-7253/", title: "FN-7253" }); + }); + + expect(result.current.activeTab?.title).toBe("FN-7253"); + expect(result.current.activeTab?.cwd).toBe("/project/.worktrees/FN-7253"); + }); + + it("treats an explicit undefined cwd like the default project-root flow", async () => { + localStorageMock.getItem.mockReturnValue(null); + mockListTerminalSessions.mockResolvedValue([]); + + mockCreateTerminalSession + .mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" }) + .mockResolvedValueOnce({ sessionId: "session-2", shell: "/bin/bash", cwd: "/project" }); + + const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID)); + + await waitFor(() => { + expect(result.current.tabs.length).toBe(1); + }); + + await act(async () => { + await result.current.createTab({ cwd: undefined }); + }); + + expect(mockCreateTerminalSession).toHaveBeenLastCalledWith(undefined, undefined, undefined, TEST_PROJECT_ID); + expect(result.current.activeTab?.title).toBe("Terminal 2"); + expect(result.current.activeTab?.cwd).toBeUndefined(); + }); + + it("creates independent sessions for duplicate cwd selections", async () => { + localStorageMock.getItem.mockReturnValue(null); + mockListTerminalSessions.mockResolvedValue([]); + + mockCreateTerminalSession + .mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" }) + .mockResolvedValueOnce({ sessionId: "session-worktree-1", shell: "/bin/bash", cwd: "/project/.worktrees/FN-7253" }) + .mockResolvedValueOnce({ sessionId: "session-worktree-2", shell: "/bin/bash", cwd: "/project/.worktrees/FN-7253" }); + + const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID)); + + await waitFor(() => { + expect(result.current.tabs.length).toBe(1); + }); + + await act(async () => { + await result.current.createTab({ cwd: "/project/.worktrees/FN-7253", title: "FN-7253" }); + }); + await act(async () => { + await result.current.createTab({ cwd: "/project/.worktrees/FN-7253", title: "FN-7253" }); + }); + + expect(mockCreateTerminalSession).toHaveBeenNthCalledWith( + 2, + "/project/.worktrees/FN-7253", + undefined, + undefined, + TEST_PROJECT_ID + ); + expect(mockCreateTerminalSession).toHaveBeenNthCalledWith( + 3, + "/project/.worktrees/FN-7253", + undefined, + undefined, + TEST_PROJECT_ID + ); + expect(result.current.tabs.map((tab) => tab.sessionId)).toEqual([ + "session-1", + "session-worktree-1", + "session-worktree-2", + ]); + expect(result.current.tabs.slice(1).map((tab) => tab.cwd)).toEqual([ + "/project/.worktrees/FN-7253", + "/project/.worktrees/FN-7253", + ]); }); it("names tabs with incrementing numbers", async () => { @@ -429,6 +596,38 @@ describe("useTerminalSessions", () => { // Tab should have new session expect(result.current.activeTab?.sessionId).toBe("session-new"); }); + + it("restarts worktree tabs in their preserved cwd", async () => { + localStorageMock.getItem.mockReturnValue(null); + mockListTerminalSessions.mockResolvedValue([]); + + mockCreateTerminalSession + .mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" }) + .mockResolvedValueOnce({ sessionId: "session-worktree", shell: "/bin/bash", cwd: "/project/.worktrees/FN-7253" }) + .mockResolvedValueOnce({ sessionId: "session-worktree-new", shell: "/bin/bash", cwd: "/project/.worktrees/FN-7253" }); + + const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID)); + + await waitFor(() => { + expect(result.current.tabs.length).toBe(1); + }); + + await act(async () => { + await result.current.createTab({ cwd: "/project/.worktrees/FN-7253", title: "FN-7253" }); + }); + await act(async () => { + await result.current.restartActiveTab(); + }); + + expect(mockCreateTerminalSession).toHaveBeenLastCalledWith( + "/project/.worktrees/FN-7253", + undefined, + undefined, + TEST_PROJECT_ID, + ); + expect(result.current.activeTab?.sessionId).toBe("session-worktree-new"); + expect(result.current.activeTab?.cwd).toBe("/project/.worktrees/FN-7253"); + }); }); describe("replacing active tab session (invalid session recovery)", () => { @@ -462,6 +661,42 @@ describe("useTerminalSessions", () => { expect(result.current.activeTab?.sessionId).toBe("session-replacement"); }); + it("replaces stale worktree sessions in their preserved cwd", async () => { + localStorageMock.getItem.mockReturnValue(null); + mockListTerminalSessions.mockResolvedValue([]); + + mockCreateTerminalSession + .mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" }) + .mockResolvedValueOnce({ sessionId: "session-worktree", shell: "/bin/bash", cwd: "/project/.worktrees/FN-7253" }) + .mockResolvedValueOnce({ sessionId: "session-worktree-replacement", shell: "/bin/bash", cwd: "/project/.worktrees/FN-7253" }); + + const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID)); + + await waitFor(() => { + expect(result.current.tabs.length).toBe(1); + }); + + await act(async () => { + await result.current.createTab({ cwd: "/project/.worktrees/FN-7253", title: "FN-7253" }); + }); + const tabId = result.current.activeTab!.id; + + await act(async () => { + await result.current.replaceActiveTabSession(); + }); + + expect(mockCreateTerminalSession).toHaveBeenLastCalledWith( + "/project/.worktrees/FN-7253", + undefined, + undefined, + TEST_PROJECT_ID, + ); + expect(mockKillPtyTerminalSession).not.toHaveBeenCalled(); + expect(result.current.activeTab?.id).toBe(tabId); + expect(result.current.activeTab?.sessionId).toBe("session-worktree-replacement"); + expect(result.current.activeTab?.cwd).toBe("/project/.worktrees/FN-7253"); + }); + it("sets bootstrapError when replacement session creation fails", async () => { localStorageMock.getItem.mockReturnValue(null); mockListTerminalSessions.mockResolvedValue([]); diff --git a/packages/dashboard/app/hooks/useTerminalSessions.ts b/packages/dashboard/app/hooks/useTerminalSessions.ts index a2230a016d..c1dc6ee12b 100644 --- a/packages/dashboard/app/hooks/useTerminalSessions.ts +++ b/packages/dashboard/app/hooks/useTerminalSessions.ts @@ -19,12 +19,21 @@ export interface TerminalTab { sessionId: string; /** Display title (e.g., "bash", "zsh", or "Terminal 1") */ title: string; + /** Optional working directory used when this tab's server session was created. */ + cwd?: string; /** Whether this tab is currently active */ isActive: boolean; /** Creation timestamp */ createdAt: number; } +export interface CreateTerminalTabInput { + /** Optional registered workspace/worktree path for the server-created session. */ + cwd?: string; + /** Optional display title supplied by workspace picker callers. */ + title?: string; +} + interface UseTerminalSessionsReturn { /** All terminal tabs */ tabs: TerminalTab[]; @@ -35,7 +44,7 @@ interface UseTerminalSessionsReturn { /** Error during bootstrap/session creation, or null if no error */ bootstrapError: string | null; /** Creates a new tab with a fresh server session */ - createTab: () => Promise; + createTab: (input?: CreateTerminalTabInput) => Promise; /** Closes a specific tab (kills server session) */ closeTab: (tabId: string) => void; /** Switches to a different tab */ @@ -84,6 +93,18 @@ function isRelativeUrlFetchError(error: unknown): boolean { return message.includes("Failed to parse URL") || message.includes("Invalid URL"); } +function titleFromCwd(cwd: string): string { + const trimmed = cwd.replace(/[\\/]+$/, ""); + const basename = trimmed.split(/[\\/]+/).filter(Boolean).pop(); + return basename || cwd; +} + +function buildTabTitle(input: CreateTerminalTabInput | undefined, terminalNumber: number): string { + if (input?.title?.trim()) return input.title.trim(); + if (input?.cwd?.trim()) return titleFromCwd(input.cwd.trim()); + return `Terminal ${terminalNumber}`; +} + /** * Wrap a promise with a timeout that rejects with a TimeoutError. * Uses an AbortSignal-style approach so only the winning path resolves. @@ -270,14 +291,22 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu }, [isReady, serverAvailable, tabs.length, retryGeneration]); // Run when ready or when tabs become empty /** - * Internal create tab function (used for auto-creation and user-initiated creation) + * Internal create tab function (used for auto-creation and user-initiated creation). + * + * FNXC:TerminalWorktrees 2026-06-29-00:00: + * Worktree picker callers need to create independent terminal sessions in registered worktree directories while the existing no-argument plus, auto-create, restart, and initial-command flows keep creating project-root terminals named with Terminal N numbering. + * Persist cwd only as optional metadata so older kb-terminal-tabs payloads without workspace data continue to restore and stale-session filtering still keys on server session ids. */ - const createTabInternal = useCallback(async (): Promise => { - const session = await createTerminalSession(undefined, undefined, undefined, projectId); + const createTabInternal = useCallback(async (input?: CreateTerminalTabInput): Promise => { + const requestedCwd = input?.cwd?.trim() || undefined; + const session = await createTerminalSession(requestedCwd, undefined, undefined, projectId); + const confirmedCwd = requestedCwd ? session.cwd : undefined; + const confirmedInput = confirmedCwd ? { ...input, cwd: confirmedCwd } : input; const newTab: TerminalTab = { id: generateTabId(), sessionId: session.sessionId, - title: `Terminal ${tabs.length + 1}`, + title: buildTabTitle(confirmedInput, tabs.length + 1), + ...(confirmedCwd ? { cwd: confirmedCwd } : {}), isActive: true, createdAt: Date.now(), }; @@ -292,14 +321,14 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu }); return newTab; - }, [tabs.length]); + }, [projectId, tabs.length]); /** * Creates a new tab with a fresh server session. * The new tab becomes the active tab. */ - const createTab = useCallback(async (): Promise => { - return createTabInternal(); + const createTab = useCallback(async (input?: CreateTerminalTabInput): Promise => { + return createTabInternal(input); }, [createTabInternal]); /** @@ -397,17 +426,17 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu const currentActiveTab = tabs.find((t) => t.isActive); if (!currentActiveTab) return; - // Create new session and update the tab's sessionId - const session = await createTerminalSession(undefined, undefined, undefined, projectId); + // Recreate worktree-scoped tabs in their original cwd so restart does not silently fall back to the project root. + const session = await createTerminalSession(currentActiveTab.cwd, undefined, undefined, projectId); setTabs((currentTabs) => currentTabs.map((tab) => tab.id === currentActiveTab.id - ? { ...tab, sessionId: session.sessionId } + ? { ...tab, sessionId: session.sessionId, cwd: currentActiveTab.cwd ? session.cwd : undefined } : tab ) ); - }, [tabs]); + }, [projectId, tabs]); /** * Replace the active tab's session with a fresh server session. @@ -429,12 +458,12 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu if (!currentActiveTab) return; try { - const session = await createTerminalSession(undefined, undefined, undefined, projectId); + const session = await createTerminalSession(currentActiveTab.cwd, undefined, undefined, projectId); setTabs((currentTabs) => currentTabs.map((tab) => tab.id === currentActiveTab.id - ? { ...tab, sessionId: session.sessionId } + ? { ...tab, sessionId: session.sessionId, cwd: currentActiveTab.cwd ? session.cwd : undefined } : tab ) ); @@ -447,7 +476,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu err instanceof Error ? err.message : typeof err === "string" ? err : "Failed to create terminal session"; setBootstrapError(message); } - }, [tabs]); + }, [projectId, tabs]); // Derive active tab const activeTab = tabs.find((tab) => tab.isActive) ?? null; diff --git a/packages/dashboard/src/__tests__/routes-automation.test.ts b/packages/dashboard/src/__tests__/routes-automation.test.ts index 6a95eea165..1dbb8dd8e7 100644 --- a/packages/dashboard/src/__tests__/routes-automation.test.ts +++ b/packages/dashboard/src/__tests__/routes-automation.test.ts @@ -143,6 +143,7 @@ vi.mock("@fusion/engine", async () => { promptWithFallback: vi.fn(async (session: { prompt: (message: string) => Promise }, prompt: string) => { await session.prompt(prompt); }), + resolveMcpServersForStore: vi.fn(async () => ({ servers: [], errors: [] })), AgentReflectionService: class MockAgentReflectionService { async generateReflection(): Promise { throw new Error("Reflection service unavailable in route tests"); @@ -319,7 +320,7 @@ describe("Terminal session routes", () => { const mockService = { getAllSessions: vi.fn().mockReturnValue(mockSessions), }; - vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any); + const terminalServiceSpy = vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any); const res = await GET(buildApp(), "/api/terminal/sessions"); @@ -332,7 +333,7 @@ describe("Terminal session routes", () => { expect(res.body[0].scrollbackBuffer).toBeUndefined(); expect(res.body[0].env).toBeUndefined(); - vi.restoreAllMocks(); + terminalServiceSpy.mockRestore(); }); }); @@ -345,7 +346,7 @@ describe("Terminal session routes", () => { error: "Maximum terminal sessions reached. Please close an existing terminal and try again.", }), }; - vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any); + const terminalServiceSpy = vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any); const res = await REQUEST( buildApp(), @@ -361,11 +362,12 @@ describe("Terminal session routes", () => { details: { code: "max_sessions" }, }); - vi.restoreAllMocks(); + terminalServiceSpy.mockRestore(); }); it.each([ ["invalid_shell", 400, "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell)."], + ["invalid_cwd", 400, "Terminal working directory is not an authorized project or task worktree."], ["pty_load_failed", 503, "Terminal service unavailable. The PTY module could not be loaded."], ["pty_spawn_failed", 500, "Failed to start terminal shell process."], ] as const)("returns %s errors with the correct status and body", async (code, status, error) => { @@ -376,7 +378,7 @@ describe("Terminal session routes", () => { error, }), }; - vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any); + const terminalServiceSpy = vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any); const res = await REQUEST( buildApp(), @@ -389,7 +391,7 @@ describe("Terminal session routes", () => { expect(res.status).toBe(status); expect(res.body).toEqual({ error, details: { code } }); - vi.restoreAllMocks(); + terminalServiceSpy.mockRestore(); }); it("returns 201 for a successful session creation", async () => { @@ -403,24 +405,29 @@ describe("Terminal session routes", () => { }, }), }; - vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any); + const terminalServiceSpy = vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any); const res = await REQUEST( buildApp(), "POST", "/api/terminal/sessions", - JSON.stringify({}), + JSON.stringify({ cwd: "/fake/root", cols: 120, rows: 40 }), { "Content-Type": "application/json" }, ); expect(res.status).toBe(201); + expect(mockService.createSession).toHaveBeenCalledWith({ + cwd: "/fake/root", + cols: 120, + rows: 40, + }); expect(res.body).toEqual({ sessionId: "term-123", shell: "/bin/zsh", cwd: "/fake/root", }); - vi.restoreAllMocks(); + terminalServiceSpy.mockRestore(); }); }); }); @@ -451,7 +458,7 @@ describe("Terminal WebSocket close handler", () => { onExit: onExitMock, }; - vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any); + const terminalServiceSpy = vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any); const { setupTerminalWebSocket } = await import("../server.js"); @@ -480,7 +487,7 @@ describe("Terminal WebSocket close handler", () => { // The session must NOT be killed on WebSocket close expect(killSessionMock).not.toHaveBeenCalled(); - vi.restoreAllMocks(); + terminalServiceSpy.mockRestore(); }); it("does NOT kill PTY session when WebSocket encounters an error (session persists for reconnect)", async () => { @@ -505,7 +512,7 @@ describe("Terminal WebSocket close handler", () => { onExit: onExitMock, }; - vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any); + const terminalServiceSpy = vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any); const { setupTerminalWebSocket } = await import("../server.js"); @@ -534,7 +541,7 @@ describe("Terminal WebSocket close handler", () => { // The session must NOT be killed on WebSocket error expect(killSessionMock).not.toHaveBeenCalled(); - vi.restoreAllMocks(); + terminalServiceSpy.mockRestore(); }); it("cleans up data/exit subscriptions on WebSocket close without killing session", async () => { @@ -563,7 +570,7 @@ describe("Terminal WebSocket close handler", () => { onExit: onExitMock, }; - vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any); + const terminalServiceSpy = vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any); const { setupTerminalWebSocket } = await import("../server.js"); @@ -595,7 +602,7 @@ describe("Terminal WebSocket close handler", () => { // But session should NOT be killed expect(killSessionMock).not.toHaveBeenCalled(); - vi.restoreAllMocks(); + terminalServiceSpy.mockRestore(); }); }); diff --git a/packages/dashboard/src/__tests__/terminal-service.test.ts b/packages/dashboard/src/__tests__/terminal-service.test.ts index 6e1d358cfb..d4fbaebf6a 100644 --- a/packages/dashboard/src/__tests__/terminal-service.test.ts +++ b/packages/dashboard/src/__tests__/terminal-service.test.ts @@ -5,6 +5,11 @@ import { TerminalService, STALE_SESSION_THRESHOLD_MS, } from "../terminal-service.js"; +import { runGitCommand } from "../routes/resolve-diff-base.js"; + +const { mockStat } = vi.hoisted(() => ({ + mockStat: vi.fn(), +})); // Mock node-pty const mockPtyProcess = { @@ -36,6 +41,18 @@ vi.mock("node:fs", async (importOriginal) => { }; }); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + stat: mockStat, + }; +}); + +vi.mock("../routes/resolve-diff-base.js", () => ({ + runGitCommand: vi.fn(), +})); + describe("TerminalService", () => { let service: TerminalService; const projectRoot = "/test/project"; @@ -45,6 +62,8 @@ describe("TerminalService", () => { service = new TerminalService(projectRoot, 10); mockPtyProcess._onDataCallback = null; mockPtyProcess._onExitCallback = null; + mockStat.mockResolvedValue({ isDirectory: () => true }); + vi.mocked(runGitCommand).mockResolvedValue("worktree /test/project\nHEAD abc\n"); }); afterEach(() => { @@ -88,6 +107,112 @@ describe("TerminalService", () => { }); }); + it("allows an explicit project-root cwd", async () => { + const result = await service.createSession({ cwd: projectRoot }); + + expect(result.success).toBe(true); + if (!result.success) throw new Error("Expected terminal session creation to succeed"); + expect(result.session.cwd).toBe(projectRoot); + expect(runGitCommand).not.toHaveBeenCalled(); + }); + + it("allows a Git-registered worktree cwd outside the project root", async () => { + vi.mocked(runGitCommand).mockResolvedValue("worktree /test/project\nHEAD abc\n\nworktree /tmp/fusion-worktrees/FN-7253\nHEAD def\n"); + + const result = await service.createSession({ cwd: "/tmp/fusion-worktrees/FN-7253" }); + + expect(result.success).toBe(true); + if (!result.success) throw new Error("Expected terminal session creation to succeed"); + expect(result.session.cwd).toBe("/tmp/fusion-worktrees/FN-7253"); + }); + + it("refreshes the registered-worktree allowlist after a cached miss", async () => { + vi.mocked(runGitCommand) + .mockResolvedValueOnce("worktree /test/project\nHEAD abc\n") + .mockResolvedValueOnce("worktree /test/project\nHEAD abc\n\nworktree /tmp/fusion-worktrees/FN-7253\nHEAD def\n") + .mockResolvedValueOnce("worktree /test/project\nHEAD abc\n\nworktree /tmp/fusion-worktrees/FN-7253\nHEAD def\n"); + + const beforeRefresh = await service.createSession({ cwd: "/tmp/fusion-worktrees/FN-7253" }); + const afterRefresh = await service.createSession({ cwd: "/tmp/fusion-worktrees/FN-7253" }); + + expect(beforeRefresh.success).toBe(true); + if (!beforeRefresh.success) throw new Error("Expected first terminal session creation to succeed"); + expect(beforeRefresh.session.cwd).toBe("/tmp/fusion-worktrees/FN-7253"); + expect(afterRefresh.success).toBe(true); + if (!afterRefresh.success) throw new Error("Expected second terminal session creation to succeed"); + expect(afterRefresh.session.cwd).toBe("/tmp/fusion-worktrees/FN-7253"); + expect(runGitCommand).toHaveBeenCalledTimes(3); + }); + + it("revalidates a cached registered worktree before authorizing cwd reuse", async () => { + vi.mocked(runGitCommand) + .mockResolvedValueOnce("worktree /test/project\nHEAD abc\n\nworktree /tmp/fusion-worktrees/stale\nHEAD def\n") + .mockResolvedValueOnce("worktree /test/project\nHEAD abc\n"); + + const beforeRemoval = await service.createSession({ cwd: "/tmp/fusion-worktrees/stale" }); + const afterRemoval = await service.createSession({ cwd: "/tmp/fusion-worktrees/stale" }); + + expect(beforeRemoval.success).toBe(true); + if (!beforeRemoval.success) throw new Error("Expected first terminal session creation to succeed"); + expect(beforeRemoval.session.cwd).toBe("/tmp/fusion-worktrees/stale"); + expect(afterRemoval).toEqual({ + success: false, + code: "invalid_cwd", + error: "Terminal working directory is not an authorized project or task worktree.", + }); + expect(runGitCommand).toHaveBeenCalledTimes(2); + }); + + it("rejects an explicit external unregistered cwd", async () => { + vi.mocked(runGitCommand).mockResolvedValue("worktree /test/project\nHEAD abc\n"); + + const result = await service.createSession({ cwd: "/etc" }); + + expect(result).toEqual({ + success: false, + code: "invalid_cwd", + error: "Terminal working directory is not an authorized project or task worktree.", + }); + }); + + it("rejects relative traversal even when the target is registered", async () => { + vi.mocked(runGitCommand).mockResolvedValue("worktree /tmp/fusion-worktrees/FN-7253\nHEAD def\n"); + + const result = await service.createSession({ cwd: "../../tmp/fusion-worktrees/FN-7253" }); + + expect(result).toEqual({ + success: false, + code: "invalid_cwd", + error: "Terminal working directory is not an authorized project or task worktree.", + }); + expect(runGitCommand).not.toHaveBeenCalled(); + }); + + it("rejects an authorized worktree cwd when the directory is missing", async () => { + vi.mocked(runGitCommand).mockResolvedValue("worktree /tmp/fusion-worktrees/missing\nHEAD def\n"); + mockStat.mockRejectedValueOnce(new Error("ENOENT")); + + const result = await service.createSession({ cwd: "/tmp/fusion-worktrees/missing" }); + + expect(result).toEqual({ + success: false, + code: "invalid_cwd", + error: "Terminal working directory is not a readable directory.", + }); + }); + + it("does not authorize duplicate or overlapping registered-worktree siblings", async () => { + vi.mocked(runGitCommand).mockResolvedValue("worktree /tmp/fusion-worktrees/task\nHEAD def\n\nworktree /tmp/fusion-worktrees/task\nHEAD def\n\nworktree /tmp/fusion-worktrees/task/nested\nHEAD abc\n"); + + const result = await service.createSession({ cwd: "/tmp/fusion-worktrees/task-evil" }); + + expect(result).toEqual({ + success: false, + code: "invalid_cwd", + error: "Terminal working directory is not an authorized project or task worktree.", + }); + }); + }); describe("waitForReady", () => { diff --git a/packages/dashboard/src/git-worktree-safety.ts b/packages/dashboard/src/git-worktree-safety.ts new file mode 100644 index 0000000000..2e015b3220 --- /dev/null +++ b/packages/dashboard/src/git-worktree-safety.ts @@ -0,0 +1,113 @@ +import { isAbsolute, relative, resolve } from "node:path"; +import type { TaskStore } from "@fusion/core"; +import { badRequest } from "./api-error.js"; +import { runGitCommand } from "./routes/resolve-diff-base.js"; + +export function isPathWithin(parent: string, candidate: string): boolean { + const rel = relative(parent, candidate); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +export async function listRegisteredWorktreePaths(rootDir: string): Promise { + const output = await runGitCommand(["worktree", "list", "--porcelain"], rootDir, 10_000); + const paths: string[] = []; + for (const line of output.split("\n")) { + if (!line.startsWith("worktree ")) continue; + const worktreePath = line.slice("worktree ".length).trim(); + if (!worktreePath) continue; + paths.push(resolve(worktreePath)); + } + return paths; +} + +export async function assertWorktreePathSafe( + scopedStore: Pick, + worktreePath: string, + cache: Map, +): Promise { + if (typeof worktreePath !== "string" || worktreePath.trim().length === 0) { + throw badRequest("worktreePath is required"); + } + + if (!isAbsolute(worktreePath)) { + throw badRequest("worktreePath must be an absolute path"); + } + const rootDir = resolve(scopedStore.getRootDir()); + const resolved = resolve(worktreePath); + if (resolved !== worktreePath) { + throw badRequest("worktreePath must be normalized"); + } + if (isPathWithin(rootDir, resolved)) { + return resolved; + } + + let allowlisted = cache.get(rootDir); + const hadCachedAllowlist = Boolean(allowlisted); + if (!allowlisted) { + allowlisted = await listRegisteredWorktreePaths(rootDir); + cache.set(rootDir, allowlisted); + } + + const cachedMatch = allowlisted.some((allowed) => isPathWithin(allowed, resolved)); + if (cachedMatch && !hadCachedAllowlist) { + return resolved; + } + + /* + FNXC:TerminalWorktrees 2026-06-29-23:37: + Registered worktrees can be created or removed while a long-lived dashboard server is already running. Refresh the Git worktree allowlist on cached hits or misses before authorizing an outside-root path so stale cached registrations do not keep arbitrary recreated directories authorized. + */ + allowlisted = await listRegisteredWorktreePaths(rootDir); + cache.set(rootDir, allowlisted); + if (allowlisted.some((allowed) => isPathWithin(allowed, resolved))) { + return resolved; + } + + throw badRequest("worktreePath outside project"); +} + +export async function isAuthorizedProjectOrRegisteredWorktreePath( + rootDir: string, + candidatePath: string, + cache: Map = new Map(), +): Promise { + /* + FNXC:TerminalWorktrees 2026-06-29-00:00: + Terminal cwd may target the project root or Git-registered worktrees for that project, including task worktrees outside the root. Normalize absolute paths and authorize them against the shared git-worktree policy before PTY spawn so arbitrary filesystem paths remain blocked. + */ + const root = resolve(rootDir); + const candidate = resolve(candidatePath); + + if (isPathWithin(root, candidate)) { + return true; + } + + let allowlisted = cache.get(root); + const hadCachedAllowlist = Boolean(allowlisted); + if (!allowlisted) { + try { + allowlisted = await listRegisteredWorktreePaths(root); + } catch { + return false; + } + cache.set(root, allowlisted); + } + + const cachedMatch = allowlisted.some((allowed) => isPathWithin(allowed, candidate)); + if (cachedMatch && !hadCachedAllowlist) { + return true; + } + + /* + FNXC:TerminalWorktrees 2026-06-29-23:37: + Revalidate outside-root terminal cwd authorization against current `git worktree list` output on cached hits or misses. Without this fresh check, removing a worktree and later recreating a directory at the same path would leave the PTY cwd authorization stale for the server lifetime. + */ + try { + allowlisted = await listRegisteredWorktreePaths(root); + } catch { + return false; + } + cache.set(root, allowlisted); + + return allowlisted.some((allowed) => isPathWithin(allowed, candidate)); +} diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index db56cd5e0e..60eb68acbb 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -1,5 +1,5 @@ import { type NextFunction, type Request, type Response } from "express"; -import { isAbsolute, resolve, relative } from "node:path"; +import { isAbsolute, resolve } from "node:path"; import { realpathSync } from "node:fs"; import { exec as execCb, spawn } from "node:child_process"; import { promisify } from "node:util"; @@ -57,6 +57,7 @@ import { } from "../github-webhooks.js"; import type { ApiRoutesContext } from "./types.js"; import { runGitCommand } from "./resolve-diff-base.js"; +import { assertWorktreePathSafe, isPathWithin, listRegisteredWorktreePaths } from "../git-worktree-safety.js"; const execAsync = promisify(execCb); const PR_ROUTE_MAX_BUFFER_BYTES = 10 * 1024 * 1024; @@ -1249,57 +1250,6 @@ async function dropStashBySha(sha: string, cwd?: string): Promise { await runGitCommand(["stash", "drop", ref], cwd, 10_000); } -function isPathWithin(parent: string, candidate: string): boolean { - const rel = relative(parent, candidate); - return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); -} - -async function listRegisteredWorktreePaths(rootDir: string): Promise { - const output = await runGitCommand(["worktree", "list", "--porcelain"], rootDir, 10_000); - const paths: string[] = []; - for (const line of output.split("\n")) { - if (!line.startsWith("worktree ")) continue; - const worktreePath = line.slice("worktree ".length).trim(); - if (!worktreePath) continue; - paths.push(resolve(worktreePath)); - } - return paths; -} - -async function assertWorktreePathSafe( - scopedStore: Pick, - worktreePath: string, - cache: Map, -): Promise { - if (typeof worktreePath !== "string" || worktreePath.trim().length === 0) { - throw badRequest("worktreePath is required"); - } - - if (!isAbsolute(worktreePath)) { - throw badRequest("worktreePath must be an absolute path"); - } - const rootDir = resolve(scopedStore.getRootDir()); - const resolved = resolve(worktreePath); - if (resolved !== worktreePath) { - throw badRequest("worktreePath must be normalized"); - } - if (isPathWithin(rootDir, resolved)) { - return resolved; - } - - let allowlisted = cache.get(rootDir); - if (!allowlisted) { - allowlisted = await listRegisteredWorktreePaths(rootDir); - cache.set(rootDir, allowlisted); - } - - if (allowlisted.some((allowed) => isPathWithin(allowed, resolved))) { - return resolved; - } - - throw badRequest("worktreePath outside project"); -} - type DashboardGitMutationType = "stash:push" | "stash:pop" | "pull:fast-forward" | "stash:pop-conflict"; function assertRelativeFileSafe(worktreePath: string, file: string): string { diff --git a/packages/dashboard/src/routes/register-messaging-scripts.ts b/packages/dashboard/src/routes/register-messaging-scripts.ts index 6160e51e13..3b42fea901 100644 --- a/packages/dashboard/src/routes/register-messaging-scripts.ts +++ b/packages/dashboard/src/routes/register-messaging-scripts.ts @@ -140,6 +140,7 @@ export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void { const statusByCode = { max_sessions: 503, invalid_shell: 400, + invalid_cwd: 400, pty_load_failed: 503, pty_spawn_failed: 500, } as const; diff --git a/packages/dashboard/src/routes/register-terminal-routes.ts b/packages/dashboard/src/routes/register-terminal-routes.ts index 1edf4d716a..97aec4eea1 100644 --- a/packages/dashboard/src/routes/register-terminal-routes.ts +++ b/packages/dashboard/src/routes/register-terminal-routes.ts @@ -173,6 +173,7 @@ export function registerTerminalRoutes(router: Router, deps: TerminalRouteDeps): const statusByCode = { max_sessions: 503, invalid_shell: 400, + invalid_cwd: 400, pty_load_failed: 503, pty_spawn_failed: 500, } as const; diff --git a/packages/dashboard/src/terminal-service.ts b/packages/dashboard/src/terminal-service.ts index e14942e0c0..a7e97c7c52 100644 --- a/packages/dashboard/src/terminal-service.ts +++ b/packages/dashboard/src/terminal-service.ts @@ -11,10 +11,12 @@ import { EventEmitter } from "events"; import * as os from "os"; import * as path from "path"; import * as fs from "node:fs"; +import { stat } from "node:fs/promises"; // The node-pty native-asset loader (lazy-load, prebuild resolution, dlopen // fallback, and permission repair) lives in @fusion/engine so PTY owners share // one implementation. See packages/engine/src/pty-native.ts. import { loadPtyModule } from "@fusion/engine"; +import { isAuthorizedProjectOrRegisteredWorktreePath, isPathWithin } from "./git-worktree-safety.js"; // Maximum scrollback buffer size (characters) const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal @@ -115,9 +117,17 @@ export interface TerminalOptions { export type CreateSessionErrorCode = | "max_sessions" | "invalid_shell" + | "invalid_cwd" | "pty_load_failed" | "pty_spawn_failed"; +class TerminalCwdError extends Error { + constructor(message: string) { + super(message); + this.name = "TerminalCwdError"; + } +} + export type CreateSessionResult = | { success: true; session: TerminalSession } | { success: false; error: string; code: CreateSessionErrorCode }; @@ -132,6 +142,7 @@ export class TerminalService extends EventEmitter { private isWindows = os.platform() === "win32"; private projectRoot: string; private maxSessions: number; + private registeredWorktreeCache: Map = new Map(); constructor(projectRoot: string, maxSessions: number = DEFAULT_MAX_SESSIONS) { super(); @@ -244,7 +255,10 @@ export class TerminalService extends EventEmitter { } /** - * Validate and resolve a working directory path + * Validate and resolve a working directory path. + * + * FNXC:TerminalWorktrees 2026-06-29-00:00: + * Terminal cwd selection may target the project root or a Git-registered task worktree, including worktrees outside the root. Explicit cwd requests that are stale, missing, traversal-based, or otherwise unauthorized must fail instead of falling back, so the UI never labels a project-root shell as a selected worktree shell. */ private async resolveWorkingDirectory(requestedCwd?: string): Promise { // If no cwd requested, use project root @@ -258,34 +272,42 @@ export class TerminalService extends EventEmitter { // Reject paths with null bytes (could bypass path checks) if (cwd.includes("\0")) { console.warn(`Rejecting path with null byte: ${cwd.replace(/\0/g, "\\0")}`); - return this.projectRoot; + throw new TerminalCwdError("Terminal working directory is not an authorized project or task worktree."); } - // Normalize the path to resolve . and .. segments + const isAbsoluteRequest = path.isAbsolute(cwd); + + // Normalize the path to resolve . and .. segments. Absolute cwd values remain absolute; + // relative cwd values resolve beneath the project root before authorization. cwd = path.resolve(this.projectRoot, cwd); - // Ensure path is within project root (path traversal protection) - const relativeToProjectRoot = path.relative(this.projectRoot, cwd); - if ( - relativeToProjectRoot.startsWith("..") || - path.isAbsolute(relativeToProjectRoot) - ) { - console.warn(`Path traversal attempt blocked: ${requestedCwd}`); - return this.projectRoot; + if (!isAbsoluteRequest && !isPathWithin(this.projectRoot, cwd)) { + console.warn(`Terminal relative working directory escape blocked: ${requestedCwd}`); + throw new TerminalCwdError("Terminal working directory is not an authorized project or task worktree."); + } + + const authorized = await isAuthorizedProjectOrRegisteredWorktreePath( + this.projectRoot, + cwd, + this.registeredWorktreeCache, + ); + if (!authorized) { + console.warn(`Terminal working directory outside project worktrees blocked: ${requestedCwd}`); + throw new TerminalCwdError("Terminal working directory is not an authorized project or task worktree."); } // Check if path exists and is a directory try { - const stat = await import("node:fs/promises").then((fs) => fs.stat(cwd)); - if (stat.isDirectory()) { + const cwdStat = await stat(cwd); + if (cwdStat.isDirectory()) { return cwd; } + console.warn(`Working directory is not a directory: ${cwd}`); } catch { - // Path doesn't exist, fall back to project root console.warn(`Working directory does not exist: ${cwd}`); } - return this.projectRoot; + throw new TerminalCwdError("Terminal working directory is not a readable directory."); } /** @@ -446,7 +468,19 @@ export class TerminalService extends EventEmitter { } // Validate and resolve working directory - const cwd = await this.resolveWorkingDirectory(options.cwd); + let cwd: string; + try { + cwd = await this.resolveWorkingDirectory(options.cwd); + } catch (error) { + if (error instanceof TerminalCwdError) { + return { + success: false, + code: "invalid_cwd", + error: error.message, + }; + } + throw error; + } const spawnDiagnostics = this.getSpawnDiagnostics(options.shell, detectedShell, shellArgs, cwd); // Build environment with stripped sensitive vars