diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 5598a50217..1aa7384e42 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -96,6 +96,7 @@ function AppInner() { setWorkspaceMetadata, removeWorkspace, updateWorkspaceTitle, + updateWorkspaceRuntimeConfig, refreshWorkspaceMetadata, selectedWorkspace, setSelectedWorkspace, @@ -574,6 +575,20 @@ function AppInner() { [updateWorkspaceTitle] ); + const changeSSHHostFromPalette = useCallback( + async (workspaceId: string, newHost: string) => { + const meta = workspaceMetadata.get(workspaceId); + if (meta?.runtimeConfig?.type !== "ssh") { + return { success: false, error: "Workspace is not an SSH workspace" }; + } + return updateWorkspaceRuntimeConfig(workspaceId, { + ...meta.runtimeConfig, + host: newHost, + }); + }, + [workspaceMetadata, updateWorkspaceRuntimeConfig] + ); + const addProjectFromPalette = useCallback(() => { openProjectCreateModal(); }, [openProjectCreateModal]); @@ -609,6 +624,7 @@ function AppInner() { onSelectWorkspace: selectWorkspaceFromPalette, onRemoveWorkspace: removeWorkspaceFromPalette, onUpdateTitle: updateTitleFromPalette, + onChangeSSHHost: changeSSHHostFromPalette, onAddProject: addProjectFromPalette, onRemoveProject: removeProjectFromPalette, onToggleSidebar: toggleSidebarFromPalette, diff --git a/src/browser/components/ChangeSSHHostDialog.tsx b/src/browser/components/ChangeSSHHostDialog.tsx new file mode 100644 index 0000000000..efe71cd97b --- /dev/null +++ b/src/browser/components/ChangeSSHHostDialog.tsx @@ -0,0 +1,101 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/browser/components/ui/dialog"; +import { Button } from "@/browser/components/ui/button"; +import { Input } from "@/browser/components/ui/input"; +import type { RuntimeConfig } from "@/common/types/runtime"; +import { useWorkspaceActions } from "@/browser/contexts/WorkspaceContext"; + +interface ChangeSSHHostDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceId: string; + currentRuntimeConfig: Extract; +} + +export function ChangeSSHHostDialog(props: ChangeSSHHostDialogProps) { + const { updateWorkspaceRuntimeConfig } = useWorkspaceActions(); + const [host, setHost] = useState(props.currentRuntimeConfig.host); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + // Reset when dialog opens with new config + useEffect(() => { + if (props.open) { + setHost(props.currentRuntimeConfig.host); + setError(null); + setSaving(false); + } + }, [props.open, props.currentRuntimeConfig.host]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmedHost = host.trim(); + if (!trimmedHost) { + setError("Host is required"); + return; + } + if (trimmedHost === props.currentRuntimeConfig.host) { + props.onOpenChange(false); + return; + } + setSaving(true); + setError(null); + const result = await updateWorkspaceRuntimeConfig(props.workspaceId, { + ...props.currentRuntimeConfig, + host: trimmedHost, + }); + setSaving(false); + if (result.success) { + props.onOpenChange(false); + } else { + setError(result.error ?? "Failed to update SSH host"); + } + }; + + return ( + + + + Change SSH Host + +
void handleSubmit(e)} className="flex flex-col gap-3"> + + {error &&

{error}

} + + + + +
+
+
+ ); +} diff --git a/src/browser/components/WorkspaceActionsMenuContent.tsx b/src/browser/components/WorkspaceActionsMenuContent.tsx index edd83a29b0..284bc4ac83 100644 --- a/src/browser/components/WorkspaceActionsMenuContent.tsx +++ b/src/browser/components/WorkspaceActionsMenuContent.tsx @@ -1,6 +1,6 @@ import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { ArchiveIcon } from "./icons/ArchiveIcon"; -import { GitBranch, Link2, Maximize2, Pencil, Server } from "lucide-react"; +import { GitBranch, Globe, Link2, Maximize2, Pencil, Server } from "lucide-react"; import React from "react"; interface WorkspaceActionButtonProps { @@ -36,6 +36,8 @@ function WorkspaceActionButton(props: WorkspaceActionButtonProps) { interface WorkspaceActionsMenuContentProps { /** Workspace title actions only make sense in the left sidebar where title text is visible. */ onEditTitle?: (() => void) | null; + /** Change the SSH host for an SSH workspace. Only shown for SSH workspaces. */ + onChangeSSHHost?: (() => void) | null; /** Workspace-level settings action currently surfaced from the workspace menu bar. */ onConfigureMcp?: (() => void) | null; /** Mobile workspace-header action: open immersive review in full-screen touch mode. */ @@ -70,6 +72,17 @@ export const WorkspaceActionsMenuContent: React.FC )} + {props.onChangeSSHHost && ( + } + onClick={(e) => { + e.stopPropagation(); + props.onCloseMenu(); + props.onChangeSSHHost?.(); + }} + /> + )} {props.onConfigureMcp && ( setSshHostDialogOpen(true) + : null + } onForkChat={(anchorEl) => { void onForkWorkspace(workspaceId, anchorEl); }} @@ -604,6 +611,14 @@ function RegularWorkspaceListItemInner(props: WorkspaceListItemProps) { onOpenChange={setShareTranscriptOpen} /> )} + {metadata.runtimeConfig?.type === "ssh" && ( + + )} ) )} diff --git a/src/browser/components/WorkspaceMenuBar.tsx b/src/browser/components/WorkspaceMenuBar.tsx index 276aa648a4..12b3dda26b 100644 --- a/src/browser/components/WorkspaceMenuBar.tsx +++ b/src/browser/components/WorkspaceMenuBar.tsx @@ -34,6 +34,7 @@ import { isDesktopMode, DESKTOP_TITLEBAR_HEIGHT_CLASS } from "@/browser/hooks/us import { DebugLlmRequestModal } from "./DebugLlmRequestModal"; import { WorkspaceLinks } from "./WorkspaceLinks"; import { ShareTranscriptDialog } from "./ShareTranscriptDialog"; +import { ChangeSSHHostDialog } from "./ChangeSSHHostDialog"; import { ConfirmationModal } from "./ConfirmationModal"; import { PopoverError } from "./PopoverError"; import { WorkspaceActionsMenuContent } from "./WorkspaceActionsMenuContent"; @@ -92,6 +93,7 @@ export const WorkspaceMenuBar: React.FC = ({ const [invalidSkills, setInvalidSkills] = useState([]); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [shareTranscriptOpen, setShareTranscriptOpen] = useState(false); + const [sshHostDialogOpen, setSshHostDialogOpen] = useState(false); const [archiveConfirmOpen, setArchiveConfirmOpen] = useState(false); const [isArchiving, setIsArchiving] = useState(false); const archiveError = usePopoverError(); @@ -527,6 +529,9 @@ export const WorkspaceMenuBar: React.FC = ({ > {/* Keep MCP configuration in the more actions menu to keep the workspace menu bar lean. */} setSshHostDialogOpen(true) : null + } onConfigureMcp={() => setMcpModalOpen(true)} onOpenTouchFullscreenReview={ isTouchMobileScreen ? handleOpenTouchFullscreenReview : null @@ -572,6 +577,14 @@ export const WorkspaceMenuBar: React.FC = ({ onOpenChange={setShareTranscriptOpen} /> )} + {runtimeConfig?.type === "ssh" && ( + + )} {/* Confirm archives that would interrupt an active stream. */} Promise<{ success: boolean; error?: string }>; + updateWorkspaceRuntimeConfig: ( + workspaceId: string, + runtimeConfig: RuntimeConfig + ) => Promise<{ success: boolean; error?: string }>; archiveWorkspace: (workspaceId: string) => Promise<{ success: boolean; error?: string }>; unarchiveWorkspace: (workspaceId: string) => Promise<{ success: boolean; error?: string }>; refreshWorkspaceMetadata: () => Promise; @@ -1328,6 +1332,29 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { [api] ); + const updateWorkspaceRuntimeConfig = useCallback( + async ( + workspaceId: string, + runtimeConfig: RuntimeConfig + ): Promise<{ success: boolean; error?: string }> => { + if (!api) return { success: false, error: "API not connected" }; + try { + const result = await api.workspace.updateRuntimeConfig({ workspaceId, runtimeConfig }); + if (result.success) { + return { success: true }; + } else { + console.error("Failed to update runtime config:", result.error); + return { success: false, error: result.error }; + } + } catch (error) { + const errorMessage = getErrorMessage(error); + console.error("Failed to update runtime config:", errorMessage); + return { success: false, error: errorMessage }; + } + }, + [api] + ); + const archiveWorkspace = useCallback( async (workspaceId: string): Promise<{ success: boolean; error?: string }> => { if (!api) return { success: false, error: "API not connected" }; @@ -1601,6 +1628,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { createWorkspace, removeWorkspace, updateWorkspaceTitle, + updateWorkspaceRuntimeConfig, archiveWorkspace, unarchiveWorkspace, refreshWorkspaceMetadata, @@ -1624,6 +1652,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { createWorkspace, removeWorkspace, updateWorkspaceTitle, + updateWorkspaceRuntimeConfig, archiveWorkspace, unarchiveWorkspace, refreshWorkspaceMetadata, diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index 12c9cee3b0..70ca9efbb9 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -26,6 +26,7 @@ export const CommandIds = { workspaceRemoveAny: () => "ws:remove-any" as const, workspaceEditTitle: () => "ws:edit-title" as const, workspaceEditTitleAny: () => "ws:edit-title-any" as const, + workspaceChangeSSHHost: () => "ws:change-ssh-host" as const, workspaceGenerateTitle: () => "ws:generate-title" as const, workspaceOpenTerminal: () => "ws:open-terminal" as const, workspaceOpenTerminalCurrent: () => "ws:open-terminal-current" as const, diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index e4e68e2cc7..ac448ee3be 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -49,6 +49,7 @@ const mk = (over: Partial[0]> = {}) => { onSelectWorkspace: () => undefined, onRemoveWorkspace: () => Promise.resolve({ success: true }), onUpdateTitle: () => Promise.resolve({ success: true }), + onChangeSSHHost: () => Promise.resolve({ success: true }), onAddProject: () => undefined, onRemoveProject: () => undefined, onToggleSidebar: () => undefined, diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 2a10f3d6da..0d40dddf22 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -74,6 +74,10 @@ export interface BuildSourcesParams { workspaceId: string, newName: string ) => Promise<{ success: boolean; error?: string }>; + onChangeSSHHost: ( + workspaceId: string, + newHost: string + ) => Promise<{ success: boolean; error?: string }>; onAddProject: () => void; onRemoveProject: (path: string) => void; onToggleSidebar: () => void; @@ -451,6 +455,56 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi }, }, }); + + // Change SSH host (only visible when SSH workspaces exist) + const sshWorkspaces = Array.from(p.workspaceMetadata.values()).filter( + (m) => m.runtimeConfig?.type === "ssh" + ); + if (sshWorkspaces.length > 0) { + list.push({ + id: CommandIds.workspaceChangeSSHHost(), + title: "Change SSH Host…", + section: section.workspaces, + run: () => undefined, + prompt: { + title: "Change SSH Host", + fields: [ + { + type: "select", + name: "workspaceId", + label: "Select workspace", + placeholder: "Search SSH workspaces…", + getOptions: () => + sshWorkspaces.map((meta) => ({ + id: meta.id, + label: `${meta.projectName} / ${meta.title ?? meta.name}`, + keywords: [ + meta.name, + meta.projectName, + meta.namedWorkspacePath, + meta.id, + meta.title, + ].filter((k): k is string => !!k), + })), + }, + { + type: "text", + name: "newHost", + label: "New SSH host", + getInitialValue: (values) => { + const meta = sshWorkspaces.find((m) => m.id === values.workspaceId); + return meta?.runtimeConfig?.type === "ssh" ? meta.runtimeConfig.host : ""; + }, + validate: (v) => (!v.trim() ? "Host is required" : null), + }, + ], + onSubmit: async (vals) => { + await p.onChangeSSHHost(vals.workspaceId, vals.newHost.trim()); + }, + }, + }); + } + list.push({ id: CommandIds.workspaceRemoveAny(), title: "Remove Workspace…", diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 5c94273f09..cefccd9701 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -856,6 +856,10 @@ export const workspace = { input: z.object({ workspaceId: z.string(), title: z.string() }), output: ResultSchema(z.void(), z.string()), }, + updateRuntimeConfig: { + input: z.object({ workspaceId: z.string(), runtimeConfig: RuntimeConfigSchema }), + output: ResultSchema(z.void(), z.string()), + }, regenerateTitle: { input: z.object({ workspaceId: z.string() }), output: ResultSchema(z.object({ title: z.string() }), z.string()), diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 2de92313b7..05ef44beca 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -2591,6 +2591,15 @@ export const router = (authToken?: string) => { .handler(async ({ context, input }) => { return context.workspaceService.updateTitle(input.workspaceId, input.title); }), + updateRuntimeConfig: t + .input(schemas.workspace.updateRuntimeConfig.input) + .output(schemas.workspace.updateRuntimeConfig.output) + .handler(async ({ context, input }) => { + return context.workspaceService.updateRuntimeConfig( + input.workspaceId, + input.runtimeConfig + ); + }), regenerateTitle: t .input(schemas.workspace.regenerateTitle.input) .output(schemas.workspace.regenerateTitle.output) diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 2d69f4ef96..7cc4cd397c 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2291,6 +2291,52 @@ export class WorkspaceService extends EventEmitter { } } + async updateRuntimeConfig( + workspaceId: string, + runtimeConfig: RuntimeConfig + ): Promise> { + try { + const workspace = this.config.findWorkspace(workspaceId); + if (!workspace) { + return Err("Workspace not found"); + } + const { projectPath, workspacePath } = workspace; + + // Use editConfig with path-fallback (same pattern as updateTitle) so + // legacy workspaces that don't yet have w.id in config are still found. + await this.config.editConfig((config) => { + const projectConfig = config.projects.get(projectPath); + if (projectConfig) { + const workspaceEntry = + projectConfig.workspaces.find((w) => w.id === workspaceId) ?? + projectConfig.workspaces.find((w) => w.path === workspacePath); + if (workspaceEntry) { + workspaceEntry.runtimeConfig = runtimeConfig; + } + } + return config; + }); + + // Emit updated metadata (same pattern as updateTitle) + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const updatedMetadata = allMetadata.find((m) => m.id === workspaceId); + if (updatedMetadata) { + const enrichedMetadata = this.enrichFrontendMetadata(updatedMetadata); + const session = this.sessions.get(workspaceId); + if (session) { + session.emitMetadata(enrichedMetadata); + } else { + this.emit("metadata", { workspaceId, metadata: enrichedMetadata }); + } + } + + return Ok(undefined); + } catch (error) { + const message = getErrorMessage(error); + return Err(`Failed to update runtime config: ${message}`); + } + } + /** * Regenerate the workspace title from chat history using AI. * Uses the first user message as the durable objective, plus a context block with