feat(FN-934): add active planning session badge to Header

- Add badge indicator to Planning button showing count of active background planning sessions
- Integrate useBackgroundSessions hook in App and Header components
- Add badge styles with pulse animation for active session indicator
- Add comprehensive tests for badge rendering and session count display
This commit is contained in:
gsxdsm
2026-04-04 15:10:11 -07:00
parent ea4fe6fe0f
commit dbd79ecb6e
5 changed files with 176 additions and 7 deletions

View File

@@ -344,6 +344,75 @@ describe("Header", () => {
renderHeader({ onOpenPlanning: vi.fn() }, "desktop");
expect(screen.getByTestId("planning-btn")).toBeDefined();
});
describe("active session badge", () => {
it("does not render badge when activePlanningSessionCount is 0", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 0 }, "desktop");
expect(screen.queryByTestId("planning-badge")).toBeNull();
});
it("does not render badge when activePlanningSessionCount is undefined", () => {
renderHeader({ onOpenPlanning: vi.fn() }, "desktop");
expect(screen.queryByTestId("planning-badge")).toBeNull();
});
it("renders badge when activePlanningSessionCount > 0", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 1 }, "desktop");
expect(screen.getByTestId("planning-badge")).toBeDefined();
});
it("badge shows correct count", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 3 }, "desktop");
expect(screen.getByTestId("planning-badge").textContent).toBe("3");
});
it("updates title to 'Resume planning session' when count > 0", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 1 }, "desktop");
expect(screen.getByTitle("Resume planning session")).toBeDefined();
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
});
it("keeps original title when count is 0", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 0 }, "desktop");
expect(screen.getByTitle("Create a task with AI planning")).toBeDefined();
});
it("calls onResumePlanning when clicked with active sessions", () => {
const onResumePlanning = vi.fn();
const onOpenPlanning = vi.fn();
renderHeader({ onOpenPlanning, onResumePlanning, activePlanningSessionCount: 2 }, "desktop");
fireEvent.click(screen.getByTitle("Resume planning session"));
expect(onResumePlanning).toHaveBeenCalled();
expect(onOpenPlanning).not.toHaveBeenCalled();
});
it("calls onOpenPlanning when clicked with no active sessions", () => {
const onResumePlanning = vi.fn();
const onOpenPlanning = vi.fn();
renderHeader({ onOpenPlanning, onResumePlanning, activePlanningSessionCount: 0 }, "desktop");
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
expect(onOpenPlanning).toHaveBeenCalled();
expect(onResumePlanning).not.toHaveBeenCalled();
});
it("calls onOpenPlanning when clicked with active sessions but no onResumePlanning", () => {
const onOpenPlanning = vi.fn();
renderHeader({ onOpenPlanning, activePlanningSessionCount: 1 }, "desktop");
// Without onResumePlanning, falls back to onOpenPlanning even with active sessions
fireEvent.click(screen.getByTitle("Resume planning session"));
expect(onOpenPlanning).toHaveBeenCalled();
});
it("badge has correct aria-label", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 2 }, "desktop");
expect(screen.getByTestId("planning-badge").getAttribute("aria-label")).toBe("2 active planning sessions");
});
it("badge aria-label uses singular for count of 1", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 1 }, "desktop");
expect(screen.getByTestId("planning-badge").getAttribute("aria-label")).toBe("1 active planning session");
});
});
});
describe("mobile overflow menu", () => {
@@ -539,6 +608,35 @@ describe("Header", () => {
expect(screen.getByTestId("overflow-planning-btn")).toBeDefined();
});
it("shows planning badge in overflow menu when activePlanningSessionCount > 0", () => {
renderHeader({ onOpenPlanning: noop, activePlanningSessionCount: 1 }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-planning-badge")).toBeDefined();
expect(screen.getByTestId("overflow-planning-badge").textContent).toBe("1");
});
it("does not show planning badge in overflow menu when count is 0", () => {
renderHeader({ onOpenPlanning: noop, activePlanningSessionCount: 0 }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.queryByTestId("overflow-planning-badge")).toBeNull();
});
it("calls onResumePlanning from overflow menu when active sessions exist", () => {
const onResumePlanning = vi.fn();
const onOpenPlanning = vi.fn();
renderHeader({ onOpenPlanning, onResumePlanning, activePlanningSessionCount: 2 }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-planning-btn"));
expect(onResumePlanning).toHaveBeenCalled();
expect(onOpenPlanning).not.toHaveBeenCalled();
});
it("shows resume text in overflow menu when active sessions exist", () => {
renderHeader({ onOpenPlanning: noop, activePlanningSessionCount: 1 }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByText("Resume planning session (1)")).toBeDefined();
});
it("shows settings in overflow menu on mobile", () => {
renderHeader({}, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));

View File

@@ -24,6 +24,10 @@ export interface HeaderProps {
onOpenSettings?: () => void;
onOpenGitHubImport?: () => void;
onOpenPlanning?: () => void;
/** Resume an in-flight planning session. Takes priority over onOpenPlanning when activePlanningSessionCount > 0 */
onResumePlanning?: () => void;
/** Number of active planning sessions. When > 0, shows a badge on the Planning button. */
activePlanningSessionCount?: number;
onOpenUsage?: () => void;
onOpenActivityLog?: () => void;
onOpenSchedules?: () => void;
@@ -95,6 +99,8 @@ export function Header({
onOpenSettings,
onOpenGitHubImport,
onOpenPlanning,
onResumePlanning,
activePlanningSessionCount = 0,
onOpenUsage,
onOpenActivityLog,
onOpenSchedules,
@@ -405,12 +411,22 @@ export function Header({
{!isCompact && (
<button
className="btn-icon"
onClick={onOpenPlanning}
title="Create a task with AI planning"
className={`btn-icon${activePlanningSessionCount > 0 ? " btn-icon--has-indicator" : ""}`}
onClick={activePlanningSessionCount > 0 && onResumePlanning ? onResumePlanning : onOpenPlanning}
title={activePlanningSessionCount > 0 ? "Resume planning session" : "Create a task with AI planning"}
data-testid="planning-btn"
style={{ position: "relative" }}
>
<Lightbulb size={16} />
{activePlanningSessionCount > 0 && (
<span
className="header-badge header-badge--pulse"
data-testid="planning-badge"
aria-label={`${activePlanningSessionCount} active planning session${activePlanningSessionCount !== 1 ? "s" : ""}`}
>
{activePlanningSessionCount}
</span>
)}
</button>
)}
@@ -569,13 +585,18 @@ export function Header({
</button>
)}
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenPlanning)}
className={`mobile-overflow-item${activePlanningSessionCount > 0 ? " mobile-overflow-item--has-indicator" : ""}`}
onClick={() => handleOverflowAction(activePlanningSessionCount > 0 && onResumePlanning ? onResumePlanning : onOpenPlanning)}
role="menuitem"
data-testid="overflow-planning-btn"
>
<Lightbulb size={16} />
<span>Create a task with AI planning</span>
<span>{activePlanningSessionCount > 0 ? `Resume planning session (${activePlanningSessionCount})` : "Create a task with AI planning"}</span>
{activePlanningSessionCount > 0 && (
<span className="header-badge header-badge--pulse" data-testid="overflow-planning-badge">
{activePlanningSessionCount}
</span>
)}
</button>
{/* Git Manager - in overflow on mobile */}
{onOpenGitManager && (