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 1 commit
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
Next Next commit
feat: display builtin apps on workspaces table
  • Loading branch information
BrunoQuaresma committed May 6, 2025
commit 771a690fe91508973a49e755f532474418b8a32d
60 changes: 60 additions & 0 deletions site/src/modules/apps/apps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { generateRandomString } from "utils/random";

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,
`${name} - ${generateRandomString(12)}`,
Copy link
Contributor

Choose a reason for hiding this comment

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

What does setting the target like this do? The MDN docs make it out like this is only useful if you wanted to target the new context with an anchor or form. More over it actually says that whitespace isn't allowed. Would one of the built in target keywords be better for this?

"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,15 @@ const VSCodeButton: FC<VSCodeDesktopButtonProps> = ({
setLoading(true);
API.getApiKey()
.then(({ key }) => {
const query = new URLSearchParams({
const 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()}`;
location.href = href;
Copy link
Contributor

Choose a reason for hiding this comment

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

nit:

location.href = getVSCodeHref("vscode", {
	owner: userName,
	workspace: workspaceName,
	token: key,
	agent: agentName,
	folder: folderPath,
});

})
.catch((ex) => {
console.error(ex);
Expand Down Expand Up @@ -163,20 +158,15 @@ const VSCodeInsidersButton: FC<VSCodeDesktopButtonProps> = ({
setLoading(true);
API.getApiKey()
.then(({ key }) => {
const query = new URLSearchParams({
const 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()}`;
location.href = href;
})
.catch((ex) => {
console.error(ex);
Expand Down
153 changes: 138 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,120 @@ const PrimaryAction: FC<PrimaryActionProps> = ({
</TooltipProvider>
);
};

type WorkspaceAppsProps = {
workspace: Workspace;
};

const WorkspaceApps: FC<WorkspaceAppsProps> = ({ workspace }) => {
const buttons: ReactNode[] = [];
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: if you want to use this variable I think it'd be better to have it closer to where it's actually used

const { data: apiKeyRes } = useQuery(apiKey());
const token = apiKeyRes?.key;

const resource = workspace.latest_build.resources
.filter((r) => !r.hide)
.at(0);
if (!resource) {
return null;
}

const agent = resource.agents?.at(0);
Copy link
Contributor

Choose a reason for hiding this comment

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

Probably out of the scope of this PR but can a workspace have more than one resource that can then also have more one agent? Do we need to check all the resources and all the agents? Right now we're only checking if the first resource's first agent has one these apps, do we need to check if all of them do? Something like:

	const agents = workspace.latest_build.resources
		.filter((r) => !r.hide)
		.flatMap((resource) => resource.agents ?? []);

	if (agents.length === 0) {
		return null;
	}

	const displayApps = new Set(agents.flatMap((agent) => agent.display_apps))
...

But if this is the case a lot of the other code would need to be re-thought too

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Probably out of the scope of this PR but can a workspace have more than one resource that can then also have more one agent?

Yes.

Do we need to check all the resources and all the agents?

Nops.

Right now we're only checking if the first resource's first agent has one these apps, do we need to check if all of them do?

Not right now.

For each agent we have two type of apps, built in apps such as VSCode, VSCode insiders, and terminal, and we also have the apps defined by the user (it is coming in a next PR). If we want to show the apps from all the agents, we would need to include all of them, which could make the UI a bit confusing like having two or three terminal buttons.

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. From my experience dealing with customers, the most common scenario is to have one single compute resource with one single agent containing all the apps, but Idk this in numbers. My idea is to test this as it is, 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.

Copy link
Contributor

Choose a reason for hiding this comment

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

Understood! Left a comment above that I think this would be useful info to document into the code

if (!agent) {
return null;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: These two could be combined into one variable and one check

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeap, but I think this way the code make it more explicit that we are looking for the first non-hide resource and after, the first agent. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

Honestly I don't think either are particularly readable 😅 . I think my preference would be the one variable with a comment above explaining. I think a comment would be especially if it includes this info from your comment below:

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. From my experience dealing with customers, the most common scenario is to have one single compute resource with one single agent containing all the apps, but Idk this in numbers. My idea is to test this as it is, 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.


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
href={href}
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}}
>
<Spinner loading={isLoading}>{children}</Spinner>
<span className="sr-only">{label}</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};