Skip to content

Commit ab873ab

Browse files
authored
🤖 fix: use web terminal for SSH workspaces in Electron mode (#951)
Fixes regression introduced in commit 3ee7288 (ORPC migration) where SSH workspaces in Electron desktop mode would open a native terminal (Ghostty, Terminal.app) that fails, instead of the working web terminal. ## The Problem The `useOpenTerminal` hook introduced in the ORPC migration always called `terminal.openNative()` in Electron mode, regardless of whether the workspace was local or SSH. This caused SSH workspaces to try opening a local Ghostty/Terminal.app instead of the web-based xterm.js terminal that actually works via the PTY service. ## The Fix Modified `useOpenTerminal` hook to check runtime type: - **SSH workspaces**: Always use web terminal (`terminal.openWindow`) - the PTY service handles SSH connections - **Local workspaces in Electron**: Continue using native terminal (`openNative`) - **Browser mode**: Unchanged (always web terminal) Updated all call sites to pass `runtimeConfig`: - `WorkspaceHeader.tsx` - `AIView.tsx` - `sources.ts` (command palette actions) --- _Generated with `mux`_
1 parent bce0bda commit ab873ab

File tree

4 files changed

+35
-22
lines changed

4 files changed

+35
-22
lines changed

src/browser/components/AIView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
329329

330330
const openTerminal = useOpenTerminal();
331331
const handleOpenTerminal = useCallback(() => {
332-
openTerminal(workspaceId);
333-
}, [workspaceId, openTerminal]);
332+
openTerminal(workspaceId, runtimeConfig);
333+
}, [workspaceId, openTerminal, runtimeConfig]);
334334

335335
// Auto-scroll when messages or todos update (during streaming)
336336
useEffect(() => {

src/browser/components/WorkspaceHeader.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ export const WorkspaceHeader: React.FC<WorkspaceHeaderProps> = ({
2929
const { canInterrupt } = useWorkspaceSidebarState(workspaceId);
3030
const { startSequence: startTutorial, isSequenceCompleted } = useTutorial();
3131
const handleOpenTerminal = useCallback(() => {
32-
openTerminal(workspaceId);
33-
}, [workspaceId, openTerminal]);
32+
openTerminal(workspaceId, runtimeConfig);
33+
}, [workspaceId, openTerminal, runtimeConfig]);
3434

3535
// Start workspace tutorial on first entry (only if settings tutorial is done)
3636
useEffect(() => {

src/browser/hooks/useOpenTerminal.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,50 @@
11
import { useCallback } from "react";
22
import { useAPI } from "@/browser/contexts/API";
3+
import type { RuntimeConfig } from "@/common/types/runtime";
4+
import { isSSHRuntime } from "@/common/types/runtime";
35

46
/**
57
* Hook to open a terminal window for a workspace.
68
* Handles the difference between Desktop (Electron) and Browser (Web) environments.
79
*
8-
* In Electron (desktop) mode: Opens the user's native terminal emulator
10+
* For SSH workspaces: Always opens a web-based xterm.js terminal that connects
11+
* through the backend PTY service (works in both browser and Electron modes).
12+
*
13+
* For local workspaces in Electron: Opens the user's native terminal emulator
914
* (Ghostty, Terminal.app, etc.) with the working directory set to the workspace path.
1015
*
11-
* In browser mode: Opens a web-based xterm.js terminal in a popup window.
16+
* For local workspaces in browser: Opens a web-based xterm.js terminal in a popup window.
1217
*/
1318
export function useOpenTerminal() {
1419
const { api } = useAPI();
1520

1621
return useCallback(
17-
(workspaceId: string) => {
22+
(workspaceId: string, runtimeConfig?: RuntimeConfig) => {
1823
// Check if running in browser mode
1924
// window.api is only available in Electron (set by preload.ts)
2025
// If window.api exists, we're in Electron; if not, we're in browser mode
2126
const isBrowser = !window.api;
27+
const isSSH = isSSHRuntime(runtimeConfig);
2228

23-
if (isBrowser) {
24-
// In browser mode, we must open the window client-side using window.open
25-
// The backend cannot open a window on the user's client
26-
const url = `/terminal.html?workspaceId=${encodeURIComponent(workspaceId)}`;
27-
window.open(
28-
url,
29-
`terminal-${workspaceId}-${Date.now()}`,
30-
"width=1000,height=600,popup=yes"
31-
);
29+
// SSH workspaces always use web terminal (in browser popup or Electron window)
30+
// because the PTY service handles the SSH connection to the remote host
31+
if (isBrowser || isSSH) {
32+
if (isBrowser) {
33+
// In browser mode, we must open the window client-side using window.open
34+
// The backend cannot open a window on the user's client
35+
const url = `/terminal.html?workspaceId=${encodeURIComponent(workspaceId)}`;
36+
window.open(
37+
url,
38+
`terminal-${workspaceId}-${Date.now()}`,
39+
"width=1000,height=600,popup=yes"
40+
);
41+
}
3242

33-
// We also notify the backend, though in browser mode the backend handler currently does nothing.
34-
// This is kept for consistency and in case the backend logic changes to track open windows.
43+
// Open web terminal window (Electron pops up BrowserWindow, browser already opened above)
44+
// For SSH: this is the only way to get a terminal that works through PTY service
3545
void api?.terminal.openWindow({ workspaceId });
3646
} else {
37-
// In Electron (desktop) mode, open the native system terminal
47+
// In Electron (desktop) mode with local workspace, open the native system terminal
3848
// This spawns the user's preferred terminal emulator (Ghostty, Terminal.app, etc.)
3949
void api?.terminal.openNative({ workspaceId });
4050
}

src/browser/utils/commands/sources.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CommandIds } from "@/browser/utils/commandIds";
99
import type { ProjectConfig } from "@/node/config";
1010
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
1111
import type { BranchListResult } from "@/common/orpc/types";
12+
import type { RuntimeConfig } from "@/common/types/runtime";
1213

1314
export interface BuildSourcesParams {
1415
api: APIClient | null;
@@ -44,7 +45,7 @@ export interface BuildSourcesParams {
4445
onRemoveProject: (path: string) => void;
4546
onToggleSidebar: () => void;
4647
onNavigateWorkspace: (dir: "next" | "prev") => void;
47-
onOpenWorkspaceInTerminal: (workspaceId: string) => void;
48+
onOpenWorkspaceInTerminal: (workspaceId: string, runtimeConfig?: RuntimeConfig) => void;
4849
onToggleTheme: () => void;
4950
onSetTheme: (theme: ThemeMode) => void;
5051
onOpenSettings?: (section?: string) => void;
@@ -130,14 +131,15 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi
130131
// Remove current workspace (rename action intentionally omitted until we add a proper modal)
131132
if (selected?.namedWorkspacePath) {
132133
const workspaceDisplayName = `${selected.projectName}/${selected.namedWorkspacePath.split("/").pop() ?? selected.namedWorkspacePath}`;
134+
const selectedMeta = p.workspaceMetadata.get(selected.workspaceId);
133135
list.push({
134136
id: CommandIds.workspaceOpenTerminalCurrent(),
135137
title: "Open Current Workspace in Terminal",
136138
subtitle: workspaceDisplayName,
137139
section: section.workspaces,
138140
shortcutHint: formatKeybind(KEYBINDS.OPEN_TERMINAL),
139141
run: () => {
140-
p.onOpenWorkspaceInTerminal(selected.workspaceId);
142+
p.onOpenWorkspaceInTerminal(selected.workspaceId, selectedMeta?.runtimeConfig);
141143
},
142144
});
143145
list.push({
@@ -204,7 +206,8 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi
204206
},
205207
],
206208
onSubmit: (vals) => {
207-
p.onOpenWorkspaceInTerminal(vals.workspaceId);
209+
const meta = p.workspaceMetadata.get(vals.workspaceId);
210+
p.onOpenWorkspaceInTerminal(vals.workspaceId, meta?.runtimeConfig);
208211
},
209212
},
210213
});

0 commit comments

Comments
 (0)