fix: persist session banner dismissals and add hide-banner setting

- Stop overwriting AI session titles with the "Planning session" fallback
  during cross-tab broadcasts before initialPlan has hydrated on resume.
- Persist SessionNotificationBanner dismissals to localStorage keyed by
  updatedAt so they survive refresh and auto-re-show on the next event.
- Add Settings → Appearance toggle to hide the banner entirely.
- Drop the inner scrollbar on the planning question list; let the outer
  pane handle all scrolling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-01 13:08:07 -07:00
parent 2e8180b917
commit 5398bc7fc1
7 changed files with 141 additions and 28 deletions

View File

@@ -0,0 +1,10 @@
---
"@runfusion/fusion": patch
---
Fixes and a new appearance setting for the AI session notification banner and planning mode UI:
- Planning mode question list no longer has its own inner scrollbar nested inside the right pane's scrollbar. The inner `.planning-options` `max-height: 40vh` constraint was removed so longer question lists expand naturally and the outer pane handles all scrolling.
- After a page refresh, the "AI sessions need your input" banner briefly displayed the real session title and then flipped to the literal default "Planning session". `PlanningModeModal` was broadcasting the fallback title via the cross-tab sync channel before `initialPlan` had hydrated on a resumed session, overwriting the API title. The broadcast now omits the title field when no real title is known, so the API title is preserved.
- Banner dismissals are now persisted to `localStorage` keyed by session `updatedAt`. A dismissed entry stays hidden across refreshes until the session advances (a new question/event arrives), at which point the dismissal is auto-pruned and the banner re-appears.
- Added a Settings → Appearance toggle to hide the AI session notification banner entirely.

View File

@@ -26,6 +26,7 @@ import { MobileNavBar } from "./components/MobileNavBar";
import { QuickChatFAB } from "./components/QuickChatFAB"; import { QuickChatFAB } from "./components/QuickChatFAB";
import { ToastContainer } from "./components/ToastContainer"; import { ToastContainer } from "./components/ToastContainer";
import { useBackgroundSessions } from "./hooks/useBackgroundSessions"; import { useBackgroundSessions } from "./hooks/useBackgroundSessions";
import { useSessionBannersHidden } from "./hooks/useSessionBannerPref";
import { useTasks } from "./hooks/useTasks"; import { useTasks } from "./hooks/useTasks";
import { useProjects } from "./hooks/useProjects"; import { useProjects } from "./hooks/useProjects";
import { useNodes } from "./hooks/useNodes"; import { useNodes } from "./hooks/useNodes";
@@ -179,6 +180,7 @@ function AppInner() {
const sessionsNeedingInput = bgSessions.filter( const sessionsNeedingInput = bgSessions.filter(
(session) => session.status === "awaiting_input" || session.status === "error" (session) => session.status === "awaiting_input" || session.status === "error"
); );
const sessionBannersHidden = useSessionBannersHidden();
// Modal state/handlers - required before useViewState // Modal state/handlers - required before useViewState
const modalManager = useModalManager({ const modalManager = useModalManager({
@@ -933,7 +935,7 @@ function AppInner() {
researchView: researchEnabled, researchView: researchEnabled,
}} }}
/> />
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && ( {viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && (
<SessionNotificationBanner <SessionNotificationBanner
sessions={sessionsNeedingInput} sessions={sessionsNeedingInput}
onResumeSession={handleOpenBackgroundSession} onResumeSession={handleOpenBackgroundSession}

View File

@@ -335,7 +335,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
needsInput: false, needsInput: false,
owningTabId: sessionTabId, owningTabId: sessionTabId,
type: "planning", type: "planning",
title: initialPlan.trim() || "Planning session", title: initialPlan.trim() || undefined,
projectId: projectId ?? null, projectId: projectId ?? null,
}); });
}, },
@@ -356,7 +356,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
needsInput: true, needsInput: true,
owningTabId: sessionTabId, owningTabId: sessionTabId,
type: "planning", type: "planning",
title: initialPlan.trim() || "Planning session", title: initialPlan.trim() || undefined,
projectId: projectId ?? null, projectId: projectId ?? null,
}); });
}, },
@@ -379,7 +379,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
needsInput: false, needsInput: false,
owningTabId: sessionTabId, owningTabId: sessionTabId,
type: "planning", type: "planning",
title: initialPlan.trim() || "Planning session", title: initialPlan.trim() || undefined,
projectId: projectId ?? null, projectId: projectId ?? null,
}); });
}, },
@@ -428,7 +428,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
needsInput: false, needsInput: false,
owningTabId: sessionTabId, owningTabId: sessionTabId,
type: "planning", type: "planning",
title: initialPlan.trim() || "Planning session", title: initialPlan.trim() || undefined,
projectId: projectId ?? null, projectId: projectId ?? null,
}); });
broadcastCompleted({ sessionId, status: "error" }); broadcastCompleted({ sessionId, status: "error" });
@@ -611,17 +611,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
void loadSession(resumeSessionId); void loadSession(resumeSessionId);
}, [isOpen, resumeSessionId]); }, [isOpen, resumeSessionId]);
// Re-sync the selected session whenever the modal is reopened. Without this, // Re-sync the selected session whenever the planning screen is shown.
// a session that progressed (or completed) on the server while the modal was // loadSession tears down any existing stream and reconnects, so the right
// closed — or whose terminal SSE event we missed because the stream had been // view always reflects the freshest server state for whatever row is
// torn down on close — keeps showing its stale view (e.g. stuck on "loading" // selected in the sidebar — no stale "loading" frames after a missed
// even though `awaiting_input` is already persisted). Hard reload used to be // terminal SSE event, no divergence from server progress while the modal
// the only fix; this effect makes close+reopen equivalent. // was closed.
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
if (!selectedSessionId) return; if (!selectedSessionId) return;
if (resumeSessionId && resumeSessionId === selectedSessionId) return; // resume effect handles this case if (resumeSessionId && resumeSessionId === selectedSessionId) return; // resume effect handles this case
if (streamConnectionRef.current?.isConnected()) return;
void loadSession(selectedSessionId); void loadSession(selectedSessionId);
// We intentionally do not depend on selectedSessionId or loadSession here: // We intentionally do not depend on selectedSessionId or loadSession here:
// handleSelectSession already drives loadSession when the user picks a // handleSelectSession already drives loadSession when the user picks a

View File

@@ -26,7 +26,36 @@ const TYPE_LABELS = {
slice_interview: "Slice Interview", slice_interview: "Slice Interview",
} as const; } as const;
export const dismissedIds = new Set<string>(); const STORAGE_KEY = "fusion:session-banner-dismissed";
function loadDismissedFromStorage(): Map<string, string> {
if (typeof window === "undefined") return new Map();
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return new Map();
const parsed = JSON.parse(raw) as Record<string, string>;
return new Map(Object.entries(parsed));
} catch {
return new Map();
}
}
function persistDismissed(map: Map<string, string>): void {
if (typeof window === "undefined") return;
try {
const obj: Record<string, string> = {};
for (const [k, v] of map) obj[k] = v;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(obj));
} catch {
// ignore quota / disabled storage
}
}
// Map of sessionId → the updatedAt at which it was dismissed. The banner
// re-shows the session when its updatedAt advances past the recorded value
// (i.e. a new request/question arrived). Persisted to localStorage so
// dismissals survive page refresh.
export const dismissedIds = loadDismissedFromStorage();
export function SessionNotificationBanner({ export function SessionNotificationBanner({
sessions, sessions,
@@ -34,20 +63,31 @@ export function SessionNotificationBanner({
onDismissSession, onDismissSession,
onDismissAll, onDismissAll,
}: SessionNotificationBannerProps) { }: SessionNotificationBannerProps) {
// Bump counter to trigger useMemo recomputation when dismissedIds mutates
const [dismissRevision, setDismissRevision] = useState(0); const [dismissRevision, setDismissRevision] = useState(0);
const bump = () => setDismissRevision((n) => n + 1); const bump = () => {
persistDismissed(dismissedIds);
setDismissRevision((n) => n + 1);
};
// Prune dismissed IDs for sessions that are no longer awaiting_input/error // Prune stored dismissals when sessions advance past the dismissed
// updatedAt (new question arrived) or are no longer in a notify-worthy
// state. This keeps localStorage from accumulating stale entries.
useEffect(() => { useEffect(() => {
if (dismissedIds.size === 0) return; if (dismissedIds.size === 0) return;
const sessionById = new Map(sessions.map((session) => [session.id, session])); const sessionById = new Map(sessions.map((session) => [session.id, session]));
let pruned = false; let pruned = false;
for (const id of dismissedIds) { for (const [id, dismissedAt] of dismissedIds) {
const session = sessionById.get(id); const session = sessionById.get(id);
if (session && session.status !== "awaiting_input" && session.status !== "error") { if (!session) continue;
const stillNotifying = session.status === "awaiting_input" || session.status === "error";
if (!stillNotifying) {
dismissedIds.delete(id);
pruned = true;
continue;
}
if (session.updatedAt && session.updatedAt !== dismissedAt) {
dismissedIds.delete(id); dismissedIds.delete(id);
pruned = true; pruned = true;
} }
@@ -58,12 +98,12 @@ export function SessionNotificationBanner({
const sessionsNeedingInput = useMemo( const sessionsNeedingInput = useMemo(
() => () =>
sessions.filter( sessions.filter((session) => {
(session) => if (session.status !== "awaiting_input" && session.status !== "error") return false;
(session.status === "awaiting_input" || session.status === "error") && const dismissedAt = dismissedIds.get(session.id);
!dismissedIds.has(session.id), if (dismissedAt === undefined) return true;
), return session.updatedAt !== dismissedAt;
// dismissRevision is a stable counter that bumps whenever dismissedIds changes }),
[sessions, dismissRevision], [sessions, dismissRevision],
); );
@@ -83,8 +123,8 @@ export function SessionNotificationBanner({
headerText = `${errorCount} AI session${errorCount === 1 ? "" : "s"} failed`; headerText = `${errorCount} AI session${errorCount === 1 ? "" : "s"} failed`;
} }
const dismissLocally = (id: string) => { const dismissLocally = (session: AiSessionSummary) => {
dismissedIds.add(id); dismissedIds.set(session.id, session.updatedAt ?? "");
bump(); bump();
}; };
@@ -96,7 +136,7 @@ export function SessionNotificationBanner({
const handleDismissAll = () => { const handleDismissAll = () => {
for (const session of sessionsNeedingInput) { for (const session of sessionsNeedingInput) {
dismissedIds.add(session.id); dismissedIds.set(session.id, session.updatedAt ?? "");
} }
bump(); bump();
onDismissAll(); onDismissAll();
@@ -148,7 +188,7 @@ export function SessionNotificationBanner({
<button <button
className="session-notification-banner__dismiss" className="session-notification-banner__dismiss"
onClick={() => { onClick={() => {
dismissLocally(session.id); dismissLocally(session);
onDismissSession(session.id); onDismissSession(session.id);
}} }}
aria-label={`Dismiss ${session.title}`} aria-label={`Dismiss ${session.title}`}

View File

@@ -16,6 +16,7 @@ import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector"; import { ThemeSelector } from "./ThemeSelector";
import { useSessionBannersHidden, setSessionBannersHidden } from "../hooks/useSessionBannerPref";
import "./SettingsModal.css"; import "./SettingsModal.css";
import { CustomModelDropdown } from "./CustomModelDropdown"; import { CustomModelDropdown } from "./CustomModelDropdown";
import { FileEditor } from "./FileEditor"; import { FileEditor } from "./FileEditor";
@@ -332,6 +333,7 @@ export function SettingsModal({
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const settingsContentRef = useRef<HTMLDivElement>(null); const settingsContentRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, true, "fusion:settings-modal-size"); useModalResizePersist(modalRef, true, "fusion:settings-modal-size");
const sessionBannersHidden = useSessionBannersHidden();
const [form, setForm] = useState<SettingsFormState>({ const [form, setForm] = useState<SettingsFormState>({
maxConcurrent: 2, maxConcurrent: 2,
maxTriageConcurrent: 2, maxTriageConcurrent: 2,
@@ -2627,6 +2629,19 @@ export function SettingsModal({
onDashboardFontScaleChange?.(scalePct); onDashboardFontScaleChange?.(scalePct);
}} }}
/> />
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={sessionBannersHidden}
onChange={(e) => setSessionBannersHidden(e.target.checked)}
/>
<span>Hide AI session notification banners</span>
</label>
<small className="form-text text-muted">
Suppress the &ldquo;needs your input&rdquo; banner that appears when AI sessions are awaiting input or have failed.
</small>
</div>
</> </>
); );
case "scheduling": case "scheduling":

View File

@@ -0,0 +1,45 @@
import { useSyncExternalStore } from "react";
const STORAGE_KEY = "fusion:hide-session-banners";
const EVENT_NAME = "fusion:session-banner-pref-changed";
function read(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(STORAGE_KEY) === "1";
} catch {
return false;
}
}
function subscribe(onChange: () => void): () => void {
if (typeof window === "undefined") return () => {};
const handleStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY) onChange();
};
const handleCustom = () => onChange();
window.addEventListener("storage", handleStorage);
window.addEventListener(EVENT_NAME, handleCustom);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(EVENT_NAME, handleCustom);
};
}
export function setSessionBannersHidden(hidden: boolean): void {
if (typeof window === "undefined") return;
try {
if (hidden) {
window.localStorage.setItem(STORAGE_KEY, "1");
} else {
window.localStorage.removeItem(STORAGE_KEY);
}
window.dispatchEvent(new Event(EVENT_NAME));
} catch {
// ignore
}
}
export function useSessionBannersHidden(): boolean {
return useSyncExternalStore(subscribe, read, () => false);
}

View File

@@ -52,6 +52,8 @@ describe("AiSessionStore", () => {
projectId, projectId,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
lockedByTab: null,
lockedAt: null,
}; };
} }