diff --git a/.changeset/fn-6975-mission-modal-stream.md b/.changeset/fn-6975-mission-modal-stream.md new file mode 100644 index 0000000000..43225f5d9a --- /dev/null +++ b/.changeset/fn-6975-mission-modal-stream.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make Plan Mission with AI desktop modal movable and recover cleanly from stream failures. +category: fix +dev: Dashboard mission interview now uses floating desktop geometry and normalizes terminal SSE errors into one retry state. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index ab518cd4c7..2e4c156d68 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -724,6 +724,13 @@ Workflow behavior: - Feature triage and slice **Triage all features** create new tasks on the selected workflow. - If no workflow is selected, or workflow columns are unavailable, mission-created tasks continue to use the project default workflow. + + +Plan Mission with AI modal behavior: +- On desktop, the modal opens as a floating workspace that can be dragged by its title bar and resized from the window edges/corners. +- On mobile, the mission interview keeps the fixed full-screen/sheet-style layout so touch users retain the original focused flow. +- If the mission interview stream reports a terminal failure, the modal closes the failed stream, shows one normalized error, and offers retry without duplicating late error/complete events. + ## Roadmaps View Roadmaps view manages roadmap hierarchies (roadmaps, milestones, features) and planning handoff exports. diff --git a/packages/dashboard/app/api/__tests__/mission-interview-stream.test.ts b/packages/dashboard/app/api/__tests__/mission-interview-stream.test.ts new file mode 100644 index 0000000000..427b2b76b5 --- /dev/null +++ b/packages/dashboard/app/api/__tests__/mission-interview-stream.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { connectMissionInterviewStream } from "../legacy"; + +class MockEventSource { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSED = 2; + static instances: MockEventSource[] = []; + + url: string; + readyState = MockEventSource.OPEN; + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + close = vi.fn(() => { + this.readyState = MockEventSource.CLOSED; + }); + + private listeners = new Map void>>(); + + constructor(url: string) { + this.url = url; + MockEventSource.instances.push(this); + } + + addEventListener(eventName: string, listener: EventListener) { + const listeners = this.listeners.get(eventName) ?? []; + listeners.push(listener as (event: MessageEvent) => void); + this.listeners.set(eventName, listeners); + } + + dispatch(eventName: string, data = "", lastEventId = "") { + const event = { data, lastEventId } as MessageEvent; + for (const listener of this.listeners.get(eventName) ?? []) { + listener(event); + } + } +} + +describe("connectMissionInterviewStream", () => { + beforeEach(() => { + MockEventSource.instances = []; + vi.stubGlobal("EventSource", MockEventSource); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + function connect(handlers: Partial[2]> = {}, options?: { maxReconnectAttempts?: number }) { + const onError = vi.fn(); + const onComplete = vi.fn(); + const connection = connectMissionInterviewStream("mission-session-1", undefined, { onError, onComplete, ...handlers }, options); + const source = MockEventSource.instances[0]; + return { connection, source, onError, onComplete }; + } + + it.each([ + ["JSON message", JSON.stringify({ message: "The model rejected the prompt." }), "The model rejected the prompt."], + ["JSON error fallback", JSON.stringify({ error: "Provider is unavailable." }), "Provider is unavailable."], + ["JSON string", JSON.stringify("Please try again later."), "Please try again later."], + ["non-JSON text", "Temporary outage", "Temporary outage"], + ["empty data", "", "The mission interview stream was interrupted. Please retry the session."], + ["generic stream error", "Stream error", "The mission interview stream was interrupted. Please retry the session."], + ["JSON primitive", JSON.stringify(500), "The mission interview stream was interrupted. Please retry the session."], + ])("normalizes terminal error payloads: %s", (_name, data, expected) => { + const { source, onError } = connect(); + + source.dispatch("error", data); + + expect(onError).toHaveBeenCalledWith(expected); + expect(source.close).toHaveBeenCalledTimes(1); + }); + + it("dedupes late terminal events and closes the stale EventSource once", () => { + const { source, onError, onComplete } = connect(); + + source.dispatch("error", JSON.stringify({ message: "First failure" }), "1"); + source.dispatch("error", JSON.stringify({ message: "Second failure" }), "2"); + source.dispatch("complete", "", "3"); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith("First failure"); + expect(onComplete).not.toHaveBeenCalled(); + expect(source.close).toHaveBeenCalledTimes(1); + }); + + it("reports fatal reconnect exhaustion through the same recoverable error path", () => { + const { source, onError } = connect({}, { maxReconnectAttempts: 0 }); + + source.readyState = MockEventSource.CLOSED; + source.onerror?.(); + + expect(onError).toHaveBeenCalledWith("Connection lost"); + expect(source.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 1bf3ac85a8..0c98e0ef6d 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -3975,6 +3975,9 @@ function createResilientEventSource( } source.close(); + if (eventSource === source) { + eventSource = null; + } if (reconnectAttempts >= maxReconnectAttempts) { options.onFatalError?.("Connection lost"); @@ -8415,6 +8418,31 @@ export function createMissionFromInterview( }); } +const MISSION_INTERVIEW_STREAM_ERROR_MESSAGE = "The mission interview stream was interrupted. Please retry the session."; + +function normalizeMissionInterviewStreamError(data: string | undefined): string { + const raw = data?.trim() ?? ""; + if (!raw) return MISSION_INTERVIEW_STREAM_ERROR_MESSAGE; + + const normalizeMessage = (value: unknown): string => { + if (typeof value !== "string") return MISSION_INTERVIEW_STREAM_ERROR_MESSAGE; + const message = value.trim(); + if (!message || message === "Stream error") return MISSION_INTERVIEW_STREAM_ERROR_MESSAGE; + return message; + }; + + try { + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const message = (parsed as { message?: unknown; error?: unknown }).message ?? (parsed as { error?: unknown }).error; + return normalizeMessage(message); + } + return normalizeMessage(parsed); + } catch { + return normalizeMessage(raw); + } +} + /** Connect to mission interview SSE stream and handle events */ export function connectMissionInterviewStream( sessionId: string, @@ -8432,12 +8460,32 @@ export function connectMissionInterviewStream( const url = buildApiUrl(withProjectId(`/missions/interview/${encodeURIComponent(sessionId)}/stream`, projectId)); let keepAlive: { stop: () => void } | null = null; let connection: { close: () => void; isConnected: () => boolean } | null = null; + let terminalEventHandled = false; const stopKeepAlive = () => { keepAlive?.stop(); keepAlive = null; }; + const closeTerminalConnection = () => { + stopKeepAlive(); + connection?.close(); + }; + + const notifyTerminalError = (message: string) => { + if (terminalEventHandled) return; + terminalEventHandled = true; + closeTerminalConnection(); + handlers.onError?.(message); + }; + + const notifyTerminalComplete = () => { + if (terminalEventHandled) return; + terminalEventHandled = true; + closeTerminalConnection(); + handlers.onComplete?.(); + }; + const resilient = createResilientEventSource( url, { @@ -8471,17 +8519,14 @@ export function connectMissionInterviewStream( } }, error: (event) => { - try { - const parsed = JSON.parse(event.data); - handlers.onError?.(parsed.message || parsed); - } catch { - handlers.onError?.(event.data || "Stream error"); - } - connection?.close(); + /* + FNXC:MissionInterviewStream 2026-06-24-00:00: + Mission interview stream failures are terminal for the current EventSource. Normalize malformed/empty/generic payloads, close keepalive + SSE once, and ignore duplicate late error/complete events so the modal can show one recoverable Retry state instead of a stale spinner or raw stream failure. + */ + notifyTerminalError(normalizeMissionInterviewStreamError(event.data)); }, complete: () => { - handlers.onComplete?.(); - connection?.close(); + notifyTerminalComplete(); }, }, }, @@ -8489,8 +8534,7 @@ export function connectMissionInterviewStream( maxReconnectAttempts: options?.maxReconnectAttempts, onConnectionStateChange: handlers.onConnectionStateChange, onFatalError: (message) => { - stopKeepAlive(); - handlers.onError?.(message); + notifyTerminalError(normalizeMissionInterviewStreamError(message)); }, }, ); diff --git a/packages/dashboard/app/components/MissionInterviewModal.css b/packages/dashboard/app/components/MissionInterviewModal.css index b55747f6e2..84c4c690e6 100644 --- a/packages/dashboard/app/components/MissionInterviewModal.css +++ b/packages/dashboard/app/components/MissionInterviewModal.css @@ -1,3 +1,54 @@ +/* +FNXC:MissionInterviewModal 2026-06-24-00:00: +Desktop mission planning is a floating workspace hosted by FloatingWindow. The embedded `.modal` must fill the floating panel instead of applying the fixed planning modal size, while mobile keeps the full-screen sheet contract and hides FloatingWindow resize shells. +*/ +.floating-window--mission-interview .floating-window__body { + overflow: hidden; +} + +.floating-window--mission-interview .mission-interview-modal { + width: 100%; + height: 100%; + max-width: none; + max-height: none; + border: 0; + border-radius: inherit; + box-shadow: none; +} + +.floating-window--mission-interview .mission-interview-modal__drag-handle { + cursor: grab; + user-select: none; + touch-action: none; +} + +.floating-window--mission-interview .mission-interview-modal__drag-handle:active { + cursor: grabbing; +} + +@media (max-width: 768px) { + .floating-window--mission-interview { + inset: 0 !important; + width: 100vw !important; + height: 100dvh !important; + min-width: 0 !important; + min-height: 0 !important; + max-width: 100vw !important; + max-height: 100dvh !important; + border: 0; + border-radius: 0; + box-shadow: none; + } + + .floating-window--mission-interview .floating-window__resize-handle { + display: none; + } + + .floating-window--mission-interview .mission-interview-modal { + border-radius: 0; + } +} + .roadmap-list { display: flex; flex-direction: column; diff --git a/packages/dashboard/app/components/MissionInterviewModal.tsx b/packages/dashboard/app/components/MissionInterviewModal.tsx index 056051c70d..43111a6e60 100644 --- a/packages/dashboard/app/components/MissionInterviewModal.tsx +++ b/packages/dashboard/app/components/MissionInterviewModal.tsx @@ -47,6 +47,7 @@ import { } from "lucide-react"; import { ConversationHistory } from "./ConversationHistory"; import { CustomModelDropdown } from "./CustomModelDropdown"; +import { FloatingWindow } from "./FloatingWindow"; import { useSessionLock } from "../hooks/useSessionLock"; import { useAiSessionSync } from "../hooks/useAiSessionSync"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; @@ -125,7 +126,6 @@ export function MissionInterviewModal({ const [editedSummary, setEditedSummary] = useState(null); const [_hasProgress, setHasProgress] = useState(false); const hasAutoStartedRef = useRef(false); - const overlayMouseDownOnSelfRef = useRef(false); const [streamingOutput, setStreamingOutput] = useState(""); const [showThinking, setShowThinking] = useState(true); const [isReconnecting, setIsReconnecting] = useState(false); @@ -631,7 +631,6 @@ export function MissionInterviewModal({ streamConnectionRef.current?.close(); streamConnectionRef.current = null; - overlayMouseDownOnSelfRef.current = false; setIsReconnecting(false); setIsRetrying(false); setIsCreating(false); @@ -641,7 +640,6 @@ export function MissionInterviewModal({ const handleSendToBackground = useCallback(() => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; - overlayMouseDownOnSelfRef.current = false; setIsReconnecting(false); setIsRetrying(false); setIsCreating(false); @@ -838,22 +836,23 @@ export function MissionInterviewModal({ if (!isOpen) return null; return ( -
{ - overlayMouseDownOnSelfRef.current = e.target === e.currentTarget; - }} - onClick={(e) => { - if (e.target === e.currentTarget && overlayMouseDownOnSelfRef.current) { - handleClose(); - } - overlayMouseDownOnSelfRef.current = false; - }} - role="dialog" - aria-modal="true" + -
-
+ {/* + FNXC:MissionInterviewModal 2026-06-24-00:00: + The Plan Mission with AI workspace must be draggable and resizable on desktop by delegating geometry to FloatingWindow, while mobile keeps the existing full-screen/sheet-like mission interview flow. Keep one embedded mission header so close/send-to-background/session-lock controls do not duplicate FloatingWindow chrome. + */} +
+

{t("missions.planTitle", "Plan Mission with AI")}

@@ -1112,7 +1111,7 @@ export function MissionInterviewModal({ )}
-
+ ); } diff --git a/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx b/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx index 265e16c49a..4e70988514 100644 --- a/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx @@ -1,8 +1,11 @@ import type React from "react"; +import { readFileSync } from "node:fs"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { MissionInterviewModal } from "../MissionInterviewModal"; +const missionInterviewCss = readFileSync("app/components/MissionInterviewModal.css", "utf8"); + const mockStartMissionInterview = vi.fn(); const mockRespondToMissionInterview = vi.fn(); const mockRetryMissionInterviewSession = vi.fn(); @@ -133,6 +136,12 @@ describe("MissionInterviewModal", () => { mockReleaseSessionLock.mockResolvedValue(undefined); mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null }); mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }); + localStorage.removeItem("floating-window:mission-interview"); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); }); function renderModal(props: Partial> = {}) { @@ -151,6 +160,79 @@ describe("MissionInterviewModal", () => { }; } + function setViewport(width: number, height: number) { + Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: height }); + } + + function stubPointerCapture(element: HTMLElement) { + Object.defineProperty(element, "setPointerCapture", { configurable: true, value: vi.fn() }); + Object.defineProperty(element, "releasePointerCapture", { configurable: true, value: vi.fn() }); + } + + it("renders mission interview inside a floating desktop workspace", () => { + setViewport(1200, 900); + + renderModal(); + + const panel = screen.getByTestId("floating-window-mission-interview"); + expect(panel).toHaveClass("floating-window--mission-interview"); + expect(panel).toHaveClass("floating-window--headerless"); + expect(panel.style.width).toBe("760px"); + expect(panel.style.height).toBe("680px"); + expect(screen.queryByTestId("floating-window-drag-handle-mission-interview")).toBeNull(); + expect(screen.getByText("Plan Mission with AI").closest(".mission-interview-modal__drag-handle")).toBeTruthy(); + expect(screen.getAllByRole("button", { name: "Close" })).toHaveLength(1); + }); + + it("drags and resizes the desktop mission floating window while clamping geometry", async () => { + setViewport(1200, 1000); + + renderModal(); + + const panel = screen.getByTestId("floating-window-mission-interview"); + const header = screen.getByText("Plan Mission with AI").closest(".mission-interview-modal__drag-handle") as HTMLElement; + stubPointerCapture(panel); + + const initialLeft = Number.parseFloat(panel.style.left); + const initialTop = Number.parseFloat(panel.style.top); + + act(() => { + fireEvent.pointerDown(header, { pointerId: 7, clientX: 120, clientY: 80 }); + fireEvent.pointerMove(panel, { pointerId: 7, clientX: 220, clientY: 140 }); + fireEvent.pointerUp(panel, { pointerId: 7, clientX: 220, clientY: 140 }); + }); + + await waitFor(() => { + expect(Number.parseFloat(panel.style.left)).toBeGreaterThan(initialLeft); + expect(Number.parseFloat(panel.style.top)).toBeGreaterThan(initialTop); + }); + + const resizeHandle = screen.getByTestId("floating-window-resize-se") as HTMLElement; + stubPointerCapture(resizeHandle); + + act(() => { + fireEvent.pointerDown(resizeHandle, { pointerId: 8, clientX: 700, clientY: 600 }); + fireEvent.pointerMove(resizeHandle, { pointerId: 8, clientX: 3000, clientY: 3000 }); + fireEvent.pointerUp(resizeHandle, { pointerId: 8, clientX: 3000, clientY: 3000 }); + }); + + expect(Number.parseFloat(panel.style.width)).toBeLessThanOrEqual(1200); + expect(Number.parseFloat(panel.style.height)).toBeLessThanOrEqual(1000); + expect(Number.parseFloat(panel.style.width)).toBeGreaterThanOrEqual(560); + expect(Number.parseFloat(panel.style.height)).toBeGreaterThanOrEqual(420); + }); + + it("keeps mobile mission planning full-screen and hides resize handles by CSS contract", () => { + const mobileBlock = missionInterviewCss.match(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.floating-window--mission-interview \.mission-interview-modal\s*\{[\s\S]*?\n\}/)?.[0]; + + expect(mobileBlock).toContain(".floating-window--mission-interview"); + expect(mobileBlock).toContain("width: 100vw !important;"); + expect(mobileBlock).toContain("height: 100dvh !important;"); + expect(mobileBlock).toContain(".floating-window--mission-interview .floating-window__resize-handle"); + expect(mobileBlock).toContain("display: none;"); + }); + it("shows lock overlay and allows take-control", async () => { window.sessionStorage.setItem("fusion-tab-id", "tab-self"); mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" }); @@ -378,6 +460,30 @@ describe("MissionInterviewModal", () => { expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); }); + it("renders normalized generic stream failures as a recoverable retry state", async () => { + mockFetchAiSession.mockRejectedValueOnce(new Error("refresh failed")); + + renderModal(); + + fireEvent.change(screen.getByLabelText("What do you want to build?"), { + target: { value: "Build a mission planning workflow" }, + }); + fireEvent.click(screen.getByText("Start Interview")); + + await waitFor(() => { + expect(streamHandlers).toBeDefined(); + }); + + await act(async () => { + streamHandlers.onError?.("The mission interview stream was interrupted. Please retry the session."); + }); + + expect(await screen.findByText("The mission interview stream was interrupted. Please retry the session.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument(); + expect(screen.queryByText("AI is thinking...")).not.toBeInTheDocument(); + }); + it("shows persisted mission interview errors after stream recovery refreshes the session", async () => { mockFetchAiSession.mockResolvedValueOnce( buildMissionSession({ @@ -582,14 +688,14 @@ describe("MissionInterviewModal", () => { expect(mockCancelMissionInterview).not.toHaveBeenCalled(); }); - it("closes from the backdrop after overlay mousedown", () => { + it("does not render a blocking backdrop click target around the floating workspace", () => { const { onClose } = renderModal(); const overlay = screen.getByRole("dialog"); fireEvent.mouseDown(overlay); fireEvent.click(overlay); - expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); expect(mockCancelMissionInterview).not.toHaveBeenCalled(); });