fix(dashboard): planning mode question display + new-session reset

- Add 8s polling fallback while view is "loading" so a missed SSE
  question/summary event self-heals instead of leaving the panel stuck
  on "thinking" until close+reopen.
- Track dismissed resumeSessionIds in a ref and drop loadSession from
  the resume effect's deps so typing into the textarea no longer
  re-fires resume and yanks the user back to the previous session.
- Guard SSE onThinking/onQuestion/onSummary against late events from a
  torn-down connection by comparing the captured sessionId against
  currentSessionIdRef.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-01 11:40:48 -07:00
parent d63aef925a
commit f0d0f8ce29
2 changed files with 81 additions and 2 deletions

View File

@@ -0,0 +1,8 @@
---
"@runfusion/fusion": patch
---
Fix two issues with question display in planning mode:
- Questions sometimes stayed hidden behind the "thinking" view until the panel was closed and reopened. The live SSE `question` event could be missed (e.g. when the tab was throttled), and the only path that promoted the view was the live event. Add an 8s polling fallback that refetches the session while the view is in the `loading` state and transitions to `question`/`summary` if the server has already moved on, so a dropped event self-heals.
- Clicking "New Session" and then typing into the textarea jumped the panel back to the previous session's questions. The "resume on open" effect listed `loadSession` in its deps; `loadSession` is recreated whenever `connectToPlanningStream` changes, and the latter depends on `initialPlan`, so each keystroke re-ran the resume effect and reloaded the dismissed session. Track dismissed `resumeSessionId`s in a ref and drop `loadSession` from the effect's deps. Also guard the SSE `onThinking`/`onQuestion`/`onSummary` handlers against late events from a stale connection so they can't overwrite the new session's view.

View File

@@ -115,6 +115,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const currentSessionIdRef = useRef<string | null>(null); const currentSessionIdRef = useRef<string | null>(null);
// Tracks resumeSessionId values the user has explicitly dismissed (via "New
// Session"). Without this, the resume effect re-fires on every callback
// identity change (e.g. typing into the textarea recreates loadSession) and
// yanks the user back into the previous session's question view.
const dismissedResumeRef = useRef<string | null>(null);
const [lockSessionId, setLockSessionId] = useState<string | null>(resumeSessionId ?? null); const [lockSessionId, setLockSessionId] = useState<string | null>(resumeSessionId ?? null);
const sessionTabId = useMemo(() => getSessionTabId(), []); const sessionTabId = useMemo(() => getSessionTabId(), []);
const { const {
@@ -188,6 +193,53 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
return () => clearInterval(timer); return () => clearInterval(timer);
}, [view.type]); }, [view.type]);
// Fallback for missed SSE 'question'/'summary' events: when the loading
// state lingers, periodically refetch the session and transition the view
// if the server has already moved past generating. Without this, a dropped
// event leaves the panel stuck on "thinking" until the user closes and
// reopens the modal (which calls loadSession). Eight seconds is short
// enough to feel responsive but long enough to avoid hammering the API
// during normal generation.
useEffect(() => {
if (view.type !== "loading") return;
const sessionId = currentSessionIdRef.current;
if (!sessionId) return;
let cancelled = false;
const tick = async () => {
try {
const session = await fetchAiSession(sessionId);
if (cancelled || !session) return;
if (currentSessionIdRef.current !== sessionId) return;
if (session.status === "awaiting_input" && session.currentQuestion) {
const question = JSON.parse(session.currentQuestion) as PlanningQuestion;
setView({
type: "question",
session: { sessionId, currentQuestion: question, summary: null },
});
setStreamingOutput("");
} else if (session.status === "complete" && session.result) {
const summary = JSON.parse(session.result) as PlanningSummary;
setView({
type: "summary",
session: { sessionId, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
setStreamingOutput("");
}
} catch {
// best-effort; keep polling
}
};
const interval = setInterval(tick, 8000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [view.type]);
const resetDetailState = useCallback(() => { const resetDetailState = useCallback(() => {
setInitialPlan(""); setInitialPlan("");
setView({ type: "initial" }); setView({ type: "initial" });
@@ -266,8 +318,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const connectToPlanningStream = useCallback( const connectToPlanningStream = useCallback(
(sessionId: string) => { (sessionId: string) => {
streamConnectionRef.current?.close(); streamConnectionRef.current?.close();
// Guard handlers against late events from a connection the user has
// already navigated away from (e.g. clicked "New Session" while the
// previous SSE flushed a buffered question). currentSessionIdRef is
// cleared by resetDetailState and reassigned by handleStartPlanning /
// loadSession before each connectToPlanningStream call.
const isStaleEvent = () => currentSessionIdRef.current !== sessionId;
const connection = connectPlanningStream(sessionId, projectId, { const connection = connectPlanningStream(sessionId, projectId, {
onThinking: (data) => { onThinking: (data) => {
if (isStaleEvent()) return;
setStreamingOutput((prev) => prev + data); setStreamingOutput((prev) => prev + data);
broadcastUpdate({ broadcastUpdate({
sessionId, sessionId,
@@ -280,6 +340,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}); });
}, },
onQuestion: (question) => { onQuestion: (question) => {
if (isStaleEvent()) return;
setIsReconnecting(false); setIsReconnecting(false);
setIsRetrying(false); setIsRetrying(false);
clearPlanningDescription(projectId); clearPlanningDescription(projectId);
@@ -300,6 +361,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}); });
}, },
onSummary: (summary) => { onSummary: (summary) => {
if (isStaleEvent()) return;
setIsReconnecting(false); setIsReconnecting(false);
setIsRetrying(false); setIsRetrying(false);
clearPlanningDescription(projectId); clearPlanningDescription(projectId);
@@ -536,13 +598,19 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// Resume the externally-requested session when the modal first opens. // Resume the externally-requested session when the modal first opens.
// (Selecting from the sidebar uses handleSelectSession instead.) // (Selecting from the sidebar uses handleSelectSession instead.)
// Note: loadSession intentionally omitted from deps. It is recreated when
// connectToPlanningStream changes (which depends on initialPlan), so
// including it would re-fire this effect on every keystroke and re-resume
// a session the user already dismissed via "New Session".
useEffect(() => { useEffect(() => {
if (!isOpen || !resumeSessionId) return; if (!isOpen || !resumeSessionId) return;
if (currentSessionIdRef.current === resumeSessionId) return; if (currentSessionIdRef.current === resumeSessionId) return;
if (dismissedResumeRef.current === resumeSessionId) return;
setSelectedSessionId(resumeSessionId); setSelectedSessionId(resumeSessionId);
setMobileShowDetail(true); setMobileShowDetail(true);
void loadSession(resumeSessionId); void loadSession(resumeSessionId);
}, [isOpen, resumeSessionId, loadSession]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, resumeSessionId]);
// Re-sync the selected session whenever the modal is reopened. Without this, // 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 // a session that progressed (or completed) on the server while the modal was
@@ -645,10 +713,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const handleNewSession = useCallback(() => { const handleNewSession = useCallback(() => {
streamConnectionRef.current?.close(); streamConnectionRef.current?.close();
streamConnectionRef.current = null; streamConnectionRef.current = null;
if (resumeSessionId) {
dismissedResumeRef.current = resumeSessionId;
}
resetDetailState(); resetDetailState();
setSelectedSessionId(null); setSelectedSessionId(null);
setMobileShowDetail(true); setMobileShowDetail(true);
}, [resetDetailState]); }, [resetDetailState, resumeSessionId]);
const handleBackToList = useCallback(() => { const handleBackToList = useCallback(() => {
setMobileShowDetail(false); setMobileShowDetail(false);