[f309463f] Systematic tooltip and aria-label pass across the entire panel (#484)

* [001c9a7a] Author tooltip/aria-label spec for the panel (#469) (#473)

* [001c9a7a] docs(ux_ui): add tooltip/aria-label classification spec for panel controls

* [001c9a7a] docs(ux_ui): commit missing tooltip/aria-label spec content

Prior commit's message claimed to add the spec but only touched
unrelated generated lifecycle prompt files — the actual spec file was
never git-added. This commits the real content.

---------

Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>

* [dbe222aa] Implement tooltip and aria-label sweep across all panel surfaces (#478)

* [6f991331] Add aria-label + matching tooltip per tooltip-aria-label-spec.md (#476)

* [6f991331] feat(panel): add aria-label + matching tooltip to 8 icon-only controls per tooltip-aria-label-spec.md §1a/§1b, wrap assignee-avatar initials in a full-name tooltip

* [6f991331] docs(accessibility): add icon-only controls pattern guide for aria-label + matching tooltip

Documented the implemented pattern for accessible icon-only controls across 8 components (bell, back-arrow, menu, toggle, drag-handle, move-forward, settings, review-link) plus the assignee-avatar tooltip. Covers when to apply the pattern, naming conventions, state-dependent labels, testing approach, and rationale for local TooltipProvider scope.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [e34da833] Fix notification-bell.tsx and assignee-avatar.tsx, re-verify all 9 claimed tooltip/aria-label retrofits (#480)

* [e34da833] test(notifications): add regression coverage confirming the bell button's aria-label/title/Tooltip and re-verify the other 8 tooltip-aria-label-spec controls by direct file read

* [e34da833] docs(ux_ui): update tooltip-aria-label-spec.md status to "implemented" with test coverage summary

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [09414273] fix(header): wrap refresh button in Tooltip; correct spec.md and accessible-icon-buttons.md doc-accuracy issues (#483)

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [f309463f] fix: missing tooltip/Link/ArrowLeft imports + dedupe command-center tooltip import, drop redundant native title on refresh button, reflow doc prose

- kanban-card.tsx, header.tsx: import TooltipProvider (used but undefined -> eslint react/jsx-no-undef, blocked Panel lint + QA image panel build)
- task-header.tsx: import Link (next/link) and ArrowLeft (lucide-react) for the back button tooltip
- command-center.tsx: remove the duplicate tooltip primitive import block (kept the one with TooltipProvider; tsc duplicate-identifier)
- header.tsx: drop native title= on the refresh button now that a Radix Tooltip carries the hint (header test expects no native title)
- docs/frontend/components/accessible-icon-buttons.md: reflow hard-wrapped prose (python gate make reflow-docs)

* [f309463f] chore: regenerate lifecycle artifacts + verb tables (reconcile after master merge)

The branch's generated intro prose in agents/prompts/_generated/lifecycle-*.md
and verbs.md had drifted to unwrapped lines (master is wrapped). The foundation-
check gate (make lifecycle + regenerate_verb_tables + git diff --exit-code) caught
the drift. Re-rendered via the canonical generators; no hand-edits.

* [f309463f] Close remaining a11y gaps: aria-labels on task-table row-expand + pagination, titles on work-session truncated task-id/branch, secretary Start loading label

---------

Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-13 06:38:48 +02:00
committed by GitHub
co-authored by UX/UI Developer 1 Frontend Developer 1 Frontend Documenter Renn F
parent 1114ee5ea0
commit 192524265c
18 changed files with 668 additions and 213 deletions
@@ -285,6 +285,7 @@ export function SecretaryTab() {
onClick={() => void handleStart()}
disabled={starting}
className="h-11 shrink-0 px-6"
aria-label={starting ? "Starting…" : undefined}
>
{starting ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -23,16 +23,19 @@ import { RoadmapReviewQueue } from "./roadmap-review-queue";
import { StrategySignalsPanel } from "./strategy-signals-panel";
import type { Activity } from "./activity-item";
import { Button } from "@/components/ui/button";
import { UsageOverviewPanel } from "./usage-overview-panel";
import { ScorecardOverviewPanel } from "./scorecard-overview-panel";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { UsageOverviewPanel } from "./usage-overview-panel";
import { ScorecardOverviewPanel } from "./scorecard-overview-panel";
import { Settings, AlertCircle } from "lucide-react";
import Link from "next/link";
const SETTINGS_LABEL = "Open settings";
export function CommandCenter() {
const {
data: overview,
@@ -108,16 +111,23 @@ export function CommandCenter() {
</p>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Link href="/settings" prefetch={false}>
<Button variant="ghost" size="icon">
<Settings className="h-5 w-5" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>Open settings</TooltipContent>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link href="/settings" prefetch={false}>
<Button
variant="ghost"
size="icon"
aria-label={SETTINGS_LABEL}
title={SETTINGS_LABEL}
>
<Settings className="h-5 w-5" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>{SETTINGS_LABEL}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
@@ -21,6 +21,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
GitPullRequest,
ExternalLink,
@@ -203,15 +209,23 @@ export function PrReviewQueue({ className }: PrReviewQueueProps) {
)}
</div>
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
<Link
href={`/tasks/${task.id}`}
title="Review details"
prefetch={false}
>
<Button variant="ghost" size="sm">
<FileText className="h-4 w-4" />
</Button>
</Link>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link href={`/tasks/${task.id}`} prefetch={false}>
<Button
variant="ghost"
size="sm"
aria-label="Review details"
title="Review details"
>
<FileText className="h-4 w-4" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>Review details</TooltipContent>
</Tooltip>
</TooltipProvider>
{awaiting && (
<>
<Button
@@ -0,0 +1,75 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
// tooltip-aria-label-spec.md §1a: the move-forward button already carried a
// conditional `title` (disabled-vs-enabled); it needs a matching aria-label
// using the identical text, per §2's "same string for both" rule.
vi.mock("@/hooks/use-tasks", () => ({
useUpdateTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
import { KanbanCard } from "../kanban-card";
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "11111111-1111-1111-1111-111111111111",
title: "A task",
description: "",
acceptance_criteria: [],
status: TaskStatus.IN_PROGRESS,
priority: 2,
sequence: null,
team: Team.BACKEND,
assigned_to: null,
task_type: TaskType.CODE,
...overrides,
} as unknown as Task;
}
describe("KanbanCard — move-forward aria-label (tooltip-aria-label-spec §1a)", () => {
it("uses 'Move forward' as both aria-label and title when the task is actionable", () => {
render(
<KanbanCard
task={buildTask()}
onAction={vi.fn()}
showQaActions={false}
/>,
);
const button = screen.getByRole("button", { name: "Move forward" });
expect(button).toHaveAttribute("title", "Move forward");
expect(button).not.toBeDisabled();
});
it("swaps to the disabled-reason text on both aria-label and title for a backlog task", () => {
render(
<KanbanCard
task={buildTask({ status: TaskStatus.BACKLOG })}
onAction={vi.fn()}
showQaActions={false}
/>,
);
const button = screen.getByRole("button", {
name: "PM must activate this task first",
});
expect(button).toHaveAttribute("title", "PM must activate this task first");
expect(button).toBeDisabled();
});
it("gives the drag handle an accessible name", () => {
render(
<KanbanCard
task={buildTask()}
onAction={vi.fn()}
showQaActions={false}
/>,
);
expect(
screen.getByLabelText("Drag to move task between columns"),
).toBeInTheDocument();
});
});
@@ -14,6 +14,7 @@ import {
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PriorityIndicator } from "../shared/priority-indicator";
@@ -66,6 +67,11 @@ export function KanbanCard({
const isDragging = isDraggingProp || isDraggingDnd;
const dragHandleLabel = "Drag to move task between columns";
const moveForwardLabel = isBacklog
? "PM must activate this task first"
: "Move forward";
const style = transform
? {
transform: CSS.Translate.toString(transform),
@@ -118,22 +124,22 @@ export function KanbanCard({
</p>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div
{...attributes}
{...listeners}
className={`shrink-0 mt-0.5 ${isBacklog ? "cursor-not-allowed opacity-50" : "cursor-grab active:cursor-grabbing"}`}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent>
{isBacklog
? "Backlog tasks can't be dragged — activate first"
: "Drag to another column to change status"}
</TooltipContent>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
{...attributes}
{...listeners}
aria-label={dragHandleLabel}
title={dragHandleLabel}
className={`shrink-0 mt-0.5 ${isBacklog ? "cursor-not-allowed opacity-50" : "cursor-grab active:cursor-grabbing"}`}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent>{dragHandleLabel}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center justify-between mt-2">
@@ -262,27 +268,27 @@ export function KanbanCard({
) : (
task.status !== TaskStatus.COMPLETED &&
task.status !== TaskStatus.CANCELLED && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={(e) => {
e.stopPropagation();
onAction("move-forward", task.id);
}}
disabled={isBacklog}
>
<ArrowRight className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
{isBacklog
? "PM must activate this task first"
: "Advance to the next lifecycle stage"}
</TooltipContent>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={(e) => {
e.stopPropagation();
onAction("move-forward", task.id);
}}
disabled={isBacklog}
aria-label={moveForwardLabel}
title={moveForwardLabel}
>
<ArrowRight className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>{moveForwardLabel}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
)}
</div>
@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AssigneeAvatar } from "../assignee-avatar";
// tooltip-aria-label-spec.md §1b: truncated content (two-letter initials)
// standing in for a full value needs a Tooltip disclosing that full value.
describe("AssigneeAvatar — full-name tooltip (tooltip-aria-label-spec §1b)", () => {
it("shows the full agent display name in the tooltip once hovered", async () => {
const user = userEvent.setup();
render(<AssigneeAvatar agentId="fe-dev-1" />);
// Radix mounts TooltipContent only once the trigger is hovered/focused.
await user.hover(screen.getByText("FD1"));
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"Frontend Dev 1",
);
});
it("renders nothing for an unassigned task", () => {
const { container } = render(<AssigneeAvatar agentId={null} />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -4,9 +4,10 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
import { getAgentInitials, getAgentDisplayName } from "@/lib/agent-utils";
interface AssigneeAvatarProps {
agentId: string | null;
@@ -17,20 +18,21 @@ export function AssigneeAvatar({ agentId, size = "sm" }: AssigneeAvatarProps) {
if (!agentId) return null;
const initials = getAgentInitials(agentId);
const displayName = getAgentDisplayName(agentId);
const sizeClasses = size === "sm" ? "h-6 w-6 text-xs" : "h-8 w-8 text-sm";
return (
<Tooltip>
<TooltipTrigger asChild>
<Avatar className={sizeClasses}>
<AvatarFallback className="bg-primary/10 text-primary">
{initials}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>
Assigned to {getAgentDisplayName(agentId)}
</TooltipContent>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Avatar className={sizeClasses}>
<AvatarFallback className="bg-primary/10 text-primary">
{initials}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>{displayName}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -1,88 +1,35 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { SidebarNav, SidebarFooter, navItems } from "../sidebar";
import { render, screen, fireEvent } from "@testing-library/react";
import { useUIStore } from "@/store";
// tooltip-aria-label-spec.md §1a: the collapse-rail toggle previously had no
// accessible name at all. The label must track the current action (collapse
// vs. expand), not a static string, so a screen reader announces what will
// happen next.
vi.mock("next/navigation", () => ({
usePathname: () => "/overview",
}));
const EXPECTED_ORDER = [
"/overview",
"/prompter",
"/tasks",
"/kanban",
"/git",
"/projects",
"/products",
"/social",
"/knowledge-base",
"/a2a",
"/agents",
"/journals",
"/auditor",
"/metrics",
];
import { Sidebar } from "../sidebar";
describe("navItems", () => {
it("is a single flat array in the exact expected order", () => {
expect(navItems.map((item) => item.href)).toEqual(EXPECTED_ORDER);
describe("Sidebar — collapse toggle aria-label (tooltip-aria-label-spec §1a)", () => {
it("labels the toggle 'Collapse sidebar' when expanded", () => {
useUIStore.setState({ sidebarCollapsed: false });
render(<Sidebar />);
const toggle = screen.getByRole("button", { name: "Collapse sidebar" });
expect(toggle).toHaveAttribute("title", "Collapse sidebar");
});
it("does not include Business", () => {
expect(navItems.some((item) => item.href === "/business")).toBe(false);
});
});
it("flips to 'Expand sidebar' once collapsed", () => {
useUIStore.setState({ sidebarCollapsed: false });
render(<Sidebar />);
describe("SidebarNav", () => {
it("renders no dividers — the nav list itself has no group separators", () => {
const { container } = render(<SidebarNav />);
expect(
container.querySelectorAll('[data-slot="separator"]'),
).toHaveLength(0);
});
it("renders every nav item as a link, in order", () => {
render(<SidebarNav />);
const links = screen.getAllByRole("link");
expect(links.map((link) => link.getAttribute("href"))).toEqual(
EXPECTED_ORDER,
);
});
it("renders correctly when collapsed (icon-only, no layout break)", () => {
render(<SidebarNav collapsed />);
expect(screen.getAllByRole("link")).toHaveLength(navItems.length);
expect(screen.queryByText("Overview")).not.toBeInTheDocument();
});
});
describe("SidebarFooter", () => {
it("renders Business immediately before AI Providers, with Settings last", () => {
render(<SidebarFooter />);
const links = screen.getAllByRole("link");
expect(links.map((link) => link.getAttribute("href"))).toEqual([
"/business",
"/settings/ai-providers",
"/settings",
]);
});
fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
it("renders no Separator — the wrapper's border-t is the single divider (a Separator doubled it)", () => {
const expanded = render(<SidebarFooter />);
expect(
expanded.container.querySelectorAll('[data-slot="separator"]'),
).toHaveLength(0);
expanded.unmount();
const collapsed = render(<SidebarFooter collapsed />);
expect(
collapsed.container.querySelectorAll('[data-slot="separator"]'),
).toHaveLength(0);
});
it("renders correctly when collapsed (icon-only)", () => {
render(<SidebarFooter collapsed />);
expect(screen.getAllByRole("link")).toHaveLength(3);
expect(screen.queryByText("Business")).not.toBeInTheDocument();
screen.getByRole("button", { name: "Expand sidebar" }),
).toBeInTheDocument();
});
});
+21 -16
View File
@@ -17,11 +17,14 @@ import { MobileSidebar } from "./mobile-sidebar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { usePageRefresh } from "@/hooks";
const REFRESH_LABEL = "Refresh only the current page";
export function Header() {
const { setTheme } = useTheme();
const { refresh, loading, disabled } = usePageRefresh();
@@ -54,22 +57,24 @@ export function Header() {
<ConnectionStatus />
{/* Refresh current page data */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => void refresh()}
disabled={disabled || loading}
aria-label="Refresh only the current page"
>
<RefreshCw
className={cn("h-5 w-5", loading && "animate-spin")}
/>
</Button>
</TooltipTrigger>
<TooltipContent>Refresh this page&apos;s data</TooltipContent>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => void refresh()}
disabled={disabled || loading}
aria-label={REFRESH_LABEL}
>
<RefreshCw
className={cn("h-5 w-5", loading && "animate-spin")}
/>
</Button>
</TooltipTrigger>
<TooltipContent>{REFRESH_LABEL}</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Theme toggle */}
<DropdownMenu>
+24 -23
View File
@@ -29,6 +29,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useUIStore } from "@/store";
@@ -154,6 +155,7 @@ export function SidebarFooter({
export function Sidebar() {
const { sidebarCollapsed, setSidebarCollapsed } = useUIStore();
const toggleLabel = sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar";
return (
<aside
@@ -184,29 +186,28 @@ export function Sidebar() {
<span className="font-semibold text-lg">RoboCo</span>
</Link>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className={cn(sidebarCollapsed && "mx-auto")}
aria-label={
sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"
}
>
<ChevronLeft
className={cn(
"h-4 w-4 transition-transform",
sidebarCollapsed && "rotate-180",
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
</TooltipContent>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className={cn(sidebarCollapsed && "mx-auto")}
aria-label={toggleLabel}
title={toggleLabel}
>
<ChevronLeft
className={cn(
"h-4 w-4 transition-transform",
sidebarCollapsed && "rotate-180",
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent>{toggleLabel}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Navigation */}
@@ -0,0 +1,38 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { NotificationBell } from "../notification-bell";
// tooltip-aria-label-spec.md §1a: the bell button's only visible content is
// an icon — it needs a mandatory aria-label, plus a matching visible
// Tooltip using the identical string per §2.
vi.mock("@/hooks/use-websocket", () => ({
useNotificationStream: () => ({
notifications: [],
isConnected: false,
isConnecting: false,
clearMessages: () => {},
}),
}));
describe("NotificationBell — aria-label + tooltip (tooltip-aria-label-spec §1a)", () => {
it("exposes 'View notifications' as the bell button's accessible name and title", () => {
render(<NotificationBell />);
const button = screen.getByRole("button", { name: "View notifications" });
expect(button).toHaveAttribute("title", "View notifications");
});
it("shows a matching visible tooltip once hovered", async () => {
const { default: userEvent } = await import("@testing-library/user-event");
const user = userEvent.setup();
render(<NotificationBell />);
await user.hover(
screen.getByRole("button", { name: "View notifications" }),
);
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"View notifications",
);
});
});
@@ -13,10 +13,13 @@ import {
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Bell, Wifi, WifiOff } from "lucide-react";
const BELL_LABEL = "View notifications";
export function NotificationBell() {
const [open, setOpen] = useState(false);
const { notifications, isConnected, clearMessages } = useNotificationStream();
@@ -25,30 +28,29 @@ export function NotificationBell() {
return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative"
aria-label="Notifications"
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs bg-red-500">
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
{unreadCount > 0
? `${unreadCount} unread notification${unreadCount === 1 ? "" : "s"}`
: "Notifications"}
</TooltipContent>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative"
aria-label={BELL_LABEL}
title={BELL_LABEL}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs bg-red-500">
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{BELL_LABEL}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent className="w-80" align="end">
<div className="space-y-4">
<div className="flex items-center justify-between">
+25 -5
View File
@@ -22,6 +22,12 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
MoreHorizontal,
Play,
@@ -138,14 +144,28 @@ export function TaskActions({
// Check if task is in backlog (needs PM activation)
const isBacklog = task.status === TaskStatus.BACKLOG;
const actionsLabel = "Open task actions menu";
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={actionsLabel}
title={actionsLabel}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>{actionsLabel}</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="end">
{/* Edit option */}
{showEdit && canEdit && (
@@ -1,6 +1,7 @@
"use client";
import { useState, useRef, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Task, TaskStatus, Team } from "@/types";
import {
@@ -50,6 +51,7 @@ import {
ThumbsUp,
ThumbsDown,
RotateCcw,
ArrowLeft,
} from "lucide-react";
import { toast } from "sonner";
import { TaskTypeBadge } from "../task-type-badge";
@@ -57,9 +59,12 @@ import { CopyButton } from "@/components/ui/copy-button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const BACK_LABEL = "Go back to tasks list";
// Status badge colors
const statusColors: Record<TaskStatus, string> = {
[TaskStatus.BACKLOG]:
@@ -479,6 +484,24 @@ export function TaskHeader({ task, onAction, nav }: TaskHeaderProps) {
truncates, so a long title never pushes the controls or the
Actions menu out of place. */}
<div className="flex items-start gap-3 min-w-0 flex-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link href="/tasks" prefetch={false}>
<Button
variant="ghost"
size="icon"
className="shrink-0"
aria-label={BACK_LABEL}
title={BACK_LABEL}
>
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>{BACK_LABEL}</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="min-w-0 flex-1">
{/* Row 1: title only — editable, no UUID. Truncates on overflow. */}
{editingTitle ? (
@@ -593,6 +593,7 @@ export function TaskTable({
variant="ghost"
size="icon-sm"
className="p-0.5 h-5 w-5 shrink-0"
aria-label={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
@@ -848,6 +849,7 @@ export function TaskTable({
className="h-8 w-8"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</Button>
@@ -857,6 +859,7 @@ export function TaskTable({
className="h-8 w-8"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
@@ -129,6 +129,7 @@ export function WorkSessionTable({
prefetch={false}
href={`/tasks/${session.task_id}`}
className="text-sm text-muted-foreground hover:text-foreground hover:underline"
title={session.task_id}
>
{session.task_id.slice(0, 8)}...
</Link>
@@ -183,6 +184,7 @@ export function WorkSessionTable({
prefetch={false}
href={`/work-sessions/${session.id}`}
className="truncate font-mono text-sm font-medium hover:underline"
title={session.branch_name}
>
{session.branch_name}
</Link>
@@ -207,6 +209,7 @@ export function WorkSessionTable({
prefetch={false}
href={`/tasks/${session.task_id}`}
className="text-muted-foreground hover:text-foreground hover:underline"
title={session.task_id}
>
{session.task_id.slice(0, 8)}...
</Link>