mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(site): expose workspace apps in chat workspace pill (#24295)
This commit is contained in:
@@ -640,11 +640,13 @@ export const CompletedWithDiffPanel: Story = {
|
||||
// Verify menu items are rendered.
|
||||
const body = within(document.body);
|
||||
await waitFor(() => {
|
||||
expect(body.getByText("Open in Cursor")).toBeInTheDocument();
|
||||
expect(body.getByText("Archive Agent")).toBeInTheDocument();
|
||||
});
|
||||
expect(body.getByText("Open in VS Code")).toBeInTheDocument();
|
||||
expect(body.getByText("View Workspace")).toBeInTheDocument();
|
||||
expect(body.getByText("Archive Agent")).toBeInTheDocument();
|
||||
// Workspace items moved to the workspace pill popover.
|
||||
expect(body.queryByText("Open in Cursor")).not.toBeInTheDocument();
|
||||
expect(body.queryByText("Open in VS Code")).not.toBeInTheDocument();
|
||||
expect(body.queryByText("View Workspace")).not.toBeInTheDocument();
|
||||
expect(body.queryByText("Copy SSH Command")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -7,9 +7,8 @@ import {
|
||||
useQueryClient,
|
||||
} from "react-query";
|
||||
import { useOutletContext, useParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import type { UrlTransform } from "streamdown";
|
||||
import { API, watchWorkspace } from "#/api/api";
|
||||
import { watchWorkspace } from "#/api/api";
|
||||
import { isApiError } from "#/api/errors";
|
||||
import { buildOptimisticEditedMessage } from "#/api/queries/chatMessageEdits";
|
||||
import {
|
||||
@@ -31,11 +30,6 @@ import { workspaceById, workspaceByIdKey } from "#/api/queries/workspaces";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import type { ChatMessagePart } from "#/api/typesGenerated";
|
||||
import { useProxy } from "#/contexts/ProxyContext";
|
||||
import {
|
||||
getTerminalHref,
|
||||
getVSCodeHref,
|
||||
openAppInNewWindow,
|
||||
} from "#/modules/apps/apps";
|
||||
import { isMobileViewport } from "#/utils/mobile";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { rewriteLocalhostURL } from "#/utils/portForward";
|
||||
@@ -1069,75 +1063,11 @@ const AgentChatPage: FC = () => {
|
||||
);
|
||||
|
||||
const parentChat = parentChatQuery.data;
|
||||
const workspaceRoute = workspace
|
||||
? `/@${workspace.owner_name}/${workspace.name}`
|
||||
: 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;
|
||||
|
||||
// See mutation destructuring comment above (React Compiler).
|
||||
const { mutate: generateKey } = useMutation({
|
||||
mutationFn: () => API.getApiKey(),
|
||||
});
|
||||
|
||||
const handleOpenInEditor = (editor: "cursor" | "vscode") => {
|
||||
if (!workspace || !workspaceAgent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer the active git repo root so VS Code opens to the
|
||||
// actual project directory, falling back to the agent's
|
||||
// configured directory.
|
||||
const repoRoots = Array.from(gitWatcher.repositories.keys()).sort();
|
||||
const folder = repoRoots[0] ?? workspaceAgent.expanded_directory;
|
||||
|
||||
generateKey(undefined, {
|
||||
onSuccess: ({ key }) => {
|
||||
location.href = getVSCodeHref(editor, {
|
||||
owner: workspace.owner_name,
|
||||
workspace: workspace.name,
|
||||
token: key,
|
||||
agent: workspaceAgent.name,
|
||||
folder,
|
||||
chatId: agentId,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
editor === "cursor"
|
||||
? "Failed to open in Cursor."
|
||||
: "Failed to open in VS Code.",
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleViewWorkspace = () => {
|
||||
if (!workspaceRoute) {
|
||||
return;
|
||||
}
|
||||
window.open(workspaceRoute, "_blank");
|
||||
};
|
||||
|
||||
const handleOpenTerminal = () => {
|
||||
if (!terminalHref) {
|
||||
return;
|
||||
}
|
||||
openAppInNewWindow(terminalHref);
|
||||
};
|
||||
|
||||
const handleArchiveAgentAction = () => {
|
||||
if (!agentId || isArchived) {
|
||||
return;
|
||||
@@ -1261,12 +1191,7 @@ const AgentChatPage: FC = () => {
|
||||
prNumber={prNumber}
|
||||
diffStatusData={chatQuery.data?.diff_status}
|
||||
gitWatcher={gitWatcher}
|
||||
canOpenEditors={canOpenEditors}
|
||||
canOpenWorkspace={canOpenWorkspace}
|
||||
sshCommand={sshCommand}
|
||||
handleOpenInEditor={handleOpenInEditor}
|
||||
handleViewWorkspace={handleViewWorkspace}
|
||||
handleOpenTerminal={handleOpenTerminal}
|
||||
handleCommit={handleCommit}
|
||||
handleInterrupt={handleInterrupt}
|
||||
handleDeleteQueuedMessage={handleDeleteQueuedMessage}
|
||||
|
||||
@@ -137,12 +137,7 @@ const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
|
||||
typeof AgentChatPageView
|
||||
>["diffStatusData"],
|
||||
gitWatcher: buildGitWatcher(),
|
||||
canOpenEditors: false,
|
||||
canOpenWorkspace: false,
|
||||
sshCommand: undefined as string | undefined,
|
||||
handleOpenInEditor: fn(),
|
||||
handleViewWorkspace: fn(),
|
||||
handleOpenTerminal: fn(),
|
||||
handleCommit: fn(),
|
||||
handleInterrupt: fn(),
|
||||
handleDeleteQueuedMessage: fn(),
|
||||
@@ -367,12 +362,11 @@ export const NoModelOptions: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
/** Top bar has workspace action buttons visible. */
|
||||
export const WithWorkspaceActions: Story = {
|
||||
export const WithWorkspace: Story = {
|
||||
render: () => (
|
||||
<StoryAgentChatPageView
|
||||
canOpenEditors
|
||||
canOpenWorkspace
|
||||
workspace={MockWorkspace}
|
||||
workspaceAgent={MockWorkspaceAgent}
|
||||
sshCommand="ssh coder.workspace"
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
ArchiveIcon,
|
||||
MonitorDotIcon,
|
||||
MonitorIcon,
|
||||
MonitorPauseIcon,
|
||||
MonitorXIcon,
|
||||
} from "lucide-react";
|
||||
import { ArchiveIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
type FC,
|
||||
@@ -20,11 +14,6 @@ import type * as TypesGen from "#/api/typesGenerated";
|
||||
import type { ChatDiffStatus, ChatMessagePart } from "#/api/typesGenerated";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import {
|
||||
type DisplayWorkspaceStatusType,
|
||||
getDisplayWorkspaceStatus,
|
||||
} from "#/utils/workspace";
|
||||
|
||||
import {
|
||||
AgentChatInput,
|
||||
type ChatMessageInputRef,
|
||||
@@ -43,6 +32,7 @@ import { ChatTopBar } from "./components/ChatTopBar";
|
||||
import { GitPanel } from "./components/GitPanel/GitPanel";
|
||||
import { RightPanel } from "./components/RightPanel/RightPanel";
|
||||
import { SidebarTabView } from "./components/Sidebar/SidebarTabView";
|
||||
import { getWorkspaceStatus, StatusIcon } from "./components/StatusIcon";
|
||||
import { TerminalPanel } from "./components/TerminalPanel";
|
||||
import { ChatWorkspaceContext } from "./context/ChatWorkspaceContext";
|
||||
import { chatWidthClass, useChatFullWidth } from "./hooks/useChatFullWidth";
|
||||
@@ -132,12 +122,7 @@ interface AgentChatPageViewProps {
|
||||
};
|
||||
|
||||
// Workspace action handlers.
|
||||
canOpenEditors: boolean;
|
||||
canOpenWorkspace: boolean;
|
||||
sshCommand: string | undefined;
|
||||
handleOpenInEditor: (editor: "cursor" | "vscode") => void;
|
||||
handleViewWorkspace: () => void;
|
||||
handleOpenTerminal: () => void;
|
||||
handleCommit: (repoRoot: string) => void;
|
||||
|
||||
// Chat action handlers.
|
||||
@@ -206,12 +191,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
prNumber,
|
||||
diffStatusData,
|
||||
gitWatcher,
|
||||
canOpenEditors,
|
||||
canOpenWorkspace,
|
||||
sshCommand,
|
||||
handleOpenInEditor,
|
||||
handleViewWorkspace,
|
||||
handleOpenTerminal,
|
||||
handleCommit,
|
||||
handleInterrupt,
|
||||
handleDeleteQueuedMessage,
|
||||
@@ -275,48 +255,29 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
|
||||
// Compute local diff stats from git watcher unified diffs.
|
||||
|
||||
// Prefer the git repository root over the agent's expanded directory
|
||||
// for VS Code folder resolution (important for monorepos).
|
||||
const preferredFolder = (() => {
|
||||
const repoRoots = Array.from(gitWatcher?.repositories.keys() ?? []).sort();
|
||||
return repoRoots[0] || workspaceAgent?.expanded_directory;
|
||||
})();
|
||||
|
||||
const workspaceRoute = workspace
|
||||
? `/@${workspace.owner_name}/${workspace.name}`
|
||||
: undefined;
|
||||
|
||||
const attachedWorkspace = (() => {
|
||||
if (!workspace || !workspaceRoute) return undefined;
|
||||
let { type, text } = getDisplayWorkspaceStatus(
|
||||
workspace.latest_build.status,
|
||||
workspace.latest_build.job,
|
||||
|
||||
const { effectiveType, statusLabel } = getWorkspaceStatus(
|
||||
workspace,
|
||||
workspaceAgent,
|
||||
);
|
||||
const agentPreparing =
|
||||
workspace.latest_build.status === "running" &&
|
||||
(workspaceAgent?.lifecycle_state === "created" ||
|
||||
workspaceAgent?.lifecycle_state === "starting");
|
||||
const agentStartupFailed =
|
||||
workspace.latest_build.status === "running" &&
|
||||
(workspaceAgent?.lifecycle_state === "start_error" ||
|
||||
workspaceAgent?.lifecycle_state === "start_timeout");
|
||||
if (agentPreparing) {
|
||||
type = "active";
|
||||
text = "Preparing";
|
||||
} else if (agentStartupFailed) {
|
||||
type = "warning";
|
||||
text = "Startup failed";
|
||||
}
|
||||
const effectiveType = workspace.health.healthy ? type : "warning";
|
||||
const statusLabel = workspace.health.healthy
|
||||
? `Workspace ${text.toLowerCase()}`
|
||||
: `Workspace ${text.toLowerCase()} (unhealthy)`;
|
||||
const iconCls = "size-3";
|
||||
const statusIconMap: Record<DisplayWorkspaceStatusType, React.ReactNode> = {
|
||||
success: <MonitorIcon className={iconCls} />,
|
||||
active: <MonitorDotIcon className={iconCls} />,
|
||||
inactive: <MonitorPauseIcon className={iconCls} />,
|
||||
error: <MonitorXIcon className={iconCls} />,
|
||||
danger: <MonitorXIcon className={iconCls} />,
|
||||
warning: <MonitorXIcon className={iconCls} />,
|
||||
};
|
||||
const statusIcon = <StatusIcon type={effectiveType} />;
|
||||
return {
|
||||
name: workspace.name,
|
||||
route: workspaceRoute,
|
||||
statusIcon: statusIconMap[effectiveType],
|
||||
statusIcon,
|
||||
statusLabel,
|
||||
};
|
||||
})();
|
||||
@@ -357,14 +318,6 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
showSidebarPanel,
|
||||
onToggleSidebar: () => onSetShowSidebarPanel((prev) => !prev),
|
||||
}}
|
||||
workspace={{
|
||||
canOpenEditors,
|
||||
canOpenWorkspace,
|
||||
onOpenInEditor: handleOpenInEditor,
|
||||
onViewWorkspace: handleViewWorkspace,
|
||||
onOpenTerminal: handleOpenTerminal,
|
||||
sshCommand,
|
||||
}}
|
||||
onArchiveAgent={handleArchiveAgentAction}
|
||||
onUnarchiveAgent={handleUnarchiveAgentAction}
|
||||
onArchiveAndDeleteWorkspace={
|
||||
@@ -454,7 +407,12 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
onMCPSelectionChange={onMCPSelectionChange}
|
||||
onMCPAuthComplete={onMCPAuthComplete}
|
||||
lastInjectedContext={lastInjectedContext}
|
||||
workspace={workspace}
|
||||
workspaceAgent={workspaceAgent}
|
||||
chatId={agentId}
|
||||
sshCommand={sshCommand}
|
||||
attachedWorkspace={attachedWorkspace}
|
||||
folder={preferredFolder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -568,14 +526,6 @@ export const AgentChatPageLoadingView: FC<AgentChatPageLoadingViewProps> = ({
|
||||
showSidebarPanel: false,
|
||||
onToggleSidebar: () => {},
|
||||
}}
|
||||
workspace={{
|
||||
canOpenEditors: false,
|
||||
canOpenWorkspace: false,
|
||||
onOpenInEditor: () => {},
|
||||
onViewWorkspace: () => {},
|
||||
onOpenTerminal: () => {},
|
||||
sshCommand: undefined,
|
||||
}}
|
||||
onArchiveAgent={() => {}}
|
||||
onUnarchiveAgent={() => {}}
|
||||
onRegenerateTitle={() => {}}
|
||||
@@ -646,14 +596,6 @@ export const AgentChatPageNotFoundView: FC<AgentChatPageNotFoundViewProps> = ({
|
||||
showSidebarPanel: false,
|
||||
onToggleSidebar: () => {},
|
||||
}}
|
||||
workspace={{
|
||||
canOpenEditors: false,
|
||||
canOpenWorkspace: false,
|
||||
onOpenInEditor: () => {},
|
||||
onViewWorkspace: () => {},
|
||||
onOpenTerminal: () => {},
|
||||
sshCommand: undefined,
|
||||
}}
|
||||
onArchiveAgent={() => {}}
|
||||
onUnarchiveAgent={() => {}}
|
||||
onRegenerateTitle={() => {}}
|
||||
|
||||
@@ -67,6 +67,7 @@ import { ContextUsageIndicator } from "./ContextUsageIndicator";
|
||||
import { ImageLightbox } from "./ImageLightbox";
|
||||
import { QueuedMessagesList } from "./QueuedMessagesList";
|
||||
import { TextPreviewDialog } from "./TextPreviewDialog";
|
||||
import { WorkspacePill } from "./WorkspacePill";
|
||||
|
||||
export {
|
||||
ImageThumbnail,
|
||||
@@ -148,7 +149,12 @@ interface AgentChatInputProps {
|
||||
selectedMCPServerIds?: readonly string[];
|
||||
onMCPSelectionChange?: (ids: string[]) => void;
|
||||
onMCPAuthComplete?: (serverId: string) => void;
|
||||
workspace?: TypesGen.Workspace;
|
||||
workspaceAgent?: TypesGen.WorkspaceAgent;
|
||||
chatId?: string;
|
||||
sshCommand?: string;
|
||||
attachedWorkspace?: AttachedWorkspaceInfo;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
export interface AttachedWorkspaceInfo {
|
||||
@@ -285,7 +291,12 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
selectedMCPServerIds,
|
||||
onMCPSelectionChange,
|
||||
onMCPAuthComplete,
|
||||
workspace,
|
||||
workspaceAgent,
|
||||
chatId,
|
||||
sshCommand,
|
||||
attachedWorkspace,
|
||||
folder,
|
||||
}) => {
|
||||
const [chatFullWidth] = useChatFullWidth();
|
||||
const internalRef = useRef<ChatMessageInputRef>(null);
|
||||
@@ -395,7 +406,10 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
// Ordered list of active tool badge data so we can determine
|
||||
// which ones ended up in the overflow popover.
|
||||
const allBadges: ToolBadgeData[] = [];
|
||||
if (attachedWorkspace) {
|
||||
// When workspace data is available, WorkspacePill handles
|
||||
// the display (including app dropdown). Otherwise fall back
|
||||
// to the simple attached-workspace ToolBadge.
|
||||
if (!(workspace && workspaceAgent && chatId) && attachedWorkspace) {
|
||||
allBadges.push({ kind: "attached-workspace", ...attachedWorkspace });
|
||||
}
|
||||
if (selectedWorkspace && onWorkspaceChange) {
|
||||
@@ -918,6 +932,15 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
* hide and reorder via CSS. The pill is invisible
|
||||
* when there's no overflow but still occupies
|
||||
* layout space, preventing measurement flicker. */}
|
||||
{workspace && workspaceAgent && chatId && (
|
||||
<WorkspacePill
|
||||
workspace={workspace}
|
||||
agent={workspaceAgent}
|
||||
chatId={chatId}
|
||||
sshCommand={sshCommand}
|
||||
folder={folder}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={badgeContainerRef}
|
||||
className="flex min-w-0 items-center gap-1 overflow-hidden"
|
||||
|
||||
@@ -184,7 +184,12 @@ interface ChatPageInputProps {
|
||||
onMCPSelectionChange?: (ids: string[]) => void;
|
||||
onMCPAuthComplete?: (serverId: string) => void;
|
||||
lastInjectedContext?: readonly TypesGen.ChatMessagePart[];
|
||||
workspace?: TypesGen.Workspace;
|
||||
workspaceAgent?: TypesGen.WorkspaceAgent;
|
||||
chatId?: string;
|
||||
sshCommand?: string;
|
||||
attachedWorkspace?: AttachedWorkspaceInfo;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
export const ChatPageInput: FC<ChatPageInputProps> = ({
|
||||
@@ -222,7 +227,12 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
|
||||
onMCPSelectionChange,
|
||||
onMCPAuthComplete,
|
||||
lastInjectedContext,
|
||||
workspace,
|
||||
workspaceAgent,
|
||||
chatId,
|
||||
sshCommand,
|
||||
attachedWorkspace,
|
||||
folder,
|
||||
}) => {
|
||||
const messagesByID = useChatSelector(store, selectMessagesByID);
|
||||
const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs);
|
||||
@@ -396,7 +406,12 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
|
||||
selectedMCPServerIds={selectedMCPServerIds}
|
||||
onMCPSelectionChange={onMCPSelectionChange}
|
||||
onMCPAuthComplete={onMCPAuthComplete}
|
||||
workspace={workspace}
|
||||
workspaceAgent={workspaceAgent}
|
||||
chatId={chatId}
|
||||
sshCommand={sshCommand}
|
||||
attachedWorkspace={attachedWorkspace}
|
||||
folder={folder}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -8,14 +8,6 @@ const defaultProps = {
|
||||
showSidebarPanel: false,
|
||||
onToggleSidebar: fn(),
|
||||
},
|
||||
workspace: {
|
||||
canOpenEditors: true,
|
||||
canOpenWorkspace: true,
|
||||
onOpenInEditor: fn(),
|
||||
onViewWorkspace: fn(),
|
||||
onOpenTerminal: fn(),
|
||||
sshCommand: "ssh main.my-workspace.admin.coder",
|
||||
},
|
||||
onArchiveAgent: fn(),
|
||||
onArchiveAndDeleteWorkspace: fn(),
|
||||
onRegenerateTitle: fn(),
|
||||
|
||||
@@ -3,20 +3,15 @@ import {
|
||||
ArchiveRestoreIcon,
|
||||
ArrowLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
EllipsisIcon,
|
||||
ExternalLinkIcon,
|
||||
MonitorIcon,
|
||||
PanelLeftIcon,
|
||||
PanelRightCloseIcon,
|
||||
PanelRightOpenIcon,
|
||||
TerminalIcon,
|
||||
Trash2Icon,
|
||||
WandSparklesIcon,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import type { ChatDiffStatus } from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
@@ -38,20 +33,10 @@ interface SidebarPanelState {
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
interface WorkspaceActions {
|
||||
canOpenEditors: boolean;
|
||||
canOpenWorkspace: boolean;
|
||||
onOpenInEditor: (editor: "cursor" | "vscode") => void;
|
||||
onViewWorkspace: () => void;
|
||||
onOpenTerminal: () => void;
|
||||
sshCommand: string | undefined;
|
||||
}
|
||||
|
||||
type ChatTopBarProps = {
|
||||
chatTitle?: string;
|
||||
parentChat?: TypesGen.Chat;
|
||||
panel: SidebarPanelState;
|
||||
workspace: WorkspaceActions;
|
||||
onArchiveAgent: () => void;
|
||||
onUnarchiveAgent: () => void;
|
||||
onArchiveAndDeleteWorkspace: () => void;
|
||||
@@ -69,7 +54,6 @@ export const ChatTopBar: FC<ChatTopBarProps> = ({
|
||||
chatTitle,
|
||||
parentChat,
|
||||
panel,
|
||||
workspace,
|
||||
onArchiveAgent,
|
||||
onUnarchiveAgent,
|
||||
onArchiveAndDeleteWorkspace,
|
||||
@@ -206,58 +190,8 @@ export const ChatTopBar: FC<ChatTopBarProps> = ({
|
||||
align="end"
|
||||
className="[&_[role=menuitem]]:text-[13px]"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
disabled={!workspace.canOpenEditors}
|
||||
onSelect={() => {
|
||||
workspace.onOpenInEditor("cursor");
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon className="h-3.5 w-3.5" />
|
||||
Open in Cursor
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!workspace.canOpenEditors}
|
||||
onSelect={() => {
|
||||
workspace.onOpenInEditor("vscode");
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
<MonitorIcon className="h-3.5 w-3.5" />
|
||||
View Workspace
|
||||
</DropdownMenuItem>
|
||||
{!isArchived && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{onRegenerateTitle && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
MonitorDotIcon,
|
||||
MonitorIcon,
|
||||
MonitorPauseIcon,
|
||||
MonitorXIcon,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { Workspace, WorkspaceAgent } from "#/api/typesGenerated";
|
||||
import {
|
||||
type DisplayWorkspaceStatusType,
|
||||
getDisplayWorkspaceStatus,
|
||||
} from "#/utils/workspace";
|
||||
|
||||
const iconMap: Record<
|
||||
DisplayWorkspaceStatusType,
|
||||
FC<{ className?: string }>
|
||||
> = {
|
||||
success: MonitorIcon,
|
||||
active: MonitorDotIcon,
|
||||
inactive: MonitorPauseIcon,
|
||||
error: MonitorXIcon,
|
||||
danger: MonitorXIcon,
|
||||
warning: MonitorXIcon,
|
||||
};
|
||||
|
||||
export const StatusIcon: FC<{
|
||||
type: DisplayWorkspaceStatusType;
|
||||
className?: string;
|
||||
}> = ({ type, className = "size-3" }) => {
|
||||
const Icon = iconMap[type];
|
||||
return <Icon className={className} />;
|
||||
};
|
||||
|
||||
export function getWorkspaceStatus(
|
||||
workspace: Workspace,
|
||||
agent?: WorkspaceAgent | null,
|
||||
): { effectiveType: DisplayWorkspaceStatusType; statusLabel: string } {
|
||||
let { type, text } = getDisplayWorkspaceStatus(
|
||||
workspace.latest_build.status,
|
||||
workspace.latest_build.job,
|
||||
);
|
||||
|
||||
const agentPreparing =
|
||||
workspace.latest_build.status === "running" &&
|
||||
(agent?.lifecycle_state === "created" ||
|
||||
agent?.lifecycle_state === "starting");
|
||||
const agentStartupFailed =
|
||||
workspace.latest_build.status === "running" &&
|
||||
(agent?.lifecycle_state === "start_error" ||
|
||||
agent?.lifecycle_state === "start_timeout");
|
||||
if (agentPreparing) {
|
||||
type = "active";
|
||||
text = "Preparing";
|
||||
} else if (agentStartupFailed) {
|
||||
type = "warning";
|
||||
text = "Startup failed";
|
||||
}
|
||||
|
||||
const effectiveType = workspace.health.healthy ? type : "warning";
|
||||
const statusLabel = workspace.health.healthy
|
||||
? `Workspace ${text.toLowerCase()}`
|
||||
: `Workspace ${text.toLowerCase()} (unhealthy)`;
|
||||
return { effectiveType, statusLabel };
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, waitFor, within } from "storybook/test";
|
||||
import type { WorkspaceApp } from "#/api/typesGenerated";
|
||||
import {
|
||||
MockStoppedWorkspace,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
} from "#/testHelpers/entities";
|
||||
import { withProxyProvider } from "#/testHelpers/storybook";
|
||||
import { WorkspacePill } from "./WorkspacePill";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const defaultProps = {
|
||||
chatId: "chat-abc-123",
|
||||
} satisfies Partial<React.ComponentProps<typeof WorkspacePill>>;
|
||||
|
||||
const externalApp: WorkspaceApp = {
|
||||
id: "jetbrains-app",
|
||||
slug: "jetbrains-gateway",
|
||||
display_name: "JetBrains Gateway",
|
||||
external: true,
|
||||
url: "jetbrains-gateway://connect?$SESSION_TOKEN",
|
||||
subdomain: false,
|
||||
health: "disabled",
|
||||
sharing_level: "owner",
|
||||
hidden: false,
|
||||
open_in: "slim-window",
|
||||
statuses: [],
|
||||
};
|
||||
|
||||
const cursorApp: WorkspaceApp = {
|
||||
id: "cursor-app",
|
||||
slug: "cursor",
|
||||
display_name: "Cursor",
|
||||
external: true,
|
||||
url: "cursor://coder.coder-remote/open?$SESSION_TOKEN",
|
||||
subdomain: false,
|
||||
health: "disabled",
|
||||
sharing_level: "owner",
|
||||
hidden: false,
|
||||
open_in: "slim-window",
|
||||
statuses: [],
|
||||
icon: "/icon/cursor.svg",
|
||||
};
|
||||
|
||||
const hiddenApp: WorkspaceApp = {
|
||||
id: "hidden-app",
|
||||
slug: "hidden-internal",
|
||||
display_name: "Hidden Internal Tool",
|
||||
external: false,
|
||||
url: "",
|
||||
subdomain: false,
|
||||
health: "disabled",
|
||||
sharing_level: "owner",
|
||||
hidden: true,
|
||||
open_in: "slim-window",
|
||||
statuses: [],
|
||||
};
|
||||
|
||||
const agentWithApps = {
|
||||
...MockWorkspaceAgent,
|
||||
display_apps: ["vscode", "vscode_insiders", "web_terminal"] as const,
|
||||
apps: [externalApp, cursorApp],
|
||||
};
|
||||
|
||||
const agentWithBuiltinsOnly = {
|
||||
...MockWorkspaceAgent,
|
||||
display_apps: ["vscode", "web_terminal"] as const,
|
||||
apps: [],
|
||||
};
|
||||
|
||||
const agentWithNoApps = {
|
||||
...MockWorkspaceAgent,
|
||||
display_apps: [] as const,
|
||||
apps: [],
|
||||
};
|
||||
|
||||
const agentWithExternalOnly = {
|
||||
...MockWorkspaceAgent,
|
||||
display_apps: [] as const,
|
||||
apps: [externalApp, cursorApp],
|
||||
};
|
||||
|
||||
const agentWithHiddenApp = {
|
||||
...MockWorkspaceAgent,
|
||||
display_apps: ["vscode"] as const,
|
||||
apps: [externalApp, hiddenApp],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const meta: Meta<typeof WorkspacePill> = {
|
||||
title: "pages/AgentsPage/WorkspacePill",
|
||||
component: WorkspacePill,
|
||||
// useAppLink calls useProxy(), so we need the proxy provider for
|
||||
// stories that render AppMenuItem.
|
||||
decorators: [withProxyProvider()],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
queries: [{ key: ["me", "apiKey"], data: { key: "mock-api-key" } }],
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WorkspacePill>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const WithAllApps: Story = {
|
||||
args: {
|
||||
...defaultProps,
|
||||
workspace: MockWorkspace,
|
||||
agent: agentWithApps,
|
||||
sshCommand: "ssh coder.test-workspace",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const pill = canvas.getByText("Test-Workspace");
|
||||
await userEvent.click(pill);
|
||||
|
||||
await waitFor(() => {
|
||||
const body = within(document.body);
|
||||
expect(body.getByText("VS Code")).toBeInTheDocument();
|
||||
expect(body.getByText("VS Code Insiders")).toBeInTheDocument();
|
||||
expect(body.getByText("JetBrains Gateway")).toBeInTheDocument();
|
||||
expect(body.getByText("Cursor")).toBeInTheDocument();
|
||||
expect(body.getByText("Terminal")).toBeInTheDocument();
|
||||
expect(body.getByText("Copy SSH Command")).toBeInTheDocument();
|
||||
expect(body.getByText("View Workspace")).toBeInTheDocument();
|
||||
|
||||
// Verify items are enabled on a running workspace.
|
||||
const vscodeItem = body.getByText("VS Code").closest("[role=menuitem]");
|
||||
expect(vscodeItem).not.toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// External apps should be enabled with API key mock.
|
||||
const jetbrainsItem = body
|
||||
.getByText("JetBrains Gateway")
|
||||
.closest("[role=menuitem]");
|
||||
expect(jetbrainsItem).not.toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
const cursorItem = body.getByText("Cursor").closest("[role=menuitem]");
|
||||
expect(cursorItem).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const WithBuiltinAppsOnly: Story = {
|
||||
args: {
|
||||
...defaultProps,
|
||||
workspace: MockWorkspace,
|
||||
agent: agentWithBuiltinsOnly,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const pill = canvas.getByText("Test-Workspace");
|
||||
await userEvent.click(pill);
|
||||
|
||||
await waitFor(() => {
|
||||
const body = within(document.body);
|
||||
expect(body.getByText("VS Code")).toBeInTheDocument();
|
||||
expect(body.getByText("Terminal")).toBeInTheDocument();
|
||||
expect(body.getByText("View Workspace")).toBeInTheDocument();
|
||||
// No external apps or VS Code Insiders.
|
||||
expect(body.queryByText("VS Code Insiders")).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const WithExternalAppsOnly: Story = {
|
||||
args: {
|
||||
...defaultProps,
|
||||
workspace: MockWorkspace,
|
||||
agent: agentWithExternalOnly,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const pill = canvas.getByText("Test-Workspace");
|
||||
await userEvent.click(pill);
|
||||
|
||||
await waitFor(() => {
|
||||
const body = within(document.body);
|
||||
expect(body.getByText("JetBrains Gateway")).toBeInTheDocument();
|
||||
expect(body.getByText("Cursor")).toBeInTheDocument();
|
||||
expect(body.getByText("View Workspace")).toBeInTheDocument();
|
||||
// No built-in apps.
|
||||
expect(body.queryByText("VS Code")).not.toBeInTheDocument();
|
||||
expect(body.queryByText("Terminal")).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const NoApps: Story = {
|
||||
args: {
|
||||
...defaultProps,
|
||||
workspace: MockWorkspace,
|
||||
agent: agentWithNoApps,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const pill = canvas.getByText("Test-Workspace");
|
||||
await userEvent.click(pill);
|
||||
|
||||
await waitFor(() => {
|
||||
const body = within(document.body);
|
||||
expect(body.getByText("View Workspace")).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHiddenApp: Story = {
|
||||
args: {
|
||||
...defaultProps,
|
||||
workspace: MockWorkspace,
|
||||
agent: agentWithHiddenApp,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const pill = canvas.getByText("Test-Workspace");
|
||||
await userEvent.click(pill);
|
||||
|
||||
await waitFor(() => {
|
||||
const body = within(document.body);
|
||||
// Visible apps should appear.
|
||||
expect(body.getByText("VS Code")).toBeInTheDocument();
|
||||
expect(body.getByText("JetBrains Gateway")).toBeInTheDocument();
|
||||
// Hidden app should NOT appear.
|
||||
expect(body.queryByText("Hidden Internal Tool")).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const WithStoppedWorkspace: Story = {
|
||||
args: {
|
||||
...defaultProps,
|
||||
workspace: MockStoppedWorkspace,
|
||||
agent: agentWithApps,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const pill = canvas.getByText(MockStoppedWorkspace.name);
|
||||
await userEvent.click(pill);
|
||||
|
||||
await waitFor(() => {
|
||||
const body = within(document.body);
|
||||
|
||||
// VS Code items should be present but disabled.
|
||||
const vscodeItem = body.getByText("VS Code").closest("[role=menuitem]");
|
||||
expect(vscodeItem).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
const vscodeInsidersItem = body
|
||||
.getByText("VS Code Insiders")
|
||||
.closest("[role=menuitem]");
|
||||
expect(vscodeInsidersItem).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// Terminal item should be disabled.
|
||||
const terminalItem = body
|
||||
.getByText("Terminal")
|
||||
.closest("[role=menuitem]");
|
||||
expect(terminalItem).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// External app items should be disabled.
|
||||
const jetbrainsItem = body
|
||||
.getByText("JetBrains Gateway")
|
||||
.closest("[role=menuitem]");
|
||||
expect(jetbrainsItem).toHaveAttribute("aria-disabled", "true");
|
||||
expect(jetbrainsItem).not.toHaveAttribute("href");
|
||||
|
||||
const cursorItem = body.getByText("Cursor").closest("[role=menuitem]");
|
||||
expect(cursorItem).toHaveAttribute("aria-disabled", "true");
|
||||
expect(cursorItem).not.toHaveAttribute("href");
|
||||
|
||||
// View Workspace link should still be accessible.
|
||||
expect(body.getByText("View Workspace")).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
CopyIcon,
|
||||
ExternalLinkIcon,
|
||||
LayoutGridIcon,
|
||||
MonitorIcon,
|
||||
SquareTerminalIcon,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { API } from "#/api/api";
|
||||
import { getErrorMessage } from "#/api/errors";
|
||||
import type {
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
WorkspaceApp,
|
||||
} from "#/api/typesGenerated";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { useClipboard } from "#/hooks/useClipboard";
|
||||
import {
|
||||
getTerminalHref,
|
||||
getVSCodeHref,
|
||||
isExternalApp,
|
||||
needsSessionToken,
|
||||
openAppInNewWindow,
|
||||
} from "#/modules/apps/apps";
|
||||
import { useAppLink } from "#/modules/apps/useAppLink";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { getWorkspaceStatus, StatusIcon } from "./StatusIcon";
|
||||
|
||||
interface WorkspacePillProps {
|
||||
workspace: Workspace;
|
||||
agent: WorkspaceAgent;
|
||||
chatId: string;
|
||||
sshCommand?: string;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
export const WorkspacePill: FC<WorkspacePillProps> = ({
|
||||
workspace,
|
||||
agent,
|
||||
chatId,
|
||||
sshCommand,
|
||||
folder,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
const isRunning = workspace.latest_build.status === "running";
|
||||
const route = `/@${workspace.owner_name}/${workspace.name}`;
|
||||
|
||||
const { effectiveType, statusLabel } = getWorkspaceStatus(workspace, agent);
|
||||
|
||||
const { mutate: generateKey, isPending: isGeneratingKey } = useMutation({
|
||||
mutationFn: () => API.getApiKey(),
|
||||
});
|
||||
|
||||
const builtinApps = new Set(agent.display_apps);
|
||||
const hasVSCode = builtinApps.has("vscode");
|
||||
const hasVSCodeInsiders = builtinApps.has("vscode_insiders");
|
||||
const hasTerminal = builtinApps.has("web_terminal");
|
||||
|
||||
const userApps = agent.apps.filter((app) => !app.hidden);
|
||||
|
||||
const hasItemsAboveSeparator =
|
||||
hasVSCode || hasVSCodeInsiders || userApps.length > 0 || hasTerminal;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<Tooltip
|
||||
open={tooltipOpen}
|
||||
onOpenChange={(v) => setTooltipOpen(v && !open)}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${workspace.name} workspace menu`}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary",
|
||||
"cursor-pointer border-0 transition-colors hover:bg-surface-tertiary hover:text-content-primary",
|
||||
)}
|
||||
>
|
||||
<StatusIcon type={effectiveType} /> {workspace.name}
|
||||
{/* The menu opens upward (side="top"), so the chevron
|
||||
points away from the menu when closed (default) and
|
||||
toward it when open (rotate-180). */}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3 opacity-60 transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{statusLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-48 p-1 [&_[role=menuitem]]:text-xs [&_[role=menuitem]]:py-1 [&_svg]:!size-3.5 [&_img]:!size-3.5"
|
||||
>
|
||||
{hasVSCode && (
|
||||
<VSCodeMenuItem
|
||||
variant="vscode"
|
||||
label="VS Code"
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
chatId={chatId}
|
||||
folder={folder}
|
||||
isRunning={isRunning}
|
||||
generateKey={generateKey}
|
||||
isGeneratingKey={isGeneratingKey}
|
||||
/>
|
||||
)}
|
||||
{hasVSCodeInsiders && (
|
||||
<VSCodeMenuItem
|
||||
variant="vscode-insiders"
|
||||
label="VS Code Insiders"
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
chatId={chatId}
|
||||
folder={folder}
|
||||
isRunning={isRunning}
|
||||
generateKey={generateKey}
|
||||
isGeneratingKey={isGeneratingKey}
|
||||
/>
|
||||
)}
|
||||
{userApps.map((app) => (
|
||||
<AppMenuItem
|
||||
key={app.id}
|
||||
app={app}
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
))}
|
||||
{hasTerminal && (
|
||||
<TerminalMenuItem
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
)}
|
||||
{hasItemsAboveSeparator && <DropdownMenuSeparator className="my-1" />}
|
||||
{sshCommand && <CopySSHMenuItem sshCommand={sshCommand} />}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={route} target="_blank" rel="noreferrer">
|
||||
<MonitorIcon className="size-3.5" />
|
||||
View Workspace
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const VSCodeMenuItem: FC<{
|
||||
variant: "vscode" | "vscode-insiders";
|
||||
label: string;
|
||||
workspace: Workspace;
|
||||
agent: WorkspaceAgent;
|
||||
chatId: string;
|
||||
folder?: string;
|
||||
isRunning: boolean;
|
||||
generateKey: (
|
||||
variables: undefined,
|
||||
options: {
|
||||
onSuccess: (data: { key: string }) => void;
|
||||
onError: (error: unknown) => void;
|
||||
},
|
||||
) => void;
|
||||
isGeneratingKey: boolean;
|
||||
}> = ({
|
||||
variant,
|
||||
label,
|
||||
workspace,
|
||||
agent,
|
||||
chatId,
|
||||
folder,
|
||||
isRunning,
|
||||
generateKey,
|
||||
isGeneratingKey,
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
generateKey(undefined, {
|
||||
onSuccess: ({ key }) => {
|
||||
location.href = getVSCodeHref(variant, {
|
||||
owner: workspace.owner_name,
|
||||
workspace: workspace.name,
|
||||
token: key,
|
||||
agent: agent.name,
|
||||
folder: folder ?? agent.expanded_directory,
|
||||
chatId,
|
||||
});
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(getErrorMessage(error, `Failed to open ${label}.`));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={handleClick}
|
||||
disabled={isGeneratingKey || !isRunning}
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const AppMenuItem: FC<{
|
||||
app: WorkspaceApp;
|
||||
workspace: Workspace;
|
||||
agent: WorkspaceAgent;
|
||||
isRunning: boolean;
|
||||
}> = ({ app, workspace, agent, isRunning }) => {
|
||||
const link = useAppLink(app, { workspace, agent });
|
||||
|
||||
const canClick =
|
||||
!isExternalApp(app) || !needsSessionToken(app) || link.hasToken;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem asChild disabled={!canClick || !isRunning}>
|
||||
<a
|
||||
href={canClick && isRunning ? link.href : undefined}
|
||||
onClick={link.onClick}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{app.icon ? (
|
||||
<ExternalImage
|
||||
src={app.icon}
|
||||
alt=""
|
||||
className="size-3.5 rounded-sm"
|
||||
/>
|
||||
) : (
|
||||
<LayoutGridIcon className="size-3.5" />
|
||||
)}
|
||||
{link.label}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const TerminalMenuItem: FC<{
|
||||
workspace: Workspace;
|
||||
agent: WorkspaceAgent;
|
||||
isRunning: boolean;
|
||||
}> = ({ workspace, agent, isRunning }) => {
|
||||
const href = getTerminalHref({
|
||||
username: workspace.owner_name,
|
||||
workspace: workspace.name,
|
||||
agent: agent.name,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
openAppInNewWindow(href);
|
||||
}}
|
||||
disabled={!isRunning}
|
||||
>
|
||||
<SquareTerminalIcon className="size-3.5" />
|
||||
Terminal
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const CopySSHMenuItem: FC<{
|
||||
sshCommand: string;
|
||||
}> = ({ sshCommand }) => {
|
||||
const { copyToClipboard } = useClipboard();
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void copyToClipboard(sshCommand);
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
Copy SSH Command
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user