feat: display the number of idle tasks in the navbar (#19471)

Depends on: https://github.com/coder/coder/pull/19377

Closes https://github.com/coder/coder/issues/19323

**Screenshot:**

<img width="1511" height="777" alt="Screenshot 2025-08-21 at 11 52 21"
src="https://github.com/user-attachments/assets/be04e507-bf04-47d0-8748-2f71b93b5685"
/>

**Screen recording:**


https://github.com/user-attachments/assets/f70b34fe-952b-427b-9bc9-71961ca23201



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- New Features
- Added a Tasks navigation item showing a badge with the number of idle
tasks and a tooltip: “You have X tasks waiting for input.”

- Improvements
  - Fetches per-user tasks with periodic refresh for up-to-date counts.
- Updated active styling for the Tasks link for clearer navigation
state.
  - User menu now always appears on medium+ screens.

- Tests
- Expanded Storybook with preloaded, user-filtered task scenarios to
showcase idle/task states.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Bruno Quaresma
2025-08-22 15:24:32 -03:00
committed by GitHub
parent 6fbe777317
commit cde5b624f4
2 changed files with 139 additions and 28 deletions
@@ -1,13 +1,31 @@
import { chromaticWithTablet } from "testHelpers/chromatic";
import { MockUserMember, MockUserOwner } from "testHelpers/entities";
import {
MockUserMember,
MockUserOwner,
MockWorkspace,
MockWorkspaceAppStatus,
} from "testHelpers/entities";
import { withDashboardProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { userEvent, within } from "storybook/test";
import { NavbarView } from "./NavbarView";
const tasksFilter = {
username: MockUserOwner.username,
};
const meta: Meta<typeof NavbarView> = {
title: "modules/dashboard/NavbarView",
parameters: { chromatic: chromaticWithTablet, layout: "fullscreen" },
parameters: {
chromatic: chromaticWithTablet,
layout: "fullscreen",
queries: [
{
key: ["tasks", tasksFilter],
data: [],
},
],
},
component: NavbarView,
args: {
user: MockUserOwner,
@@ -78,3 +96,36 @@ export const CustomLogo: Story = {
logo_url: "/icon/github.svg",
},
};
export const IdleTasks: Story = {
parameters: {
queries: [
{
key: ["tasks", tasksFilter],
data: [
{
prompt: "Task 1",
workspace: {
...MockWorkspace,
latest_app_status: {
...MockWorkspaceAppStatus,
state: "idle",
},
},
},
{
prompt: "Task 2",
workspace: MockWorkspace,
},
{
prompt: "Task 3",
workspace: {
...MockWorkspace,
latest_app_status: MockWorkspaceAppStatus,
},
},
],
},
],
},
};
@@ -1,13 +1,21 @@
import { API } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { CoderIcon } from "components/Icons/CoderIcon";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import type { ProxyContextValue } from "contexts/ProxyContext";
import { useWebpushNotifications } from "contexts/useWebpushNotifications";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { NotificationsInbox } from "modules/notifications/NotificationsInbox/NotificationsInbox";
import type { FC } from "react";
import { useQuery } from "react-query";
import { NavLink, useLocation } from "react-router";
import { cn } from "utils/cn";
import { DeploymentDropdown } from "./DeploymentDropdown";
@@ -17,7 +25,7 @@ import { UserDropdown } from "./UserDropdown/UserDropdown";
interface NavbarViewProps {
logo_url?: string;
user?: TypesGen.User;
user: TypesGen.User;
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: readonly TypesGen.LinkConfig[];
onSignOut: () => void;
@@ -60,7 +68,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
)}
</NavLink>
<NavItems className="ml-4" />
<NavItems className="ml-4" user={user} />
<div className="flex items-center gap-3 ml-auto">
{proxyContextValue && (
@@ -109,16 +117,14 @@ export const NavbarView: FC<NavbarViewProps> = ({
}
/>
{user && (
<div className="hidden md:block">
<UserDropdown
user={user}
buildInfo={buildInfo}
supportLinks={supportLinks}
onSignOut={onSignOut}
/>
</div>
)}
<div className="hidden md:block">
<UserDropdown
user={user}
buildInfo={buildInfo}
supportLinks={supportLinks}
onSignOut={onSignOut}
/>
</div>
<div className="md:hidden">
<MobileMenu
@@ -140,11 +146,11 @@ export const NavbarView: FC<NavbarViewProps> = ({
interface NavItemsProps {
className?: string;
user: TypesGen.User;
}
const NavItems: FC<NavItemsProps> = ({ className }) => {
const NavItems: FC<NavItemsProps> = ({ className, user }) => {
const location = useLocation();
const { metadata } = useEmbeddedMetadata();
return (
<nav className={cn("flex items-center gap-4 h-full", className)}>
@@ -153,7 +159,7 @@ const NavItems: FC<NavItemsProps> = ({ className }) => {
if (location.pathname.startsWith("/@")) {
isActive = true;
}
return cn(linkStyles.default, isActive ? linkStyles.active : "");
return cn(linkStyles.default, { [linkStyles.active]: isActive });
}}
to="/workspaces"
>
@@ -161,22 +167,76 @@ const NavItems: FC<NavItemsProps> = ({ className }) => {
</NavLink>
<NavLink
className={({ isActive }) => {
return cn(linkStyles.default, isActive ? linkStyles.active : "");
return cn(linkStyles.default, { [linkStyles.active]: isActive });
}}
to="/templates"
>
Templates
</NavLink>
{metadata["tasks-tab-visible"].value && (
<NavLink
className={({ isActive }) => {
return cn(linkStyles.default, isActive ? linkStyles.active : "");
}}
to="/tasks"
>
Tasks
</NavLink>
)}
<TasksNavItem user={user} />
</nav>
);
};
type TasksNavItemProps = {
user: TypesGen.User;
};
const TasksNavItem: FC<TasksNavItemProps> = ({ user }) => {
const { metadata } = useEmbeddedMetadata();
const canSeeTasks = Boolean(
metadata["tasks-tab-visible"].value ||
process.env.NODE_ENV === "development" ||
process.env.STORYBOOK,
);
const filter = {
username: user.username,
};
const { data: idleCount } = useQuery({
queryKey: ["tasks", filter],
queryFn: () => API.experimental.getTasks(filter),
refetchInterval: 1_000 * 60,
enabled: canSeeTasks,
refetchOnWindowFocus: true,
initialData: [],
select: (data) =>
data.filter((task) => task.workspace.latest_app_status?.state === "idle")
.length,
});
if (!canSeeTasks) {
return null;
}
return (
<NavLink
to="/tasks"
className={({ isActive }) => {
return cn(linkStyles.default, { [linkStyles.active]: isActive });
}}
>
Tasks
{idleCount > 0 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="info"
size="xs"
className="ml-2"
aria-label={idleTasksLabel(idleCount)}
>
{idleCount}
</Badge>
</TooltipTrigger>
<TooltipContent>{idleTasksLabel(idleCount)}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</NavLink>
);
};
function idleTasksLabel(count: number) {
return `You have ${count} ${count === 1 ? "task" : "tasks"} waiting for input`;
}