FN-6975: make mission planning modal movable and resilient
Make the AI mission planning workspace movable on desktop and safer when streams fail. - Host the Plan Mission with AI modal in FloatingWindow with desktop drag/resize geometry and mobile full-screen preservation. - Normalize terminal mission interview stream failures, close SSE/keepalive once, and suppress duplicate late terminal events. - Cover modal geometry and stream-error behavior with dashboard tests and document the operator-facing behavior. Files changed: .changeset/fn-6975-mission-modal-stream.md | 7 ++ docs/dashboard-guide.md | 7 ++ .../api/__tests__/mission-interview-stream.test.ts | 98 ++++++++++++++++++ packages/dashboard/app/api/legacy.ts | 66 ++++++++++--- .../app/components/MissionInterviewModal.css | 51 ++++++++++ .../app/components/MissionInterviewModal.tsx | 37 ++++--- .../__tests__/MissionInterviewModal.test.tsx | 110 ++++++++++++++++++++- 7 files changed, 344 insertions(+), 32 deletions(-) Fusion-Task-Id: FN-6975 Fusion-Task-Lineage: b2ffa558-8b7e-4a46-8882-f2c4f6189831
This commit is contained in:
7
.changeset/fn-6975-mission-modal-stream.md
Normal file
7
.changeset/fn-6975-mission-modal-stream.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
<!-- FNXC:MissionInterviewDocs 2026-06-25-15:55: FN-6975 made the Plan Mission with AI workspace movable/resizable on desktop while preserving mobile's fixed full-screen flow, and stream failures now surface one recoverable retry state instead of leaving the modal spinning. -->
|
||||
|
||||
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.
|
||||
|
||||
@@ -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<string, Array<(event: MessageEvent) => 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<Parameters<typeof connectMissionInterviewStream>[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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<MissionPlanSummary | null>(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 (
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
onMouseDown={(e) => {
|
||||
overlayMouseDownOnSelfRef.current = e.target === e.currentTarget;
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && overlayMouseDownOnSelfRef.current) {
|
||||
handleClose();
|
||||
}
|
||||
overlayMouseDownOnSelfRef.current = false;
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
<FloatingWindow
|
||||
windowKey="mission-interview"
|
||||
title={t("missions.planTitle", "Plan Mission with AI")}
|
||||
onClose={handleClose}
|
||||
hideHeader
|
||||
dragHandleSelector=".mission-interview-modal__drag-handle"
|
||||
className="floating-window--mission-interview"
|
||||
defaultSize={{ width: 760, height: 680 }}
|
||||
minSize={{ width: 560, height: 420 }}
|
||||
persistGeometryKey="floating-window:mission-interview"
|
||||
>
|
||||
<div className="modal modal-lg planning-modal">
|
||||
<div className="modal-header">
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<div className="modal modal-lg planning-modal mission-interview-modal">
|
||||
<div className="modal-header mission-interview-modal__drag-handle">
|
||||
<div className="detail-title-row">
|
||||
<Target size={20} className="icon-triage" />
|
||||
<h3>{t("missions.planTitle", "Plan Mission with AI")}</h3>
|
||||
@@ -1112,7 +1111,7 @@ export function MissionInterviewModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<React.ComponentProps<typeof MissionInterviewModal>> = {}) {
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user