diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 91dfa2bb66..ae2435bde9 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -329,8 +329,8 @@ const AIViewInner: React.FC = ({ const openTerminal = useOpenTerminal(); const handleOpenTerminal = useCallback(() => { - openTerminal(workspaceId); - }, [workspaceId, openTerminal]); + openTerminal(workspaceId, runtimeConfig); + }, [workspaceId, openTerminal, runtimeConfig]); // Auto-scroll when messages or todos update (during streaming) useEffect(() => { diff --git a/src/browser/components/WorkspaceHeader.tsx b/src/browser/components/WorkspaceHeader.tsx index c93d487956..adfaf64dfa 100644 --- a/src/browser/components/WorkspaceHeader.tsx +++ b/src/browser/components/WorkspaceHeader.tsx @@ -29,8 +29,8 @@ export const WorkspaceHeader: React.FC = ({ const { canInterrupt } = useWorkspaceSidebarState(workspaceId); const { startSequence: startTutorial, isSequenceCompleted } = useTutorial(); const handleOpenTerminal = useCallback(() => { - openTerminal(workspaceId); - }, [workspaceId, openTerminal]); + openTerminal(workspaceId, runtimeConfig); + }, [workspaceId, openTerminal, runtimeConfig]); // Start workspace tutorial on first entry (only if settings tutorial is done) useEffect(() => { diff --git a/src/browser/hooks/useOpenTerminal.ts b/src/browser/hooks/useOpenTerminal.ts index 403a4fb887..39d6f4a8eb 100644 --- a/src/browser/hooks/useOpenTerminal.ts +++ b/src/browser/hooks/useOpenTerminal.ts @@ -1,40 +1,50 @@ import { useCallback } from "react"; import { useAPI } from "@/browser/contexts/API"; +import type { RuntimeConfig } from "@/common/types/runtime"; +import { isSSHRuntime } from "@/common/types/runtime"; /** * Hook to open a terminal window for a workspace. * Handles the difference between Desktop (Electron) and Browser (Web) environments. * - * In Electron (desktop) mode: Opens the user's native terminal emulator + * For SSH workspaces: Always opens a web-based xterm.js terminal that connects + * through the backend PTY service (works in both browser and Electron modes). + * + * For local workspaces in Electron: Opens the user's native terminal emulator * (Ghostty, Terminal.app, etc.) with the working directory set to the workspace path. * - * In browser mode: Opens a web-based xterm.js terminal in a popup window. + * For local workspaces in browser: Opens a web-based xterm.js terminal in a popup window. */ export function useOpenTerminal() { const { api } = useAPI(); return useCallback( - (workspaceId: string) => { + (workspaceId: string, runtimeConfig?: RuntimeConfig) => { // Check if running in browser mode // window.api is only available in Electron (set by preload.ts) // If window.api exists, we're in Electron; if not, we're in browser mode const isBrowser = !window.api; + const isSSH = isSSHRuntime(runtimeConfig); - if (isBrowser) { - // In browser mode, we must open the window client-side using window.open - // The backend cannot open a window on the user's client - const url = `/terminal.html?workspaceId=${encodeURIComponent(workspaceId)}`; - window.open( - url, - `terminal-${workspaceId}-${Date.now()}`, - "width=1000,height=600,popup=yes" - ); + // SSH workspaces always use web terminal (in browser popup or Electron window) + // because the PTY service handles the SSH connection to the remote host + if (isBrowser || isSSH) { + if (isBrowser) { + // In browser mode, we must open the window client-side using window.open + // The backend cannot open a window on the user's client + const url = `/terminal.html?workspaceId=${encodeURIComponent(workspaceId)}`; + window.open( + url, + `terminal-${workspaceId}-${Date.now()}`, + "width=1000,height=600,popup=yes" + ); + } - // We also notify the backend, though in browser mode the backend handler currently does nothing. - // This is kept for consistency and in case the backend logic changes to track open windows. + // Open web terminal window (Electron pops up BrowserWindow, browser already opened above) + // For SSH: this is the only way to get a terminal that works through PTY service void api?.terminal.openWindow({ workspaceId }); } else { - // In Electron (desktop) mode, open the native system terminal + // In Electron (desktop) mode with local workspace, open the native system terminal // This spawns the user's preferred terminal emulator (Ghostty, Terminal.app, etc.) void api?.terminal.openNative({ workspaceId }); } diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 01791e58b6..70e4dc4544 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -9,6 +9,7 @@ import { CommandIds } from "@/browser/utils/commandIds"; import type { ProjectConfig } from "@/node/config"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { BranchListResult } from "@/common/orpc/types"; +import type { RuntimeConfig } from "@/common/types/runtime"; export interface BuildSourcesParams { api: APIClient | null; @@ -44,7 +45,7 @@ export interface BuildSourcesParams { onRemoveProject: (path: string) => void; onToggleSidebar: () => void; onNavigateWorkspace: (dir: "next" | "prev") => void; - onOpenWorkspaceInTerminal: (workspaceId: string) => void; + onOpenWorkspaceInTerminal: (workspaceId: string, runtimeConfig?: RuntimeConfig) => void; onToggleTheme: () => void; onSetTheme: (theme: ThemeMode) => void; onOpenSettings?: (section?: string) => void; @@ -130,6 +131,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi // Remove current workspace (rename action intentionally omitted until we add a proper modal) if (selected?.namedWorkspacePath) { const workspaceDisplayName = `${selected.projectName}/${selected.namedWorkspacePath.split("/").pop() ?? selected.namedWorkspacePath}`; + const selectedMeta = p.workspaceMetadata.get(selected.workspaceId); list.push({ id: CommandIds.workspaceOpenTerminalCurrent(), title: "Open Current Workspace in Terminal", @@ -137,7 +139,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi section: section.workspaces, shortcutHint: formatKeybind(KEYBINDS.OPEN_TERMINAL), run: () => { - p.onOpenWorkspaceInTerminal(selected.workspaceId); + p.onOpenWorkspaceInTerminal(selected.workspaceId, selectedMeta?.runtimeConfig); }, }); list.push({ @@ -204,7 +206,8 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi }, ], onSubmit: (vals) => { - p.onOpenWorkspaceInTerminal(vals.workspaceId); + const meta = p.workspaceMetadata.get(vals.workspaceId); + p.onOpenWorkspaceInTerminal(vals.workspaceId, meta?.runtimeConfig); }, }, });