feat(FN-1150): add session notification banner for awaiting input

- Add SessionNotificationBanner to list awaiting_input AI sessions with type-specific labels/icons and resume or dismiss actions
- Integrate the banner into App project layout and wire resume handlers for planning, subtask, and mission interview sessions
- Add dismiss-all handling that clears all currently awaiting-input background sessions from the banner
- Style the banner with sticky placement, entry animation, responsive layout, and action button states
- Add component tests covering filtering, count rendering, resume/dismiss callbacks, type metadata, and local dismissal behavior
This commit is contained in:
gsxdsm
2026-04-08 13:18:28 -07:00
parent 67ce1ca2ca
commit fd4ba8ab34
4 changed files with 542 additions and 9 deletions

View File

@@ -8,6 +8,7 @@ import { AgentsView } from "./components/AgentsView";
import { NodesView } from "./components/NodesView";
import { AppModals } from "./components/AppModals";
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
import { MobileNavBar } from "./components/MobileNavBar";
import { QuickChatFAB } from "./components/QuickChatFAB";
import { useBackgroundSessions } from "./hooks/useBackgroundSessions";
@@ -25,6 +26,7 @@ import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
import { useViewState } from "./hooks/useViewState";
import { useProjectActions } from "./hooks/useProjectActions";
import { useTaskHandlers } from "./hooks/useTaskHandlers";
import type { AiSessionSummary } from "./api";
function AppInner() {
const { toasts, addToast, removeToast } = useToast();
@@ -45,6 +47,7 @@ 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 viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
@@ -164,6 +167,22 @@ function AppInner() {
setNodesOpen((prev) => !prev);
}, []);
const handleOpenBackgroundSession = useCallback((session: AiSessionSummary) => {
if (session.type === "planning") {
modalManager.openPlanningWithSession(session.id);
} else if (session.type === "subtask") {
modalManager.openSubtaskWithSession(session.id);
} else if (session.type === "mission_interview") {
modalManager.openMissionWithSession(session.id);
}
}, [modalManager]);
const handleDismissAllNeedingInputSessions = useCallback(() => {
for (const session of sessionsNeedingInput) {
bgDismiss(session.id);
}
}, [bgDismiss, sessionsNeedingInput]);
// Render main content based on view mode
const renderMainContent = () => {
if (nodesOpen) {
@@ -291,6 +310,14 @@ function AppInner() {
projectId={currentProject?.id}
mobileNavEnabled={isMobile}
/>
{viewMode === "project" && currentProject && !nodesOpen && (
<SessionNotificationBanner
sessions={sessionsNeedingInput}
onResumeSession={handleOpenBackgroundSession}
onDismissSession={bgDismiss}
onDismissAll={handleDismissAllNeedingInputSessions}
/>
)}
<div
className={`project-content${viewMode === "project" && currentProject ? " project-content--with-footer" : ""}${isMobile ? " project-content--with-mobile-nav" : ""}`}
>
@@ -304,15 +331,7 @@ function AppInner() {
backgroundSessions={bgSessions}
backgroundGenerating={bgGenerating}
backgroundNeedsInput={bgNeedsInput}
onOpenBackgroundSession={(session) => {
if (session.type === "planning") {
modalManager.openPlanningWithSession(session.id);
} else if (session.type === "subtask") {
modalManager.openSubtaskWithSession(session.id);
} else if (session.type === "mission_interview") {
modalManager.openMissionWithSession(session.id);
}
}}
onOpenBackgroundSession={handleOpenBackgroundSession}
onDismissBackgroundSession={bgDismiss}
/>
)}

View File

@@ -0,0 +1,171 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type { AiSessionSummary } from "../api";
import { SessionNotificationBanner } from "./SessionNotificationBanner";
function buildSession(overrides: Partial<AiSessionSummary>): AiSessionSummary {
return {
id: overrides.id ?? "session-1",
type: overrides.type ?? "planning",
status: overrides.status ?? "awaiting_input",
title: overrides.title ?? "Draft implementation plan",
projectId: overrides.projectId ?? "proj-1",
updatedAt: overrides.updatedAt ?? new Date().toISOString(),
};
}
describe("SessionNotificationBanner", () => {
it("renders nothing when no sessions need input", () => {
const { container } = render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "a", status: "generating" }),
buildSession({ id: "b", status: "complete" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(container.firstChild).toBeNull();
});
it("renders banner with correct awaiting_input count", () => {
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "a", title: "First", status: "awaiting_input" }),
buildSession({ id: "b", title: "Second", status: "awaiting_input" }),
buildSession({ id: "c", title: "Done", status: "complete" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(screen.getByText("2 AI sessions need your input")).toBeInTheDocument();
expect(screen.getByText("First")).toBeInTheDocument();
expect(screen.getByText("Second")).toBeInTheDocument();
expect(screen.queryByText("Done")).not.toBeInTheDocument();
});
it("does not render sessions that are generating or complete", () => {
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "planning", type: "planning", title: "Planning Session", status: "awaiting_input" }),
buildSession({ id: "gen", title: "Generating", status: "generating" }),
buildSession({ id: "complete", title: "Complete", status: "complete" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(screen.getByText("Planning Session")).toBeInTheDocument();
expect(screen.queryByText("Generating")).not.toBeInTheDocument();
expect(screen.queryByText("Complete")).not.toBeInTheDocument();
});
it("calls onResumeSession with the selected session", () => {
const onResumeSession = vi.fn();
const planningSession = buildSession({
id: "planning-1",
type: "planning",
status: "awaiting_input",
title: "Plan checkout flow",
});
render(
<SessionNotificationBanner
sessions={[planningSession]}
onResumeSession={onResumeSession}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
expect(onResumeSession).toHaveBeenCalledWith(planningSession);
});
it("calls onDismissSession with the session id", () => {
const onDismissSession = vi.fn();
render(
<SessionNotificationBanner
sessions={[buildSession({ id: "dismiss-1", title: "Dismiss me" })]}
onResumeSession={vi.fn()}
onDismissSession={onDismissSession}
onDismissAll={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Dismiss Dismiss me" }));
expect(onDismissSession).toHaveBeenCalledWith("dismiss-1");
});
it("calls onDismissAll when clicking dismiss all", () => {
const onDismissAll = vi.fn();
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "a", title: "A" }),
buildSession({ id: "b", title: "B" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={onDismissAll}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /dismiss all/i }));
expect(onDismissAll).toHaveBeenCalledTimes(1);
});
it("shows type labels and icons for planning, subtask, and mission interview", () => {
const { container } = render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "planning", type: "planning", title: "Plan" }),
buildSession({ id: "subtask", type: "subtask", title: "Breakdown" }),
buildSession({ id: "mission", type: "mission_interview", title: "Mission" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(screen.getByText("Planning")).toBeInTheDocument();
expect(screen.getByText("Subtask Breakdown")).toBeInTheDocument();
expect(screen.getByText("Mission Interview")).toBeInTheDocument();
expect(container.querySelector(".lucide-lightbulb")).toBeTruthy();
expect(container.querySelector(".lucide-layers")).toBeTruthy();
expect(container.querySelector(".lucide-target")).toBeTruthy();
});
it("removes dismissed sessions from the banner", () => {
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "first", title: "First Session" }),
buildSession({ id: "second", title: "Second Session" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Dismiss First Session" }));
expect(screen.queryByText("First Session")).not.toBeInTheDocument();
expect(screen.getByText("Second Session")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,146 @@
import { useEffect, useMemo, useState } from "react";
import { AlertCircle, Lightbulb, Layers, Target, X } from "lucide-react";
import type { AiSessionSummary } from "../api";
interface SessionNotificationBannerProps {
sessions: AiSessionSummary[];
onResumeSession: (session: AiSessionSummary) => void;
onDismissSession: (id: string) => void;
onDismissAll: () => void;
}
const TYPE_ICONS = {
planning: Lightbulb,
subtask: Layers,
mission_interview: Target,
} as const;
const TYPE_LABELS = {
planning: "Planning",
subtask: "Subtask Breakdown",
mission_interview: "Mission Interview",
} as const;
export function SessionNotificationBanner({
sessions,
onResumeSession,
onDismissSession,
onDismissAll,
}: SessionNotificationBannerProps) {
const [dismissedSessionIds, setDismissedSessionIds] = useState<Set<string>>(() => new Set());
useEffect(() => {
setDismissedSessionIds((previous) => {
if (previous.size === 0) {
return previous;
}
const sessionById = new Map(sessions.map((session) => [session.id, session]));
const next = new Set<string>();
let changed = false;
for (const id of previous) {
const session = sessionById.get(id);
if (session && session.status === "awaiting_input") {
next.add(id);
} else {
changed = true;
}
}
return changed ? next : previous;
});
}, [sessions]);
const sessionsNeedingInput = useMemo(
() => sessions.filter((session) => session.status === "awaiting_input" && !dismissedSessionIds.has(session.id)),
[dismissedSessionIds, sessions],
);
if (sessionsNeedingInput.length === 0) {
return null;
}
const count = sessionsNeedingInput.length;
const headerText = `${count} AI session${count === 1 ? "" : "s"} need${count === 1 ? "s" : ""} your input`;
const dismissLocally = (id: string) => {
setDismissedSessionIds((previous) => {
const next = new Set(previous);
next.add(id);
return next;
});
};
const handleResume = (session: AiSessionSummary) => {
setDismissedSessionIds((previous) => {
if (!previous.has(session.id)) {
return previous;
}
const next = new Set(previous);
next.delete(session.id);
return next;
});
onResumeSession(session);
};
const handleDismissAll = () => {
setDismissedSessionIds((previous) => {
const next = new Set(previous);
for (const session of sessionsNeedingInput) {
next.add(session.id);
}
return next;
});
onDismissAll();
};
return (
<section className="session-notification-banner" role="region" aria-live="polite" aria-label="AI sessions needing input">
<div className="session-notification-banner__header">
<div className="session-notification-banner__headline">
<AlertCircle size={16} aria-hidden="true" />
<span>{headerText}</span>
</div>
<button className="session-notification-banner__dismiss-all" onClick={handleDismissAll}>
<X size={14} aria-hidden="true" />
<span>Dismiss all</span>
</button>
</div>
<div className="session-notification-banner__list">
{sessionsNeedingInput.map((session) => {
const Icon = TYPE_ICONS[session.type];
return (
<article className="session-notification-banner__item" key={session.id} data-session-type={session.type}>
<div className="session-notification-banner__item-main">
<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>
</div>
</div>
<div className="session-notification-banner__actions">
<button className="session-notification-banner__resume" onClick={() => handleResume(session)}>
Resume
</button>
<button
className="session-notification-banner__dismiss"
onClick={() => {
dismissLocally(session.id);
onDismissSession(session.id);
}}
aria-label={`Dismiss ${session.title}`}
>
<X size={14} aria-hidden="true" />
</button>
</div>
</article>
);
})}
</div>
</section>
);
}

View File

@@ -25460,3 +25460,200 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
justify-content: center;
}
}
/* === Session Notification Banner === */
.session-notification-banner {
position: sticky;
top: 0;
z-index: 900;
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg);
border-bottom: 1px solid var(--border);
border-left: 4px solid var(--triage);
background: var(--surface);
box-sizing: border-box;
max-height: 420px;
opacity: 1;
transition: opacity var(--transition-fast), max-height var(--transition-fast);
animation: session-notification-banner-enter 180ms ease-out;
}
@keyframes session-notification-banner-enter {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 420px;
}
}
.session-notification-banner__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.session-notification-banner__headline {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-weight: 600;
color: var(--text);
}
.session-notification-banner__headline svg {
color: var(--triage);
flex-shrink: 0;
}
.session-notification-banner__dismiss-all {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
border-radius: var(--radius-sm);
padding: 4px 8px;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.session-notification-banner__dismiss-all:hover {
color: var(--text);
background: var(--card-hover);
border-color: var(--border);
}
.session-notification-banner__list {
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
max-height: 300px;
}
.session-notification-banner__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--triage) 8%, var(--surface));
}
.session-notification-banner__item-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.session-notification-banner__type-icon {
color: var(--triage);
flex-shrink: 0;
}
.session-notification-banner__text {
min-width: 0;
}
.session-notification-banner__title {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-notification-banner__meta {
margin: 2px 0 0;
font-size: 12px;
color: var(--text-muted);
}
.session-notification-banner__actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
.session-notification-banner__resume {
border: 1px solid color-mix(in srgb, var(--triage) 60%, var(--border));
background: transparent;
color: var(--triage);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.session-notification-banner__resume:hover {
background: color-mix(in srgb, var(--triage) 14%, transparent);
border-color: var(--triage);
}
.session-notification-banner__dismiss {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.session-notification-banner__dismiss:hover {
color: var(--text);
background: var(--card-hover);
border-color: var(--border);
}
@media (max-width: 768px) {
.session-notification-banner {
padding: var(--space-sm) var(--space-md);
gap: 8px;
}
.session-notification-banner__header {
flex-direction: column;
align-items: flex-start;
}
.session-notification-banner__item {
flex-direction: column;
align-items: flex-start;
}
.session-notification-banner__item-main {
width: 100%;
}
.session-notification-banner__title {
max-width: 100%;
}
.session-notification-banner__actions {
width: 100%;
justify-content: flex-end;
}
}