Skip to content

feat: display builtin apps on workspaces table #17695

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 7, 2025
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
54 changes: 54 additions & 0 deletions site/src/modules/apps/apps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
type GetVSCodeHrefParams = {
owner: string;
workspace: string;
token: string;
agent?: string;
folder?: string;
};

export const getVSCodeHref = (
app: "vscode" | "vscode-insiders",
{ owner, workspace, token, agent, folder }: GetVSCodeHrefParams,
) => {
const query = new URLSearchParams({
owner,
workspace,
url: location.origin,
token,
openRecent: "true",
});
if (agent) {
query.set("agent", agent);
}
if (folder) {
query.set("folder", folder);
}
return `${app}://coder.coder-remote/open?${query}`;
};

type GetTerminalHrefParams = {
username: string;
workspace: string;
agent?: string;
container?: string;
};

export const getTerminalHref = ({
username,
workspace,
agent,
container,
}: GetTerminalHrefParams) => {
const params = new URLSearchParams();
if (container) {
params.append("container", container);
}
// Always use the primary for the terminal link. This is a relative link.
return `/@${username}/${workspace}${
agent ? `.${agent}` : ""
}/terminal?${params}`;
};

export const openAppInNewWindow = (name: string, href: string) => {
window.open(href, "_blank", "width=900,height=600");
};
26 changes: 8 additions & 18 deletions site/src/modules/resources/TerminalLink/TerminalLink.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { TerminalIcon } from "components/Icons/TerminalIcon";
import { getTerminalHref, openAppInNewWindow } from "modules/apps/apps";
import type { FC, MouseEvent } from "react";
import { generateRandomString } from "utils/random";
import { AgentButton } from "../AgentButton";
import { DisplayAppNameMap } from "../AppLink/AppLink";

const Language = {
terminalTitle: (identifier: string): string => `Terminal - ${identifier}`,
};

export interface TerminalLinkProps {
workspaceName: string;
agentName?: string;
Expand All @@ -28,26 +24,20 @@ export const TerminalLink: FC<TerminalLinkProps> = ({
workspaceName,
containerName,
}) => {
const params = new URLSearchParams();
if (containerName) {
params.append("container", containerName);
}
// Always use the primary for the terminal link. This is a relative link.
const href = `/@${userName}/${workspaceName}${
agentName ? `.${agentName}` : ""
}/terminal?${params.toString()}`;
const href = getTerminalHref({
username: userName,
workspace: workspaceName,
agent: agentName,
container: containerName,
});

return (
<AgentButton asChild>
<a
href={href}
onClick={(event: MouseEvent<HTMLElement>) => {
event.preventDefault();
window.open(
href,
Language.terminalTitle(generateRandomString(12)),
"width=900,height=600",
);
openAppInNewWindow("Terminal", href);
}}
>
<TerminalIcon />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { DisplayApp } from "api/typesGenerated";
import { VSCodeIcon } from "components/Icons/VSCodeIcon";
import { VSCodeInsidersIcon } from "components/Icons/VSCodeInsidersIcon";
import { ChevronDownIcon } from "lucide-react";
import { getVSCodeHref } from "modules/apps/apps";
import { type FC, useRef, useState } from "react";
import { AgentButton } from "../AgentButton";
import { DisplayAppNameMap } from "../AppLink/AppLink";
Expand Down Expand Up @@ -118,21 +119,13 @@ const VSCodeButton: FC<VSCodeDesktopButtonProps> = ({
setLoading(true);
API.getApiKey()
.then(({ key }) => {
const query = new URLSearchParams({
location.href = getVSCodeHref("vscode", {
owner: userName,
workspace: workspaceName,
url: location.origin,
token: key,
openRecent: "true",
agent: agentName,
folder: folderPath,
});
if (agentName) {
query.set("agent", agentName);
}
if (folderPath) {
query.set("folder", folderPath);
}

location.href = `vscode://coder.coder-remote/open?${query.toString()}`;
})
.catch((ex) => {
console.error(ex);
Expand Down Expand Up @@ -163,20 +156,13 @@ const VSCodeInsidersButton: FC<VSCodeDesktopButtonProps> = ({
setLoading(true);
API.getApiKey()
.then(({ key }) => {
const query = new URLSearchParams({
location.href = getVSCodeHref("vscode-insiders", {
owner: userName,
workspace: workspaceName,
url: location.origin,
token: key,
agent: agentName,
folder: folderPath,
});
if (agentName) {
query.set("agent", agentName);
}
if (folderPath) {
query.set("folder", folderPath);
}

location.href = `vscode-insiders://coder.coder-remote/open?${query.toString()}`;
})
.catch((ex) => {
console.error(ex);
Expand Down
160 changes: 145 additions & 15 deletions site/src/pages/WorkspacesPage/WorkspacesTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Star from "@mui/icons-material/Star";
import Checkbox from "@mui/material/Checkbox";
import Skeleton from "@mui/material/Skeleton";
import { templateVersion } from "api/queries/templates";
import { apiKey } from "api/queries/users";
import {
cancelBuild,
deleteWorkspace,
Expand All @@ -19,6 +20,8 @@ import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/Avatar/AvatarData";
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
import { Button } from "components/Button/Button";
import { VSCodeIcon } from "components/Icons/VSCodeIcon";
import { VSCodeInsidersIcon } from "components/Icons/VSCodeInsidersIcon";
import { InfoTooltip } from "components/InfoTooltip/InfoTooltip";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
Expand Down Expand Up @@ -49,7 +52,17 @@ import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { useAuthenticated } from "hooks";
import { useClickableTableRow } from "hooks/useClickableTableRow";
import { BanIcon, PlayIcon, RefreshCcwIcon, SquareIcon } from "lucide-react";
import {
BanIcon,
PlayIcon,
RefreshCcwIcon,
SquareTerminalIcon,
} from "lucide-react";
import {
getTerminalHref,
getVSCodeHref,
openAppInNewWindow,
} from "modules/apps/apps";
import { useDashboard } from "modules/dashboard/useDashboard";
import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus";
import { WorkspaceDormantBadge } from "modules/workspaces/WorkspaceDormantBadge/WorkspaceDormantBadge";
Expand All @@ -59,6 +72,7 @@ import {
useWorkspaceUpdate,
} from "modules/workspaces/WorkspaceUpdateDialogs";
import { abilitiesByWorkspaceStatus } from "modules/workspaces/actions";
import type React from "react";
import {
type FC,
type PropsWithChildren,
Expand Down Expand Up @@ -534,6 +548,10 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({
return (
<TableCell>
<div className="flex gap-1 justify-end">
{workspace.latest_build.status === "running" && (
<WorkspaceApps workspace={workspace} />
)}

{abilities.actions.includes("start") && (
<PrimaryAction
onClick={() => startWorkspaceMutation.mutate({})}
Expand All @@ -557,18 +575,6 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({
</>
)}

{abilities.actions.includes("stop") && (
<PrimaryAction
onClick={() => {
stopWorkspaceMutation.mutate({});
}}
isLoading={stopWorkspaceMutation.isLoading}
label="Stop workspace"
>
<SquareIcon />
</PrimaryAction>
)}

Comment on lines -560 to -571
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we not want users to be able to stop workspaces from the table view anymore? I see in the designs there is a dropdown menu that will have this. Is that going to be a fast follow to this PR?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the "more options" menu will be implemented in a different PR.

{abilities.canCancel && (
<PrimaryAction
onClick={cancelBuildMutation.mutate}
Expand All @@ -594,9 +600,9 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({
};

type PrimaryActionProps = PropsWithChildren<{
onClick: () => void;
isLoading: boolean;
label: string;
isLoading?: boolean;
onClick: () => void;
}>;

const PrimaryAction: FC<PrimaryActionProps> = ({
Expand Down Expand Up @@ -626,3 +632,127 @@ const PrimaryAction: FC<PrimaryActionProps> = ({
</TooltipProvider>
);
};

type WorkspaceAppsProps = {
workspace: Workspace;
};

const WorkspaceApps: FC<WorkspaceAppsProps> = ({ workspace }) => {
const { data: apiKeyRes } = useQuery(apiKey());
const token = apiKeyRes?.key;

/**
* Coder is pretty flexible and allows an enormous variety of use cases, such
* as having multiple resources with many agents, but they are not common. The
* most common scenario is to have one single compute resource with one single
* agent containing all the apps. Lets test this getting the apps for the
* first resource, and first agent - they are sorted to return the compute
* resource first - and see what customers and ourselves, using dogfood, think
* about that.
*/
const agent = workspace.latest_build.resources
.filter((r) => !r.hide)
.at(0)
?.agents?.at(0);
if (!agent) {
return null;
}

const buttons: ReactNode[] = [];

if (agent.display_apps.includes("vscode")) {
buttons.push(
<AppLink
isLoading={!token}
label="Open VSCode"
href={getVSCodeHref("vscode", {
owner: workspace.owner_name,
workspace: workspace.name,
agent: agent.name,
token: apiKeyRes?.key ?? "",
folder: agent.expanded_directory,
})}
>
<VSCodeIcon />
</AppLink>,
);
}

if (agent.display_apps.includes("vscode_insiders")) {
buttons.push(
<AppLink
label="Open VSCode Insiders"
isLoading={!token}
href={getVSCodeHref("vscode-insiders", {
owner: workspace.owner_name,
workspace: workspace.name,
agent: agent.name,
token: apiKeyRes?.key ?? "",
folder: agent.expanded_directory,
})}
>
<VSCodeInsidersIcon />
</AppLink>,
);
}

if (agent.display_apps.includes("web_terminal")) {
const href = getTerminalHref({
username: workspace.owner_name,
workspace: workspace.name,
agent: agent.name,
});
buttons.push(
<AppLink
href={href}
onClick={(e) => {
e.preventDefault();
openAppInNewWindow("Terminal", href);
}}
label="Open Terminal"
>
<SquareTerminalIcon />
</AppLink>,
);
}

return buttons;
};

type AppLinkProps = PropsWithChildren<{
label: string;
href: string;
isLoading?: boolean;
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
}>;

const AppLink: FC<AppLinkProps> = ({
href,
isLoading,
label,
children,
onClick,
}) => {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon-lg" asChild>
<a
className={isLoading ? "animate-pulse" : ""}
href={href}
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}}
>
{children}
<span className="sr-only">{label}</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
Loading