Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,9 @@ export const AISpendZeroLimit: Story = {
},
};

// Dropdown closed to isolate the avatar border, which reflects spend severity.
// Dropdown closed to isolate the avatar border and severity badge, which
// reflect spend severity. The normal state keeps the standard avatar with no
// badge; only warning/exceeded show the indicator.

export const AvatarBorderDisabled: Story = {
parameters: {
Expand All @@ -238,6 +240,14 @@ export const AvatarBorderNormal: Story = {
...aiCostControl,
queries: [{ key: meAISpendKey, data: mockAISpend }],
},
play: async ({ canvasElement, step }) => {
await step("shows no severity badge for normal spend", async () => {
const canvas = within(canvasElement);
expect(
canvas.queryByText(/AI spend (is nearing its limit|limit exceeded)/),
).not.toBeInTheDocument();
});
},
};

export const AvatarBorderWarning: Story = {
Expand All @@ -250,6 +260,12 @@ export const AvatarBorderWarning: Story = {
},
],
},
play: async ({ canvasElement, step }) => {
await step("shows the warning badge", async () => {
const canvas = within(canvasElement);
await canvas.findByText("AI spend is nearing its limit");
});
},
};

export const AvatarBorderExceeded: Story = {
Expand All @@ -262,6 +278,12 @@ export const AvatarBorderExceeded: Story = {
},
],
},
play: async ({ canvasElement, step }) => {
await step("shows the exceeded badge", async () => {
const canvas = within(canvasElement);
await canvas.findByText("AI spend limit exceeded");
});
},
};

export const AISpendHiddenOnInvalidData: Story = {
Expand Down
70 changes: 52 additions & 18 deletions site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,34 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
import { getSeverity, type UsageSeverity } from "#/utils/budget";
import { cn } from "#/utils/cn";
import { UserDropdownAISpend } from "./UserDropdownAISpend";
import { UserDropdownContent } from "./UserDropdownContent";

const severityBorderClasses = {
normal: "border-content-secondary",
warning: "border-content-warning",
exceeded: "border-content-destructive",
} as const satisfies Record<UsageSeverity, string>;
// The normal state keeps the standard avatar border. Elevated states use a
// thicker border plus a small notification-style dot so the change is
// perceivable without relying on color alone.
const severityIndicators: Partial<
Record<UsageSeverity, { border: string; dot: string; label: string }>
> = {
warning: {
border: "border-2 border-content-warning",
dot: "bg-content-warning",
label: "AI spend is nearing its limit",
},
exceeded: {
border: "border-2 border-content-destructive",
dot: "bg-content-destructive",
label: "AI spend limit exceeded",
},
};

interface UserDropdownProps {
user: TypesGen.User;
Expand Down Expand Up @@ -58,22 +76,38 @@ export const UserDropdown: FC<UserDropdownProps> = ({
spend && spend.spendLimit !== null
? getSeverity(spend.currentSpend, spend.spendLimit)
: "normal";
const indicator = spend ? severityIndicators[severity] : undefined;

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="bg-transparent border-0 cursor-pointer p-0"
>
<Avatar
fallback={user.username}
src={user.avatar_url}
size="lg"
className={spend ? severityBorderClasses[severity] : undefined}
/>
</button>
</DropdownMenuTrigger>
<Tooltip>
<DropdownMenuTrigger asChild>
<TooltipTrigger asChild>
<button
type="button"
className="relative bg-transparent border-0 cursor-pointer p-0"
>
<Avatar
fallback={user.username}
src={user.avatar_url}
size="lg"
className={indicator?.border}
/>
{indicator && (
<span
className={cn(
"absolute -top-0.5 -right-0.5 size-2.5 rounded-full",
indicator.dot,
)}
>
<span className="sr-only">{indicator.label}</span>
</span>
)}
</button>
</TooltipTrigger>
</DropdownMenuTrigger>
{indicator && <TooltipContent>{indicator.label}</TooltipContent>}
</Tooltip>

<DropdownMenuContent align="end" className="min-w-auto w-[260px]">
<UserDropdownContent
Expand Down
Loading