feat(site): add Open Terminal and Copy SSH Command to agent chat TopBar (#22529)

Adds two new items to the agent chat TopBar dropdown menu:

- **Open Terminal**: opens the workspace web terminal in a new browser
window, reusing the existing `getTerminalHref`/`openAppInNewWindow`
infra.
- **Copy SSH Command**: copies the SSH command (e.g. `ssh
agent.workspace.owner.suffix`) to the clipboard with a toast
confirmation. Only shown when the deployment SSH hostname suffix is
configured.

Both items appear after a separator below the existing editor/workspace
actions.

## Changes

| File | What |
|---|---|
| `TopBar.tsx` | Added `Open Terminal` and `Copy SSH Command` dropdown
items with separator, `TerminalIcon`/`CopyIcon`, toast on copy |
| `AgentDetail.tsx` | Wired up `getTerminalHref`, `openAppInNewWindow`,
`deploymentSSHConfig` query, and passed new props to TopBar in all 3
render paths |
| `TopBar.stories.tsx` | Added new fields to default story props |
This commit is contained in:
Cian Johnston
2026-03-03 11:44:39 +00:00
committed by GitHub
parent 2203b259e6
commit 3c4a416b55
3 changed files with 66 additions and 1 deletions
+33 -1
View File
@@ -11,12 +11,18 @@ import {
interruptChat,
promoteChatQueuedMessage,
} from "api/queries/chats";
import { deploymentSSHConfig } from "api/queries/deployment";
import { workspaceById } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { Skeleton } from "components/Skeleton/Skeleton";
import { ArchiveIcon } from "lucide-react";
import { getVSCodeHref, SESSION_TOKEN_PLACEHOLDER } from "modules/apps/apps";
import {
getTerminalHref,
getVSCodeHref,
openAppInNewWindow,
SESSION_TOKEN_PLACEHOLDER,
} from "modules/apps/apps";
import {
type FC,
useCallback,
@@ -515,6 +521,7 @@ const AgentDetail: FC = () => {
});
const chatModelsQuery = useQuery(chatModels());
const chatModelConfigsQuery = useQuery(chatModelConfigs());
const sshConfigQuery = useQuery(deploymentSSHConfig());
const hasDiffStatus = Boolean(diffStatusQuery.data?.url);
const workspace = workspaceQuery.data;
const workspaceAgent = getWorkspaceAgent(workspace, undefined);
@@ -818,6 +825,18 @@ const AgentDetail: FC = () => {
: null;
const canOpenWorkspace = Boolean(workspaceRoute);
const canOpenEditors = Boolean(workspace && workspaceAgent);
const terminalHref =
workspace && workspaceAgent
? getTerminalHref({
username: workspace.owner_name,
workspace: workspace.name,
agent: workspaceAgent.name,
})
: null;
const sshCommand =
workspace && workspaceAgent && sshConfigQuery.data?.hostname_suffix
? `ssh ${workspaceAgent.name}.${workspace.name}.${workspace.owner_name}.${sshConfigQuery.data.hostname_suffix}`
: undefined;
const shouldShowDiffPanel = hasDiffStatus && showDiffPanel;
const handleOpenInEditor = async (editor: "cursor" | "vscode") => {
@@ -868,6 +887,13 @@ const AgentDetail: FC = () => {
navigate(workspaceRoute);
};
const handleOpenTerminal = () => {
if (!terminalHref) {
return;
}
openAppInNewWindow(terminalHref);
};
const handleArchiveAgentAction = () => {
if (!agentId || isArchived) {
return;
@@ -897,6 +923,8 @@ const AgentDetail: FC = () => {
canOpenWorkspace: false,
onOpenInEditor: () => {},
onViewWorkspace: () => {},
onOpenTerminal: () => {},
sshCommand: undefined,
}}
onOpenParentChat={() => {}}
onArchiveAgent={() => {}}
@@ -968,6 +996,8 @@ const AgentDetail: FC = () => {
canOpenWorkspace: false,
onOpenInEditor: () => {},
onViewWorkspace: () => {},
onOpenTerminal: () => {},
sshCommand: undefined,
}}
onOpenParentChat={() => {}}
onArchiveAgent={() => {}}
@@ -1009,6 +1039,8 @@ const AgentDetail: FC = () => {
void handleOpenInEditor(editor);
},
onViewWorkspace: handleViewWorkspace,
onOpenTerminal: handleOpenTerminal,
sshCommand,
}}
onArchiveAgent={handleArchiveAgentAction}
onArchiveAndDeleteWorkspace={handleArchiveAndDeleteWorkspaceAction}
@@ -26,6 +26,8 @@ const defaultProps = {
canOpenWorkspace: true,
onOpenInEditor: () => {},
onViewWorkspace: () => {},
onOpenTerminal: () => {},
sshCommand: "ssh main.my-workspace.admin.coder",
},
onArchiveAgent: () => {},
onArchiveAndDeleteWorkspace: () => {},
@@ -5,6 +5,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { useAuthenticated } from "hooks";
@@ -12,18 +13,21 @@ import {
ArchiveIcon,
ArrowLeftIcon,
ChevronRightIcon,
CopyIcon,
EllipsisIcon,
ExternalLinkIcon,
MonitorIcon,
PanelLeftIcon,
PanelRightCloseIcon,
PanelRightOpenIcon,
TerminalIcon,
Trash2Icon,
} from "lucide-react";
import { UserDropdown } from "modules/dashboard/Navbar/UserDropdown/UserDropdown";
import { useDashboard } from "modules/dashboard/useDashboard";
import type { FC } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { WebPushButton } from "../WebPushButton";
interface DiffStatsBadgeProps {
@@ -73,6 +77,8 @@ interface WorkspaceActions {
canOpenWorkspace: boolean;
onOpenInEditor: (editor: "cursor" | "vscode") => void;
onViewWorkspace: () => void;
onOpenTerminal: () => void;
sshCommand: string | undefined;
}
type AgentDetailTopBarProps = {
@@ -197,6 +203,30 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
<ExternalLinkIcon className="h-3.5 w-3.5" />
Open in VS Code
</DropdownMenuItem>
<DropdownMenuItem
// You can think of the web terminal as an editor if you squint.
disabled={!workspace.canOpenEditors}
onSelect={workspace.onOpenTerminal}
>
<TerminalIcon className="h-3.5 w-3.5" />
Open Terminal
</DropdownMenuItem>
<DropdownMenuItem
disabled={!workspace.sshCommand}
onSelect={async () => {
if (!workspace.sshCommand) return;
try {
await navigator.clipboard.writeText(workspace.sshCommand);
toast.success("SSH command copied to clipboard");
} catch {
toast.error("Failed to copy SSH command");
}
}}
>
<CopyIcon className="h-3.5 w-3.5" />
Copy SSH Command
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={!workspace.canOpenWorkspace}
onSelect={workspace.onViewWorkspace}
@@ -204,6 +234,7 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
<MonitorIcon className="h-3.5 w-3.5" />
View Workspace
</DropdownMenuItem>
<DropdownMenuSeparator />
{!isArchived && (
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"