fix: resolve <Alert /> button poor visibility (#22597)

Closes #22244

This pull-request makes our `<Alert />`'s more inline with the Figma
style-system, we're looking to ensure that these are vertically rendered
now and not horizontal WCAG nightmares.

---------

Co-authored-by: Danielle Maywood <danielle@themaywoods.com>
This commit is contained in:
Jake Howell
2026-04-01 16:41:45 +00:00
committed by GitHub
co-authored by Danielle Maywood
parent 7dc81bdef1
commit 4a363b0d85
17 changed files with 117 additions and 173 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ export default meta;
type Story = StoryObj<typeof Alert>;
const ExampleAction = (
<Button onClick={() => null} size="sm" variant="subtle">
<Button onClick={() => null} size="sm">
Button
</Button>
);
+28 -22
View File
@@ -96,31 +96,37 @@ export const Alert: FC<AlertProps> = ({
className={cn(alertVariants({ severity, prominent }), className)}
{...props}
>
<div className="flex items-center justify-between gap-4 text-sm">
<div
className={cn(
"relative flex flex-col justify-between gap-4 text-sm",
dismissible ? "pr-8" : undefined,
)}
>
<div className="flex min-w-0 flex-1 flex-row items-start gap-3">
<Icon className={cn("size-icon-sm mt-[3px]", iconClassName)} />
<div className="min-w-0 flex-1">{children}</div>
</div>
<div className="flex items-center gap-2">
{actions}
{dismissible && (
<Button
variant="subtle"
size="icon"
className="!size-auto !min-w-0 !p-0"
onClick={() => {
setOpen(false);
onDismiss?.();
}}
data-testid="dismiss-banner-btn"
aria-label="Dismiss"
>
<XIcon className="!p-0" />
</Button>
)}
<div className="flex min-w-0 flex-1 flex-col gap-4">
<div className="min-w-0 flex-1">{children}</div>
{actions && (
<div className="flex items-center gap-2">{actions}</div>
)}
</div>
</div>
</div>
{dismissible && (
<Button
variant="subtle"
size="icon"
className="size-8 !min-w-0 !p-0 absolute top-3 right-3"
onClick={() => {
setOpen(false);
onDismiss?.();
}}
data-testid="dismiss-banner-btn"
aria-label="Dismiss"
>
<XIcon className="!p-0 size-icon-lg" />
</Button>
)}
</div>
);
};
@@ -139,5 +145,5 @@ export const AlertTitle: React.FC<React.ComponentPropsWithRef<"h2">> = ({
className,
...props
}) => {
return <h2 className={cn("m-0 text-sm font-medium", className)} {...props} />;
return <h2 className={cn("m-0 text-sm", className)} {...props} />;
};
@@ -21,7 +21,7 @@ export default meta;
type Story = StoryObj<typeof ErrorAlert>;
const ExampleAction = (
<Button onClick={() => null} size="sm" variant="subtle">
<Button onClick={() => null} size="sm">
Button
</Button>
);
@@ -75,6 +75,7 @@ const ErrorScriptAlert: FC = () => {
)}
target="_blank"
rel="noreferrer"
className="mx-0"
>
startup script has exited with an error
</Link>
@@ -164,7 +165,7 @@ const TerminalAlert: FC<AlertProps> = (props) => {
<Alert
{...props}
className={cn(
"rounded-none border-0 border-b border-l-[3px] border-b-border-default bg-surface-primary mb-px [&>div]:items-center",
"rounded-none border-0 border-b border-l-[3px] border-b-border-default bg-surface-primary mb-px",
severityBorderColors[severity],
)}
/>
@@ -192,7 +193,6 @@ const RefreshSessionButton: FC = () => {
<Button
disabled={isRefreshing}
size="sm"
variant="subtle"
onClick={() => {
setIsRefreshing(true);
window.location.reload();
@@ -2,7 +2,7 @@ import type { FC, FormEvent } from "react";
import { useMemo, useState } from "react";
import TextareaAutosize from "react-textarea-autosize";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import { Link } from "#/components/Link/Link";
import { Switch } from "#/components/Switch/Switch";
@@ -278,9 +278,11 @@ export const AgentSettingsBehaviorPageView: FC<
/>
{userInvisibleCharCount > 0 && (
<Alert severity="warning">
This text contains {userInvisibleCharCount} invisible Unicode{" "}
{userInvisibleCharCount !== 1 ? "characters" : "character"} that
could hide content. These will be stripped on save.
<AlertDescription>
This text contains {userInvisibleCharCount} invisible Unicode{" "}
{userInvisibleCharCount !== 1 ? "characters" : "character"} that
could hide content. These will be stripped on save.
</AlertDescription>
</Alert>
)}
<div className="flex justify-end gap-2">
@@ -375,9 +377,12 @@ export const AgentSettingsBehaviorPageView: FC<
/>
{systemInvisibleCharCount > 0 && (
<Alert severity="warning">
This text contains {systemInvisibleCharCount} invisible Unicode{" "}
{systemInvisibleCharCount !== 1 ? "characters" : "character"}{" "}
that could hide content. These will be stripped on save.
<AlertDescription>
This text contains {systemInvisibleCharCount} invisible
Unicode{" "}
{systemInvisibleCharCount !== 1 ? "characters" : "character"}{" "}
that could hide content. These will be stripped on save.
</AlertDescription>
</Alert>
)}
<div className="flex justify-end gap-2">
@@ -22,7 +22,7 @@ import {
} from "react";
import type * as TypesGen from "#/api/typesGenerated";
import type { ChatMessagePart, ChatQueuedMessage } from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import {
Command,
@@ -683,9 +683,11 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
{invisibleCharCount > 0 && (
<div className="px-3 pb-1">
<Alert severity="warning">
This message contains {invisibleCharCount} invisible Unicode
character{invisibleCharCount !== 1 ? "s" : ""} that could hide
content. Review carefully before sending.
<AlertDescription>
This message contains {invisibleCharCount} invisible Unicode
character{invisibleCharCount !== 1 ? "s" : ""} that could hide
content. Review carefully before sending.
</AlertDescription>
</Alert>
</div>
)}
@@ -3,7 +3,7 @@ import { Link } from "react-router";
import { toast } from "sonner";
import { isApiError } from "#/api/errors";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import { useDashboard } from "#/modules/dashboard/useDashboard";
@@ -320,14 +320,15 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
isUsageLimitData(createError.response.data) ? (
<Alert
severity="info"
className="py-2"
actions={
<Button asChild variant="subtle" size="sm">
<Button asChild size="sm">
<Link to="/agents/analytics">View Usage</Link>
</Button>
}
>
{formatUsageLimitMessage(createError.response.data)}
<AlertDescription>
{formatUsageLimitMessage(createError.response.data)}
</AlertDescription>
</Alert>
) : (
<ErrorAlert error={createError} />
@@ -1,5 +1,5 @@
import type { FC } from "react";
import { Alert } from "#/components/Alert/Alert";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import { Link } from "#/components/Link/Link";
import { docs } from "#/utils/docs";
@@ -12,28 +12,21 @@ export const ChatAccessDeniedAlert: FC = () => {
return (
<Alert
severity="info"
className="py-2"
actions={
<div className="flex gap-2">
<Button
variant="subtle"
size="sm"
onClick={() => window.location.reload()}
>
Refresh
</Button>
<Link href={docsLink} target="_blank" rel="noreferrer" size="sm">
View Docs
</Link>
</div>
<Button size="sm" onClick={() => window.location.reload()}>
Refresh
</Button>
}
>
<p className="m-0 font-medium">Permission required</p>
<p className="m-0 mt-1 text-sm text-content-secondary">
<AlertTitle>Permission required</AlertTitle>
<AlertDescription>
You don't have permission to use Coder Agents. Contact your Coder
administrator for access. Refresh this page after access has been
granted.
</p>
granted.{" "}
<Link href={docsLink} target="_blank" rel="noreferrer">
View Docs
</Link>
</AlertDescription>
</Alert>
);
};
@@ -1,10 +1,8 @@
import { ExternalLinkIcon } from "lucide-react";
import { type FC, useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import { Pill } from "#/components/Pill/Pill";
import { Link } from "#/components/Link/Link";
import { Response, Shimmer } from "../ChatElements";
import { getKindLabel, getProviderStatusURL } from "./chatStatusHelpers";
import { getProviderStatusURL } from "./chatStatusHelpers";
import type { LiveStatusModel } from "./liveStatusModel";
const RESPONSE_STARTUP_GRACE_MS = 15_000;
@@ -116,76 +114,58 @@ const StatusCountdown: FC<{
const StatusAlert: FC<{ status: RetryOrFailedStatus }> = ({ status }) => {
const statusURL = getProviderStatusURL(status.kind, status.provider);
const pillType =
status.phase === "failed"
? "error"
: status.kind === "generic"
? "inactive"
: "warning";
const severity =
status.phase === "failed"
? "error"
: status.kind === "generic"
? "info"
: "warning";
const hasMetadata =
status.phase === "retrying" ||
(status.phase === "failed" && status.statusCode !== undefined);
const metadataItems: React.ReactNode[] = [];
if (status.phase === "retrying" && status.retryingAt) {
metadataItems.push(
<StatusCountdown
key="countdown"
deadline={status.retryingAt}
label="Retrying in"
/>,
);
}
if (status.phase === "retrying") {
metadataItems.push(<span key="attempt">Attempt {status.attempt}</span>);
}
if (status.phase === "failed" && status.statusCode !== undefined) {
metadataItems.push(<span key="code">HTTP {status.statusCode}</span>);
}
return (
<Alert
severity={severity}
className="py-3"
actions={
statusURL && (
<Button asChild variant="subtle" size="sm">
<a href={statusURL} target="_blank" rel="noreferrer">
Status
<ExternalLinkIcon />
</a>
</Button>
)
metadataItems.length > 0 ? (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-content-secondary">
{metadataItems}
</div>
) : undefined
}
>
<div className="space-y-2.5">
<div className="flex flex-wrap items-center gap-2">
<AlertTitle>{status.title}</AlertTitle>
<Pill
className="h-5 px-2.5 text-[10px] font-semibold"
type={pillType}
>
{getKindLabel(status.kind)}
</Pill>
</div>
<AlertDescription>{status.message}</AlertDescription>
{hasMetadata && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-content-secondary">
{status.phase === "retrying" && status.retryingAt && (
<StatusCountdown
deadline={status.retryingAt}
label="Retrying in"
/>
)}
{status.phase === "retrying" && (
<span>Attempt {status.attempt}</span>
)}
{status.phase === "failed" && status.statusCode !== undefined && (
<span>HTTP {status.statusCode}</span>
)}
</div>
<AlertTitle>{status.title}</AlertTitle>
<AlertDescription>
{status.message}{" "}
{statusURL && (
<Link href={statusURL} target="_blank" rel="noreferrer">
Status
</Link>
)}
</div>
</AlertDescription>
</Alert>
);
};
const ReconnectingAlert: FC<{ status: ReconnectingStatus }> = ({ status }) => {
return (
<Alert severity="info" className="py-3">
<div className="space-y-2.5">
<AlertTitle>{status.title}</AlertTitle>
<AlertDescription>{status.message}</AlertDescription>
<Alert
severity="info"
actions={
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-content-secondary">
<StatusCountdown
deadline={status.retryingAt}
@@ -193,7 +173,10 @@ const ReconnectingAlert: FC<{ status: ReconnectingStatus }> = ({ status }) => {
/>
<span>Attempt {status.attempt}</span>
</div>
</div>
}
>
<AlertTitle>{status.title}</AlertTitle>
<AlertDescription>{status.message}</AlertDescription>
</Alert>
);
};
@@ -92,7 +92,6 @@ export const TerminalOverloadedError: Story = {
expect(
canvas.getByRole("heading", { name: /service overloaded/i }),
).toBeVisible();
expect(canvas.getByText("Overloaded")).toBeVisible();
expect(
canvas.getByText(/anthropic is currently overloaded./i),
).toBeVisible();
@@ -122,7 +121,6 @@ export const TerminalStartupTimeoutError: Story = {
expect(
canvas.getByRole("heading", { name: /startup timed out/i }),
).toBeVisible();
expect(canvas.getByText("Startup timeout")).toBeVisible();
expect(
canvas.getByText(/anthropic did not start responding in time./i),
).toBeVisible();
@@ -1,7 +1,7 @@
import { Link } from "react-router";
import type { UrlTransform } from "streamdown";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import type { ChatDetailError } from "../../utils/usageLimitMessage";
import { ChatStatusCallout } from "./ChatStatusCallout";
@@ -92,14 +92,13 @@ export const LiveStreamTailContent = ({
{usageLimitStatus ? (
<Alert
severity="info"
className="py-2"
actions={
<Button asChild variant="subtle" size="sm">
<Button asChild size="sm">
<Link to="/agents/analytics">View Usage</Link>
</Button>
}
>
{usageLimitStatus.message}
<AlertDescription>{usageLimitStatus.message}</AlertDescription>
</Alert>
) : terminalStatus ? (
<ChatStatusCallout status={terminalStatus} />
@@ -95,7 +95,6 @@ export const RetryWithVisibleReason: Story = {
expect(
canvas.getByText(/anthropic returned an unexpected error/i),
).toBeVisible();
expect(canvas.getByText("Unexpected error")).toBeVisible();
expect(canvas.getByText(/attempt 1/i)).toBeVisible();
expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument();
expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument();
@@ -125,7 +124,6 @@ export const RetryRateLimited: Story = {
expect(
canvas.getByText(/anthropic is rate limiting requests/i),
).toBeVisible();
expect(canvas.getByText("Rate limit")).toBeVisible();
await waitFor(() => {
expect(canvasElement.textContent).toMatch(/retrying in \d+s/i);
});
@@ -160,7 +158,6 @@ export const RetryInvalidTimestamp: Story = {
expect(
canvas.getByText(/anthropic is rate limiting requests/i),
).toBeVisible();
expect(canvas.getByText("Rate limit")).toBeVisible();
expect(canvas.getByText(/attempt 3/i)).toBeVisible();
await waitFor(() => {
expect(canvas.queryByText(/retrying in nan/i)).not.toBeInTheDocument();
@@ -192,7 +189,6 @@ export const RetryOverloaded: Story = {
expect(
canvas.getByText(/anthropic is temporarily overloaded/i),
).toBeVisible();
expect(canvas.getByText("Overloaded")).toBeVisible();
const statusLink = screen.getByRole("link", { name: /status/i });
expect(statusLink).toBeVisible();
expect(statusLink).toHaveAttribute("href", "https://status.anthropic.com");
@@ -221,7 +217,6 @@ export const RetryTimeout: Story = {
expect(
canvas.getByText(/anthropic is temporarily unavailable/i),
).toBeVisible();
expect(canvas.getByText("Timeout")).toBeVisible();
expect(
canvas.queryByRole("link", { name: /status/i }),
).not.toBeInTheDocument();
@@ -250,7 +245,6 @@ export const RetryStartupTimeout: Story = {
expect(
canvas.getByText(/anthropic did not start responding in time/i),
).toBeVisible();
expect(canvas.getByText("Startup timeout")).toBeVisible();
expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument();
expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument();
expect(
@@ -23,19 +23,6 @@ const normalizeProvider = (provider?: string): string | undefined => {
}
};
const humanizeKind = (kind: string): string => {
const words = kind
.trim()
.split(/[_\-\s]+/)
.filter(Boolean);
if (words.length === 0) {
return "Unexpected error";
}
return words
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
export const getErrorTitle = (
kind: ChatProviderFailureKind | (string & {}),
mode: "retry" | "error",
@@ -58,29 +45,6 @@ export const getErrorTitle = (
}
};
export const getKindLabel = (
kind: ChatProviderFailureKind | (string & {}),
): string => {
switch (kind) {
case "generic":
return "Unexpected error";
case "overloaded":
return "Overloaded";
case "rate_limit":
return "Rate limit";
case "timeout":
return "Timeout";
case "startup_timeout":
return "Startup timeout";
case "auth":
return "Authentication";
case "config":
return "Configuration";
default:
return humanizeKind(kind);
}
};
export const getProviderStatusURL = (
kind: ChatProviderFailureKind | (string & {}),
provider?: string,
@@ -71,7 +71,7 @@ export const NotificationEvents: FC<NotificationEventsProps> = ({
severity="warning"
prominent
actions={
<Button variant="subtle" size="sm" asChild>
<Button size="sm" asChild>
<a
target="_blank"
rel="noreferrer"
@@ -91,7 +91,7 @@ export const NotificationEvents: FC<NotificationEventsProps> = ({
severity="warning"
prominent
actions={
<Button variant="subtle" size="sm" asChild>
<Button size="sm" asChild>
<a
target="_blank"
rel="noreferrer"
+2 -1
View File
@@ -1,5 +1,4 @@
import { useTheme } from "@emotion/react";
import Link from "@mui/material/Link";
import {
CircleAlertIcon,
CircleCheckIcon,
@@ -14,6 +13,7 @@ import {
type ReactElement,
} from "react";
import type { HealthCode, HealthSeverity } from "#/api/typesGenerated";
import { Link } from "#/components/Link/Link";
import { docs } from "#/utils/docs";
import { healthyColor } from "./healthyColor";
@@ -242,6 +242,7 @@ export const HealthMessageDocsLink: FC<HealthMessageDocsLinkProps> = ({
href={docs(`/admin/monitoring/health-check#${code.toLocaleLowerCase()}`)}
target="_blank"
rel="noreferrer"
className="mx-0"
>
Docs for {code}
</Link>
@@ -23,7 +23,7 @@ import type {
VariableValue,
WorkspaceResource,
} from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { Alert, AlertTitle } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import { Sidebar } from "#/components/FullPageLayout/Sidebar";
import {
@@ -306,16 +306,14 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
prominent
dismissible
actions={
<Button
variant="subtle"
size="sm"
onClick={onCreateWorkspace}
>
<Button size="sm" onClick={onCreateWorkspace}>
Create a workspace
</Button>
}
>
Successfully published {publishedVersion.name}!
<AlertTitle>
Successfully published {publishedVersion.name}!
</AlertTitle>
</Alert>
</div>
)}
@@ -10,7 +10,7 @@ export const WorkspaceDeletedBanner: FC<WorkspaceDeletedBannerProps> = ({
handleClick,
}) => {
const NewWorkspaceButton = (
<Button onClick={handleClick} size="sm" variant="subtle">
<Button onClick={handleClick} size="sm">
Create new workspace
</Button>
);