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:
@@ -55,7 +55,7 @@ function AppInner() {
|
||||
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
|
||||
|
||||
// Background AI sessions
|
||||
const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id);
|
||||
const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id);
|
||||
|
||||
// View state
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
@@ -397,6 +397,13 @@ function AppInner() {
|
||||
|
||||
// Planning mode handlers
|
||||
const handlePlanningOpen = useCallback(() => setIsPlanningOpen(true), []);
|
||||
const handleResumePlanning = useCallback(() => {
|
||||
const session = bgPlanningSessions[0];
|
||||
if (session) {
|
||||
setPlanningResumeSessionId(session.id);
|
||||
setIsPlanningOpen(true);
|
||||
}
|
||||
}, [bgPlanningSessions]);
|
||||
const handlePlanningClose = useCallback(() => {
|
||||
setIsPlanningOpen(false);
|
||||
setPlanningInitialPlan(null);
|
||||
@@ -638,6 +645,8 @@ function AppInner() {
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenGitHubImport={() => setGitHubImportOpen(true)}
|
||||
onOpenPlanning={handlePlanningOpen}
|
||||
onResumePlanning={handleResumePlanning}
|
||||
activePlanningSessionCount={bgPlanningSessions.length}
|
||||
onOpenUsage={handleOpenUsage}
|
||||
onOpenActivityLog={handleOpenActivityLog}
|
||||
onOpenSchedules={handleOpenSchedules}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -5,6 +5,8 @@ interface UseBackgroundSessionsResult {
|
||||
sessions: AiSessionSummary[];
|
||||
generating: number;
|
||||
needsInput: number;
|
||||
/** Active sessions filtered to type === "planning" only */
|
||||
planningSessions: AiSessionSummary[];
|
||||
dismissSession: (id: string) => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
@@ -74,10 +76,13 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
|
||||
(s) => s.status === "generating" || s.status === "awaiting_input"
|
||||
);
|
||||
|
||||
const planningSessions = active.filter((s) => s.type === "planning");
|
||||
|
||||
return {
|
||||
sessions: active,
|
||||
generating: active.filter((s) => s.status === "generating").length,
|
||||
needsInput: active.filter((s) => s.status === "awaiting_input").length,
|
||||
planningSessions,
|
||||
dismissSession,
|
||||
refresh,
|
||||
};
|
||||
|
||||
@@ -381,6 +381,42 @@ body {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Header badge for active sessions */
|
||||
.btn-icon--has-indicator {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--triage);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header-badge--pulse {
|
||||
animation: header-badge-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes header-badge-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.mobile-overflow-item--has-indicator {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* View Toggle */
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user