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:
@@ -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";
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user