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:
10
.changeset/fix-session-banner-and-planning-scroll.md
Normal file
10
.changeset/fix-session-banner-and-planning-scroll.md
Normal 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.
|
||||
@@ -26,6 +26,7 @@ import { MobileNavBar } from "./components/MobileNavBar";
|
||||
import { QuickChatFAB } from "./components/QuickChatFAB";
|
||||
import { ToastContainer } from "./components/ToastContainer";
|
||||
import { useBackgroundSessions } from "./hooks/useBackgroundSessions";
|
||||
import { useSessionBannersHidden } from "./hooks/useSessionBannerPref";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { useProjects } from "./hooks/useProjects";
|
||||
import { useNodes } from "./hooks/useNodes";
|
||||
@@ -179,6 +180,7 @@ function AppInner() {
|
||||
const sessionsNeedingInput = bgSessions.filter(
|
||||
(session) => session.status === "awaiting_input" || session.status === "error"
|
||||
);
|
||||
const sessionBannersHidden = useSessionBannersHidden();
|
||||
|
||||
// Modal state/handlers - required before useViewState
|
||||
const modalManager = useModalManager({
|
||||
@@ -933,7 +935,7 @@ function AppInner() {
|
||||
researchView: researchEnabled,
|
||||
}}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && (
|
||||
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && (
|
||||
<SessionNotificationBanner
|
||||
sessions={sessionsNeedingInput}
|
||||
onResumeSession={handleOpenBackgroundSession}
|
||||
|
||||
@@ -335,7 +335,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
needsInput: false,
|
||||
owningTabId: sessionTabId,
|
||||
type: "planning",
|
||||
title: initialPlan.trim() || "Planning session",
|
||||
title: initialPlan.trim() || undefined,
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
},
|
||||
@@ -356,7 +356,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
needsInput: true,
|
||||
owningTabId: sessionTabId,
|
||||
type: "planning",
|
||||
title: initialPlan.trim() || "Planning session",
|
||||
title: initialPlan.trim() || undefined,
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
},
|
||||
@@ -379,7 +379,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
needsInput: false,
|
||||
owningTabId: sessionTabId,
|
||||
type: "planning",
|
||||
title: initialPlan.trim() || "Planning session",
|
||||
title: initialPlan.trim() || undefined,
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
},
|
||||
@@ -428,7 +428,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
needsInput: false,
|
||||
owningTabId: sessionTabId,
|
||||
type: "planning",
|
||||
title: initialPlan.trim() || "Planning session",
|
||||
title: initialPlan.trim() || undefined,
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
broadcastCompleted({ sessionId, status: "error" });
|
||||
@@ -611,17 +611,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
void loadSession(resumeSessionId);
|
||||
}, [isOpen, resumeSessionId]);
|
||||
|
||||
// Re-sync the selected session whenever the modal is reopened. Without this,
|
||||
// a session that progressed (or completed) on the server while the modal was
|
||||
// closed — or whose terminal SSE event we missed because the stream had been
|
||||
// torn down on close — keeps showing its stale view (e.g. stuck on "loading"
|
||||
// even though `awaiting_input` is already persisted). Hard reload used to be
|
||||
// the only fix; this effect makes close+reopen equivalent.
|
||||
// Re-sync the selected session whenever the planning screen is shown.
|
||||
// loadSession tears down any existing stream and reconnects, so the right
|
||||
// view always reflects the freshest server state for whatever row is
|
||||
// selected in the sidebar — no stale "loading" frames after a missed
|
||||
// terminal SSE event, no divergence from server progress while the modal
|
||||
// was closed.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!selectedSessionId) return;
|
||||
if (resumeSessionId && resumeSessionId === selectedSessionId) return; // resume effect handles this case
|
||||
if (streamConnectionRef.current?.isConnected()) return;
|
||||
void loadSession(selectedSessionId);
|
||||
// We intentionally do not depend on selectedSessionId or loadSession here:
|
||||
// handleSelectSession already drives loadSession when the user picks a
|
||||
|
||||
@@ -26,7 +26,36 @@ const TYPE_LABELS = {
|
||||
slice_interview: "Slice Interview",
|
||||
} 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({
|
||||
sessions,
|
||||
@@ -34,20 +63,31 @@ export function SessionNotificationBanner({
|
||||
onDismissSession,
|
||||
onDismissAll,
|
||||
}: SessionNotificationBannerProps) {
|
||||
// Bump counter to trigger useMemo recomputation when dismissedIds mutates
|
||||
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(() => {
|
||||
if (dismissedIds.size === 0) return;
|
||||
|
||||
const sessionById = new Map(sessions.map((session) => [session.id, session]));
|
||||
let pruned = false;
|
||||
|
||||
for (const id of dismissedIds) {
|
||||
for (const [id, dismissedAt] of dismissedIds) {
|
||||
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);
|
||||
pruned = true;
|
||||
}
|
||||
@@ -58,12 +98,12 @@ export function SessionNotificationBanner({
|
||||
|
||||
const sessionsNeedingInput = useMemo(
|
||||
() =>
|
||||
sessions.filter(
|
||||
(session) =>
|
||||
(session.status === "awaiting_input" || session.status === "error") &&
|
||||
!dismissedIds.has(session.id),
|
||||
),
|
||||
// dismissRevision is a stable counter that bumps whenever dismissedIds changes
|
||||
sessions.filter((session) => {
|
||||
if (session.status !== "awaiting_input" && session.status !== "error") return false;
|
||||
const dismissedAt = dismissedIds.get(session.id);
|
||||
if (dismissedAt === undefined) return true;
|
||||
return session.updatedAt !== dismissedAt;
|
||||
}),
|
||||
[sessions, dismissRevision],
|
||||
);
|
||||
|
||||
@@ -83,8 +123,8 @@ export function SessionNotificationBanner({
|
||||
headerText = `${errorCount} AI session${errorCount === 1 ? "" : "s"} failed`;
|
||||
}
|
||||
|
||||
const dismissLocally = (id: string) => {
|
||||
dismissedIds.add(id);
|
||||
const dismissLocally = (session: AiSessionSummary) => {
|
||||
dismissedIds.set(session.id, session.updatedAt ?? "");
|
||||
bump();
|
||||
};
|
||||
|
||||
@@ -96,7 +136,7 @@ export function SessionNotificationBanner({
|
||||
|
||||
const handleDismissAll = () => {
|
||||
for (const session of sessionsNeedingInput) {
|
||||
dismissedIds.add(session.id);
|
||||
dismissedIds.set(session.id, session.updatedAt ?? "");
|
||||
}
|
||||
bump();
|
||||
onDismissAll();
|
||||
@@ -148,7 +188,7 @@ export function SessionNotificationBanner({
|
||||
<button
|
||||
className="session-notification-banner__dismiss"
|
||||
onClick={() => {
|
||||
dismissLocally(session.id);
|
||||
dismissLocally(session);
|
||||
onDismissSession(session.id);
|
||||
}}
|
||||
aria-label={`Dismiss ${session.title}`}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ThemeSelector } from "./ThemeSelector";
|
||||
import { useSessionBannersHidden, setSessionBannersHidden } from "../hooks/useSessionBannerPref";
|
||||
import "./SettingsModal.css";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
@@ -332,6 +333,7 @@ export function SettingsModal({
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const settingsContentRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, true, "fusion:settings-modal-size");
|
||||
const sessionBannersHidden = useSessionBannersHidden();
|
||||
const [form, setForm] = useState<SettingsFormState>({
|
||||
maxConcurrent: 2,
|
||||
maxTriageConcurrent: 2,
|
||||
@@ -2627,6 +2629,19 @@ export function SettingsModal({
|
||||
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 “needs your input” banner that appears when AI sessions are awaiting input or have failed.
|
||||
</small>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "scheduling":
|
||||
|
||||
45
packages/dashboard/app/hooks/useSessionBannerPref.ts
Normal file
45
packages/dashboard/app/hooks/useSessionBannerPref.ts
Normal 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);
|
||||
}
|
||||
@@ -52,6 +52,8 @@ describe("AiSessionStore", () => {
|
||||
projectId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lockedByTab: null,
|
||||
lockedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user