fix(FN-1620): show error sessions in notification banner

- Update BackgroundTasksIndicator to display error sessions count alongside active sessions
- Include error sessions in App filter for notification banner visibility
- Add session cancellation for mission interviews on banner dismiss
- Add comprehensive test coverage for SessionNotificationBanner component
- Update useBackgroundSessions hook to expose error session counts
This commit is contained in:
gsxdsm
2026-04-12 13:46:32 -07:00
parent 724bb7931a
commit 51f31f099a
6 changed files with 204 additions and 17 deletions

View File

@@ -106,7 +106,9 @@ function AppInner() {
// Background AI sessions
const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id);
const sessionsNeedingInput = bgSessions.filter((session) => session.status === "awaiting_input");
const sessionsNeedingInput = bgSessions.filter(
(session) => session.status === "awaiting_input" || session.status === "error"
);
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useMemo } from "react";
import { Lightbulb, Layers, Target, Loader2, HelpCircle, X, Lock } from "lucide-react";
import { Lightbulb, Layers, Target, Loader2, HelpCircle, X, Lock, AlertCircle } from "lucide-react";
import type { AiSessionSummary } from "../api";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { getSessionTabId } from "../utils/getSessionTabId";
@@ -132,6 +132,7 @@ export function BackgroundTasksIndicator({
const Icon = TYPE_ICONS[session.type];
const isGenerating = session.status === "generating";
const isAwaiting = session.status === "awaiting_input";
const isError = session.status === "error";
const activeTab = activeTabMap.get(session.id);
const owningTabId = activeTab?.tabId ?? session.lockedByTab ?? null;
const activeElsewhere = Boolean(
@@ -162,13 +163,17 @@ export function BackgroundTasksIndicator({
setPopoverOpen(false);
}}
>
<Icon size={14} className="background-tasks-indicator__session-icon" />
{isError ? (
<AlertCircle size={14} className="background-tasks-indicator__session-icon" style={{ color: "var(--color-error)" }} />
) : (
<Icon size={14} className="background-tasks-indicator__session-icon" />
)}
<div className="background-tasks-indicator__session-content">
<div className="background-tasks-indicator__session-title">
{session.title}
</div>
<div className="background-tasks-indicator__session-meta">
{TYPE_LABELS[session.type]}
{isError ? "Failed" : TYPE_LABELS[session.type]}
{isGenerating && " — generating..."}
{isAwaiting && !activeElsewhere && " — needs input"}
{isAwaiting && activeElsewhere && " — active in another tab"}

View File

@@ -168,4 +168,145 @@ describe("SessionNotificationBanner", () => {
expect(screen.queryByText("First Session")).not.toBeInTheDocument();
expect(screen.getByText("Second Session")).toBeInTheDocument();
});
it("renders error sessions in the banner", () => {
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "error-session", type: "mission_interview", title: "Failed Mission", status: "error" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(screen.getByText("Failed Mission")).toBeInTheDocument();
expect(screen.getByText("Failed")).toBeInTheDocument();
});
it("shows 'Retry' button for error sessions instead of 'Resume'", () => {
const errorSession = buildSession({
id: "error-1",
type: "mission_interview",
status: "error",
title: "Error Session",
});
render(
<SessionNotificationBanner
sessions={[errorSession]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Resume" })).not.toBeInTheDocument();
});
it("calls onResumeSession when clicking Retry on error session", () => {
const onResumeSession = vi.fn();
const errorSession = buildSession({
id: "error-retry",
type: "mission_interview",
status: "error",
title: "Error to Retry",
});
render(
<SessionNotificationBanner
sessions={[errorSession]}
onResumeSession={onResumeSession}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(onResumeSession).toHaveBeenCalledWith(errorSession);
});
it("shows combined header text for both awaiting_input and error sessions", () => {
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "awaiting", status: "awaiting_input", title: "Awaiting Input" }),
buildSession({ id: "error", status: "error", title: "Error Session" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(screen.getByText("1 AI session needs your input, 1 failed")).toBeInTheDocument();
});
it("shows error count only when no awaiting_input sessions", () => {
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "error1", status: "error", title: "Error 1" }),
buildSession({ id: "error2", status: "error", title: "Error 2" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(screen.getByText("2 AI sessions failed")).toBeInTheDocument();
});
it("dismisses error sessions from the banner", () => {
const onDismissSession = vi.fn();
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "error-dismiss", status: "error", title: "Error to Dismiss" }),
]}
onResumeSession={vi.fn()}
onDismissSession={onDismissSession}
onDismissAll={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Dismiss Error to Dismiss" }));
expect(onDismissSession).toHaveBeenCalledWith("error-dismiss");
});
it("preserves dismissed error sessions when session status changes", () => {
const { rerender } = render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "session-1", status: "error", title: "Error Session" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
// Dismiss the error session
fireEvent.click(screen.getByRole("button", { name: "Dismiss Error Session" }));
expect(screen.queryByText("Error Session")).not.toBeInTheDocument();
// Simulate session status changing to complete (should NOT reappear)
rerender(
<SessionNotificationBanner
sessions={[
buildSession({ id: "session-1", status: "complete", title: "Error Session" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
// Dismissed session should still be hidden even though status changed
expect(screen.queryByText("Error Session")).not.toBeInTheDocument();
});
});

View File

@@ -45,7 +45,8 @@ export function SessionNotificationBanner({
for (const id of previous) {
const session = sessionById.get(id);
if (session && session.status === "awaiting_input") {
// Preserve dismissed IDs for awaiting_input and error sessions
if (session && (session.status === "awaiting_input" || session.status === "error")) {
next.add(id);
} else {
changed = true;
@@ -57,7 +58,12 @@ export function SessionNotificationBanner({
}, [sessions]);
const sessionsNeedingInput = useMemo(
() => sessions.filter((session) => session.status === "awaiting_input" && !dismissedSessionIds.has(session.id)),
() =>
sessions.filter(
(session) =>
(session.status === "awaiting_input" || session.status === "error") &&
!dismissedSessionIds.has(session.id),
),
[dismissedSessionIds, sessions],
);
@@ -65,8 +71,17 @@ export function SessionNotificationBanner({
return null;
}
const count = sessionsNeedingInput.length;
const headerText = `${count} AI session${count === 1 ? "" : "s"} need${count === 1 ? "s" : ""} your input`;
const awaitingInputCount = sessionsNeedingInput.filter((s) => s.status === "awaiting_input").length;
const errorCount = sessionsNeedingInput.filter((s) => s.status === "error").length;
let headerText = "";
if (awaitingInputCount > 0 && errorCount > 0) {
headerText = `${awaitingInputCount} AI session${awaitingInputCount === 1 ? "" : "s"} need${awaitingInputCount === 1 ? "s" : ""} your input, ${errorCount} failed`;
} else if (awaitingInputCount > 0) {
headerText = `${awaitingInputCount} AI session${awaitingInputCount === 1 ? "" : "s"} need${awaitingInputCount === 1 ? "s" : ""} your input`;
} else if (errorCount > 0) {
headerText = `${errorCount} AI session${errorCount === 1 ? "" : "s"} failed`;
}
const dismissLocally = (id: string) => {
setDismissedSessionIds((previous) => {
@@ -100,7 +115,7 @@ export function SessionNotificationBanner({
};
return (
<section className="session-notification-banner" role="region" aria-live="polite" aria-label="AI sessions needing input">
<section className="session-notification-banner" role="region" aria-live="polite" aria-label="AI sessions needing input or failed">
<div className="session-notification-banner__header">
<div className="session-notification-banner__headline">
<AlertCircle size={16} aria-hidden="true" />
@@ -115,20 +130,32 @@ export function SessionNotificationBanner({
<div className="session-notification-banner__list">
{sessionsNeedingInput.map((session) => {
const Icon = TYPE_ICONS[session.type];
const isError = session.status === "error";
return (
<article className="session-notification-banner__item" key={session.id} data-session-type={session.type}>
<article
className={`session-notification-banner__item${isError ? " session-notification-banner__item--error" : ""}`}
key={session.id}
data-session-type={session.type}
data-session-status={session.status}
>
<div className="session-notification-banner__item-main">
<Icon size={16} className="session-notification-banner__type-icon" aria-hidden="true" />
{isError ? (
<AlertCircle size={16} className="session-notification-banner__type-icon session-notification-banner__type-icon--error" aria-hidden="true" />
) : (
<Icon size={16} className="session-notification-banner__type-icon" aria-hidden="true" />
)}
<div className="session-notification-banner__text">
<p className="session-notification-banner__title" title={session.title}>{session.title}</p>
<p className="session-notification-banner__meta">{TYPE_LABELS[session.type]}</p>
<p className="session-notification-banner__meta">
{isError ? "Failed" : TYPE_LABELS[session.type]}
</p>
</div>
</div>
<div className="session-notification-banner__actions">
<button className="session-notification-banner__resume" onClick={() => handleResume(session)}>
Resume
{isError ? "Retry" : "Resume"}
</button>
<button
className="session-notification-banner__dismiss"

View File

@@ -18,12 +18,14 @@ vi.mock("../../api", () => ({
deleteAiSession: vi.fn(),
cancelPlanning: vi.fn(),
cancelSubtaskBreakdown: vi.fn(),
cancelMissionInterview: vi.fn(),
}));
const mockFetchAiSessions = vi.mocked(apiModule.fetchAiSessions);
const mockDeleteAiSession = vi.mocked(apiModule.deleteAiSession);
const mockCancelPlanning = vi.mocked(apiModule.cancelPlanning);
const mockCancelSubtaskBreakdown = vi.mocked(apiModule.cancelSubtaskBreakdown);
const mockCancelMissionInterview = vi.mocked(apiModule.cancelMissionInterview);
function makeSession(overrides: Partial<apiModule.AiSessionSummary> & Pick<apiModule.AiSessionSummary, "id">): apiModule.AiSessionSummary {
return {
@@ -46,6 +48,7 @@ describe("useBackgroundSessions", () => {
mockDeleteAiSession.mockResolvedValue(undefined);
mockCancelPlanning.mockResolvedValue(undefined);
mockCancelSubtaskBreakdown.mockResolvedValue(undefined);
mockCancelMissionInterview.mockResolvedValue(undefined);
});
afterEach(() => {
@@ -206,7 +209,7 @@ describe("useBackgroundSessions", () => {
expect(mockDeleteAiSession).toHaveBeenCalledWith("subtask-session");
});
it("dismissSession does not call cancel for mission_interview sessions", async () => {
it("dismissSession calls cancelMissionInterview for mission_interview sessions", async () => {
mockFetchAiSessions.mockResolvedValueOnce([
makeSession({ id: "interview-session", status: "generating", type: "mission_interview" }),
]);
@@ -221,8 +224,7 @@ describe("useBackgroundSessions", () => {
await result.current.dismissSession("interview-session");
});
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockCancelSubtaskBreakdown).not.toHaveBeenCalled();
expect(mockCancelMissionInterview).toHaveBeenCalledWith("interview-session", undefined, expect.any(String));
expect(mockDeleteAiSession).toHaveBeenCalledWith("interview-session");
});

View File

@@ -4,6 +4,7 @@ import {
deleteAiSession,
cancelPlanning,
cancelSubtaskBreakdown,
cancelMissionInterview,
type AiSessionSummary,
} from "../api";
import { useAiSessionSync } from "./useAiSessionSync";
@@ -241,8 +242,17 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
console.warn(`[useBackgroundSessions] Cannot dismiss subtask session ${id}: locked by another tab`);
}
}
} else if (sessionType === "mission_interview") {
try {
await cancelMissionInterview(id, projectId, sessionTabId);
} catch (err: unknown) {
cancelFailed = true;
if (err instanceof Error && err.message.includes("locked")) {
lockConflict = true;
console.warn(`[useBackgroundSessions] Cannot dismiss mission interview session ${id}: locked by another tab`);
}
}
}
// For other session types (mission_interview, etc.), just delete without cancellation
// Only proceed with deletion if cancellation succeeded or wasn't needed
if (cancelFailed && !lockConflict) {