refactor: update app buttons to use the new button component (#17684)

Related to https://github.com/coder/coder/issues/17311

- Replaces the MUI Buttons by the new shadcn/ui buttons. This change
allows the reuse of app links, and terminal buttons using the `asChild`
capability from the Radix components
- Uses the new [proposed
design](https://www.figma.com/design/OR75XeUI0Z3ksqt1mHsNQw/Workspace-views?node-id=1014-8242&t=wtUXJRN1SfyZiFKn-0)
- Updates the button styles to support image tags as icons
- Uses the new Tooltip component for the app buttons

**Before:**
<img width="1243" alt="Screenshot 2025-05-05 at 17 55 49"
src="https://github.com/user-attachments/assets/e689e9dc-d8e1-4c9d-ba09-ef1479a501f1"
/>

**After:**
<img width="1264" alt="Screenshot 2025-05-05 at 18 05 38"
src="https://github.com/user-attachments/assets/8fafbe20-f063-46ab-86cf-2e0381bba889"
/>
This commit is contained in:
Bruno Quaresma
2025-05-06 13:26:37 -03:00
committed by GitHub
parent a7e828593f
commit 4fa9d30bf4
12 changed files with 127 additions and 166 deletions
+3 -1
View File
@@ -1042,7 +1042,9 @@ export async function openTerminalWindow(
): Promise<Page> {
// Wait for the web terminal to open in a new tab
const pagePromise = context.waitForEvent("page");
await page.getByTestId("terminal").click({ timeout: 60_000 });
await page
.getByRole("link", { name: /terminal/i })
.click({ timeout: 60_000 });
const terminal = await pagePromise;
await terminal.waitForLoadState("domcontentloaded");
+7 -5
View File
@@ -13,7 +13,9 @@ const buttonVariants = cva(
text-sm font-semibold font-medium cursor-pointer no-underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
disabled:pointer-events-none disabled:text-content-disabled
[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:p-0.5`,
[&:is(a):not([href])]:pointer-events-none [&:is(a):not([href])]:text-content-disabled
[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:p-0.5
[&>img]:pointer-events-none [&>img]:shrink-0 [&>img]:p-0.5`,
{
variants: {
variant: {
@@ -28,11 +30,11 @@ const buttonVariants = cva(
},
size: {
lg: "min-w-20 h-10 px-3 py-2 [&_svg]:size-icon-lg",
sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&_svg]:size-icon-sm",
lg: "min-w-20 h-10 px-3 py-2 [&_svg]:size-icon-lg [&>img]:size-icon-lg",
sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&_svg]:size-icon-sm [&>img]:size-icon-sm",
xs: "min-w-8 py-1 px-2 text-2xs rounded-md",
icon: "size-8 px-1.5 [&_svg]:size-icon-sm",
"icon-lg": "size-10 px-2 [&_svg]:size-icon-lg",
icon: "size-8 px-1.5 [&_svg]:size-icon-sm [&>img]:size-icon-sm",
"icon-lg": "size-10 px-2 [&_svg]:size-icon-lg [&>img]:size-icon-lg",
},
},
defaultVariants: {
@@ -5,15 +5,15 @@ import { getExternalImageStylesFromUrl } from "theme/externalImages";
export const ExternalImage = forwardRef<
HTMLImageElement,
ImgHTMLAttributes<HTMLImageElement>
>((attrs, ref) => {
>((props, ref) => {
const theme = useTheme();
return (
// biome-ignore lint/a11y/useAltText: no reasonable alt to provide
// biome-ignore lint/a11y/useAltText: alt should be passed in as a prop
<img
ref={ref}
css={getExternalImageStylesFromUrl(theme.externalImages, attrs.src)}
{...attrs}
css={getExternalImageStylesFromUrl(theme.externalImages, props.src)}
{...props}
/>
);
});
@@ -81,32 +81,25 @@ export const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
</span>
{selectedProxy ? (
<div css={{ display: "flex", gap: 8, alignItems: "center" }}>
<div css={{ width: 16, height: 16, lineHeight: 0 }}>
<img
// Empty alt text used because we don't want to double up on
// screen reader announcements from visually-hidden span
alt=""
src={selectedProxy.icon_url}
css={{
objectFit: "contain",
width: "100%",
height: "100%",
}}
/>
</div>
<>
<img
// Empty alt text used because we don't want to double up on
// screen reader announcements from visually-hidden span
alt=""
src={selectedProxy.icon_url}
/>
<Latency
latency={latencies?.[selectedProxy.id]?.latencyMS}
isLoading={proxyLatencyLoading(selectedProxy)}
size={24}
/>
</div>
</>
) : (
"Select Proxy"
)}
<ChevronDownIcon className="text-content-primary !size-icon-xs" />
<ChevronDownIcon className="text-content-primary !size-icon-sm" />
</Button>
<Menu
@@ -62,25 +62,23 @@ export const OrganizationSidebarView: FC<
<Button
variant="outline"
aria-expanded={isPopoverOpen}
className="w-60 justify-between p-2 h-11"
className="w-60 gap-2 justify-start"
>
<div className="flex flex-row gap-2 items-center p-2 truncate">
{activeOrganization ? (
<>
<Avatar
size="sm"
src={activeOrganization.icon}
fallback={activeOrganization.display_name}
/>
<span className="truncate">
{activeOrganization.display_name || activeOrganization.name}
</span>
</>
) : (
<span className="truncate">No organization selected</span>
)}
</div>
<ChevronDown />
{activeOrganization ? (
<>
<Avatar
size="sm"
src={activeOrganization.icon}
fallback={activeOrganization.display_name}
/>
<span className="truncate">
{activeOrganization.display_name || activeOrganization.name}
</span>
</>
) : (
<span className="truncate">No organization selected</span>
)}
<ChevronDown className="ml-auto !size-icon-sm" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-60">
+2 -25
View File
@@ -1,31 +1,8 @@
import Button, { type ButtonProps } from "@mui/material/Button";
import { Button, type ButtonProps } from "components/Button/Button";
import { forwardRef } from "react";
export const AgentButton = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const { children, ...buttonProps } = props;
return (
<Button
{...buttonProps}
color="neutral"
size="xlarge"
variant="contained"
ref={ref}
css={(theme) => ({
padding: "12px 20px",
color: theme.palette.text.primary,
// Making them smaller since those icons don't have a padding around them
"& .MuiButton-startIcon, & .MuiButton-endIcon": {
width: 16,
height: 16,
"& svg, & img": { width: "100%", height: "100%" },
},
})}
>
{children}
</Button>
);
return <Button variant="outline" ref={ref} {...props} />;
},
);
@@ -1,10 +1,14 @@
import Link from "@mui/material/Link";
import Tooltip from "@mui/material/Tooltip";
import type {
Workspace,
WorkspaceAgent,
WorkspaceAgentContainer,
} from "api/typesGenerated";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { ExternalLinkIcon } from "lucide-react";
import type { FC } from "react";
import { portForwardURL } from "utils/portForward";
@@ -74,7 +78,7 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
const linkDest = hasHostBind
? portForwardURL(
wildcardHostname,
port.host_port!,
port.host_port,
agent.name,
workspace.name,
workspace.owner_name,
@@ -82,21 +86,19 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
)
: "";
return (
<Tooltip key={portLabel} title={helperText}>
<span>
<Link
key={portLabel}
color="inherit"
component={AgentButton}
underline="none"
startIcon={<ExternalLinkIcon className="size-icon-sm" />}
disabled={!hasHostBind}
href={linkDest}
>
{portLabel}
</Link>
</span>
</Tooltip>
<TooltipProvider key={portLabel}>
<Tooltip>
<TooltipTrigger>
<AgentButton disabled={!hasHostBind} asChild>
<a href={linkDest}>
<ExternalLinkIcon />
{portLabel}
</a>
</AgentButton>
</TooltipTrigger>
<TooltipContent>{helperText}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
})}
</div>
+32 -36
View File
@@ -1,13 +1,17 @@
import { useTheme } from "@emotion/react";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import CircularProgress from "@mui/material/CircularProgress";
import Link from "@mui/material/Link";
import Tooltip from "@mui/material/Tooltip";
import { API } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
import { Spinner } from "components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useProxy } from "contexts/ProxyContext";
import { type FC, type MouseEvent, useState } from "react";
import { type FC, useState } from "react";
import { createAppLinkHref } from "utils/apps";
import { generateRandomString } from "utils/random";
import { AgentButton } from "../AgentButton";
@@ -75,21 +79,7 @@ export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
let primaryTooltip = "";
if (app.health === "initializing") {
icon = (
// This is a hack to make the spinner appear in the center of the start
// icon space
<span
css={{
display: "flex",
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
}}
>
<CircularProgress size={14} />
</span>
);
icon = <Spinner loading />;
primaryTooltip = "Initializing...";
}
if (app.health === "unhealthy") {
@@ -112,22 +102,13 @@ export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
canClick = false;
}
const isPrivateApp = app.sharing_level === "owner";
const canShare = app.sharing_level !== "owner";
return (
<Tooltip title={primaryTooltip}>
<Link
color="inherit"
component={AgentButton}
startIcon={icon}
endIcon={isPrivateApp ? undefined : <ShareIcon app={app} />}
disabled={!canClick}
href={href}
css={{
pointerEvents: canClick ? undefined : "none",
textDecoration: "none !important",
}}
onClick={async (event: MouseEvent<HTMLElement>) => {
const button = (
<AgentButton asChild>
<a
href={canClick ? href : undefined}
onClick={async (event) => {
if (!canClick) {
return;
}
@@ -187,8 +168,23 @@ export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
}
}}
>
{icon}
{appDisplayName}
</Link>
</Tooltip>
{canShare && <ShareIcon app={app} />}
</a>
</AgentButton>
);
if (primaryTooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>{primaryTooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
@@ -1,4 +1,3 @@
import Link from "@mui/material/Link";
import { TerminalIcon } from "components/Icons/TerminalIcon";
import type { FC, MouseEvent } from "react";
import { generateRandomString } from "utils/random";
@@ -39,23 +38,21 @@ export const TerminalLink: FC<TerminalLinkProps> = ({
}/terminal?${params.toString()}`;
return (
<Link
underline="none"
color="inherit"
component={AgentButton}
startIcon={<TerminalIcon />}
href={href}
onClick={(event: MouseEvent<HTMLElement>) => {
event.preventDefault();
window.open(
href,
Language.terminalTitle(generateRandomString(12)),
"width=900,height=600",
);
}}
data-testid="terminal"
>
{DisplayAppNameMap.web_terminal}
</Link>
<AgentButton asChild>
<a
href={href}
onClick={(event: MouseEvent<HTMLElement>) => {
event.preventDefault();
window.open(
href,
Language.terminalTitle(generateRandomString(12)),
"width=900,height=600",
);
}}
>
<TerminalIcon />
{DisplayAppNameMap.web_terminal}
</a>
</AgentButton>
);
};
@@ -1,11 +1,10 @@
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import ButtonGroup from "@mui/material/ButtonGroup";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { API } from "api/api";
import type { DisplayApp } from "api/typesGenerated";
import { VSCodeIcon } from "components/Icons/VSCodeIcon";
import { VSCodeInsidersIcon } from "components/Icons/VSCodeInsidersIcon";
import { ChevronDownIcon } from "lucide-react";
import { type FC, useRef, useState } from "react";
import { AgentButton } from "../AgentButton";
import { DisplayAppNameMap } from "../AppLink/AppLink";
@@ -43,8 +42,8 @@ export const VSCodeDesktopButton: FC<VSCodeDesktopButtonProps> = (props) => {
const includesVSCodeInsiders = props.displayApps.includes("vscode_insiders");
return includesVSCodeDesktop && includesVSCodeInsiders ? (
<div>
<ButtonGroup ref={menuAnchorRef} variant="outlined">
<>
<div ref={menuAnchorRef} className="flex items-center gap-1">
{variant === "vscode" ? (
<VSCodeButton {...props} />
) : (
@@ -58,15 +57,14 @@ export const VSCodeDesktopButton: FC<VSCodeDesktopButtonProps> = (props) => {
aria-expanded={isVariantMenuOpen ? "true" : undefined}
aria-label="select VSCode variant"
aria-haspopup="menu"
disableRipple
onClick={() => {
setIsVariantMenuOpen(true);
}}
css={{ paddingLeft: 0, paddingRight: 0 }}
size="icon-lg"
>
<KeyboardArrowDownIcon css={{ fontSize: 16 }} />
<ChevronDownIcon />
</AgentButton>
</ButtonGroup>
</div>
<Menu
open={isVariantMenuOpen}
@@ -97,7 +95,7 @@ export const VSCodeDesktopButton: FC<VSCodeDesktopButtonProps> = (props) => {
{DisplayAppNameMap.vscode_insiders}
</MenuItem>
</Menu>
</div>
</>
) : includesVSCodeDesktop ? (
<VSCodeButton {...props} />
) : (
@@ -115,7 +113,6 @@ const VSCodeButton: FC<VSCodeDesktopButtonProps> = ({
return (
<AgentButton
startIcon={<VSCodeIcon />}
disabled={loading}
onClick={() => {
setLoading(true);
@@ -145,6 +142,7 @@ const VSCodeButton: FC<VSCodeDesktopButtonProps> = ({
});
}}
>
<VSCodeIcon />
{DisplayAppNameMap.vscode}
</AgentButton>
);
@@ -160,7 +158,6 @@ const VSCodeInsidersButton: FC<VSCodeDesktopButtonProps> = ({
return (
<AgentButton
startIcon={<VSCodeInsidersIcon />}
disabled={loading}
onClick={() => {
setLoading(true);
@@ -189,6 +186,7 @@ const VSCodeInsidersButton: FC<VSCodeDesktopButtonProps> = ({
});
}}
>
<VSCodeInsidersIcon />
{DisplayAppNameMap.vscode_insiders}
</AgentButton>
);
@@ -1,11 +1,10 @@
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import ButtonGroup from "@mui/material/ButtonGroup";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { API } from "api/api";
import type { DisplayApp } from "api/typesGenerated";
import { VSCodeIcon } from "components/Icons/VSCodeIcon";
import { VSCodeInsidersIcon } from "components/Icons/VSCodeInsidersIcon";
import { ChevronDownIcon } from "lucide-react";
import { type FC, useRef, useState } from "react";
import { AgentButton } from "../AgentButton";
import { DisplayAppNameMap } from "../AppLink/AppLink";
@@ -46,8 +45,8 @@ export const VSCodeDevContainerButton: FC<VSCodeDevContainerButtonProps> = (
const includesVSCodeInsiders = props.displayApps.includes("vscode_insiders");
return includesVSCodeDesktop && includesVSCodeInsiders ? (
<div>
<ButtonGroup ref={menuAnchorRef} variant="outlined">
<>
<div ref={menuAnchorRef} className="flex items-center gap-1">
{variant === "vscode" ? (
<VSCodeButton {...props} />
) : (
@@ -61,15 +60,14 @@ export const VSCodeDevContainerButton: FC<VSCodeDevContainerButtonProps> = (
aria-expanded={isVariantMenuOpen ? "true" : undefined}
aria-label="select VSCode variant"
aria-haspopup="menu"
disableRipple
onClick={() => {
setIsVariantMenuOpen(true);
}}
css={{ paddingLeft: 0, paddingRight: 0 }}
size="icon-lg"
>
<KeyboardArrowDownIcon css={{ fontSize: 16 }} />
<ChevronDownIcon />
</AgentButton>
</ButtonGroup>
</div>
<Menu
open={isVariantMenuOpen}
@@ -100,7 +98,7 @@ export const VSCodeDevContainerButton: FC<VSCodeDevContainerButtonProps> = (
{DisplayAppNameMap.vscode_insiders}
</MenuItem>
</Menu>
</div>
</>
) : includesVSCodeDesktop ? (
<VSCodeButton {...props} />
) : (
@@ -119,7 +117,6 @@ const VSCodeButton: FC<VSCodeDevContainerButtonProps> = ({
return (
<AgentButton
startIcon={<VSCodeIcon />}
disabled={loading}
onClick={() => {
setLoading(true);
@@ -147,6 +144,7 @@ const VSCodeButton: FC<VSCodeDevContainerButtonProps> = ({
});
}}
>
<VSCodeIcon />
{DisplayAppNameMap.vscode}
</AgentButton>
);
@@ -163,7 +161,6 @@ const VSCodeInsidersButton: FC<VSCodeDevContainerButtonProps> = ({
return (
<AgentButton
startIcon={<VSCodeInsidersIcon />}
disabled={loading}
onClick={() => {
setLoading(true);
@@ -191,6 +188,7 @@ const VSCodeInsidersButton: FC<VSCodeDevContainerButtonProps> = ({
});
}}
>
<VSCodeInsidersIcon />
{DisplayAppNameMap.vscode_insiders}
</AgentButton>
);
-2
View File
@@ -1,7 +1,5 @@
import type { CSSObject } from "@emotion/react";
type ExternalImageMode = keyof ExternalImageModeStyles;
export interface ExternalImageModeStyles {
/**
* monochrome icons will be flattened to a neutral, theme-appropriate color.