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 { 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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`}
|
||||||
|
|||||||
@@ -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 “needs your input” banner that appears when AI sessions are awaiting input or have failed.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case "scheduling":
|
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,
|
projectId,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
lockedByTab: null,
|
||||||
|
lockedAt: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user