Skip to content
Open
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
16 changes: 16 additions & 0 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ function AppInner() {
setWorkspaceMetadata,
removeWorkspace,
updateWorkspaceTitle,
updateWorkspaceRuntimeConfig,
refreshWorkspaceMetadata,
selectedWorkspace,
setSelectedWorkspace,
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -609,6 +624,7 @@ function AppInner() {
onSelectWorkspace: selectWorkspaceFromPalette,
onRemoveWorkspace: removeWorkspaceFromPalette,
onUpdateTitle: updateTitleFromPalette,
onChangeSSHHost: changeSSHHostFromPalette,
onAddProject: addProjectFromPalette,
onRemoveProject: removeProjectFromPalette,
onToggleSidebar: toggleSidebarFromPalette,
Expand Down
101 changes: 101 additions & 0 deletions src/browser/components/ChangeSSHHostDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<RuntimeConfig, { type: "ssh" }>;
}

export function ChangeSSHHostDialog(props: ChangeSSHHostDialogProps) {
const { updateWorkspaceRuntimeConfig } = useWorkspaceActions();
const [host, setHost] = useState(props.currentRuntimeConfig.host);
const [error, setError] = useState<string | null>(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 (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent maxWidth="380px" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Change SSH Host</DialogTitle>
</DialogHeader>
<form onSubmit={(e) => void handleSubmit(e)} className="flex flex-col gap-3">
<label className="flex flex-col gap-1">
<span className="text-foreground-secondary text-xs">SSH Host</span>
<Input
type="text"
value={host}
onChange={(e) => {
setHost(e.target.value);
setError(null);
}}
placeholder="user@hostname or SSH config alias"
autoFocus
disabled={saving}
/>
</label>
{error && <p className="text-error text-xs">{error}</p>}
<DialogFooter>
<Button
type="button"
variant="secondary"
size="sm"
disabled={saving}
onClick={() => props.onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" size="sm" disabled={saving || !host.trim()}>
{saving ? "Saving\u2026" : "Save"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
15 changes: 14 additions & 1 deletion src/browser/components/WorkspaceActionsMenuContent.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -70,6 +72,17 @@ export const WorkspaceActionsMenuContent: React.FC<WorkspaceActionsMenuContentPr
}}
/>
)}
{props.onChangeSSHHost && (
<WorkspaceActionButton
label="Change SSH host"
icon={<Globe className="h-3 w-3 shrink-0" />}
onClick={(e) => {
e.stopPropagation();
props.onCloseMenu();
props.onChangeSSHHost?.();
}}
/>
)}
{props.onConfigureMcp && (
<WorkspaceActionButton
label="Configure MCP servers"
Expand Down
15 changes: 15 additions & 0 deletions src/browser/components/WorkspaceListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { WORKSPACE_DRAG_TYPE, type WorkspaceDragItem } from "./WorkspaceSectionD
import { useLinkSharingEnabled } from "@/browser/contexts/TelemetryEnabledContext";
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
import { ShareTranscriptDialog } from "./ShareTranscriptDialog";
import { ChangeSSHHostDialog } from "./ChangeSSHHostDialog";
import { WorkspaceActionsMenuContent } from "./WorkspaceActionsMenuContent";
import { useAPI } from "@/browser/contexts/API";

Expand Down Expand Up @@ -290,6 +291,7 @@ function RegularWorkspaceListItemInner(props: WorkspaceListItemProps) {

const linkSharingEnabled = useLinkSharingEnabled();
const [shareTranscriptOpen, setShareTranscriptOpen] = useState(false);
const [sshHostDialogOpen, setSshHostDialogOpen] = useState(false);
const [isOverflowMenuPlaced, setIsOverflowMenuPlaced] = useState(false);

// Context menu via right-click / long-press. The hook manages position + long-press state.
Expand Down Expand Up @@ -562,6 +564,11 @@ function RegularWorkspaceListItemInner(props: WorkspaceListItemProps) {
>
<WorkspaceActionsMenuContent
onEditTitle={startEditing}
onChangeSSHHost={
metadata.runtimeConfig?.type === "ssh"
? () => setSshHostDialogOpen(true)
: null
}
onForkChat={(anchorEl) => {
void onForkWorkspace(workspaceId, anchorEl);
}}
Expand Down Expand Up @@ -604,6 +611,14 @@ function RegularWorkspaceListItemInner(props: WorkspaceListItemProps) {
onOpenChange={setShareTranscriptOpen}
/>
)}
{metadata.runtimeConfig?.type === "ssh" && (
<ChangeSSHHostDialog
open={sshHostDialogOpen}
onOpenChange={setSshHostDialogOpen}
workspaceId={workspaceId}
currentRuntimeConfig={metadata.runtimeConfig}
/>
)}
</ActionButtonWrapper>
)
)}
Expand Down
13 changes: 13 additions & 0 deletions src/browser/components/WorkspaceMenuBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -92,6 +93,7 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
const [invalidSkills, setInvalidSkills] = useState<AgentSkillIssue[]>([]);
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();
Expand Down Expand Up @@ -527,6 +529,9 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
>
{/* Keep MCP configuration in the more actions menu to keep the workspace menu bar lean. */}
<WorkspaceActionsMenuContent
onChangeSSHHost={
runtimeConfig?.type === "ssh" ? () => setSshHostDialogOpen(true) : null
}
onConfigureMcp={() => setMcpModalOpen(true)}
onOpenTouchFullscreenReview={
isTouchMobileScreen ? handleOpenTouchFullscreenReview : null
Expand Down Expand Up @@ -572,6 +577,14 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
onOpenChange={setShareTranscriptOpen}
/>
)}
{runtimeConfig?.type === "ssh" && (
<ChangeSSHHostDialog
open={sshHostDialogOpen}
onOpenChange={setSshHostDialogOpen}
workspaceId={workspaceId}
currentRuntimeConfig={runtimeConfig}
/>
)}
{/* Confirm archives that would interrupt an active stream. */}
<ConfirmationModal
isOpen={archiveConfirmOpen}
Expand Down
29 changes: 29 additions & 0 deletions src/browser/contexts/WorkspaceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,10 @@ export interface WorkspaceContext extends WorkspaceMetadataContextValue {
workspaceId: string,
newTitle: string
) => 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<void>;
Expand Down Expand Up @@ -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" };
Expand Down Expand Up @@ -1601,6 +1628,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
createWorkspace,
removeWorkspace,
updateWorkspaceTitle,
updateWorkspaceRuntimeConfig,
archiveWorkspace,
unarchiveWorkspace,
refreshWorkspaceMetadata,
Expand All @@ -1624,6 +1652,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
createWorkspace,
removeWorkspace,
updateWorkspaceTitle,
updateWorkspaceRuntimeConfig,
archiveWorkspace,
unarchiveWorkspace,
refreshWorkspaceMetadata,
Expand Down
1 change: 1 addition & 0 deletions src/browser/utils/commandIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/browser/utils/commands/sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const mk = (over: Partial<Parameters<typeof buildCoreSources>[0]> = {}) => {
onSelectWorkspace: () => undefined,
onRemoveWorkspace: () => Promise.resolve({ success: true }),
onUpdateTitle: () => Promise.resolve({ success: true }),
onChangeSSHHost: () => Promise.resolve({ success: true }),
onAddProject: () => undefined,
onRemoveProject: () => undefined,
onToggleSidebar: () => undefined,
Expand Down
54 changes: 54 additions & 0 deletions src/browser/utils/commands/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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…",
Expand Down
Loading
Loading