Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,8 +329,8 @@ const AIViewInner: React.FC<AIViewProps> = ({

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(() => {
Expand Down
4 changes: 2 additions & 2 deletions src/browser/components/WorkspaceHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ export const WorkspaceHeader: React.FC<WorkspaceHeaderProps> = ({
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(() => {
Expand Down
40 changes: 25 additions & 15 deletions src/browser/hooks/useOpenTerminal.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
Expand Down
9 changes: 6 additions & 3 deletions src/browser/utils/commands/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -130,14 +131,15 @@ 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",
subtitle: workspaceDisplayName,
section: section.workspaces,
shortcutHint: formatKeybind(KEYBINDS.OPEN_TERMINAL),
run: () => {
p.onOpenWorkspaceInTerminal(selected.workspaceId);
p.onOpenWorkspaceInTerminal(selected.workspaceId, selectedMeta?.runtimeConfig);
},
});
list.push({
Expand Down Expand Up @@ -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);
},
},
});
Expand Down