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.
|
- 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.
|
- 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
|
||||||
|
|
||||||
Roadmaps view manages roadmap hierarchies (roadmaps, milestones, features) and planning handoff exports.
|
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();
|
source.close();
|
||||||
|
if (eventSource === source) {
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (reconnectAttempts >= maxReconnectAttempts) {
|
if (reconnectAttempts >= maxReconnectAttempts) {
|
||||||
options.onFatalError?.("Connection lost");
|
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 */
|
/** Connect to mission interview SSE stream and handle events */
|
||||||
export function connectMissionInterviewStream(
|
export function connectMissionInterviewStream(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -8432,12 +8460,32 @@ export function connectMissionInterviewStream(
|
|||||||
const url = buildApiUrl(withProjectId(`/missions/interview/${encodeURIComponent(sessionId)}/stream`, projectId));
|
const url = buildApiUrl(withProjectId(`/missions/interview/${encodeURIComponent(sessionId)}/stream`, projectId));
|
||||||
let keepAlive: { stop: () => void } | null = null;
|
let keepAlive: { stop: () => void } | null = null;
|
||||||
let connection: { close: () => void; isConnected: () => boolean } | null = null;
|
let connection: { close: () => void; isConnected: () => boolean } | null = null;
|
||||||
|
let terminalEventHandled = false;
|
||||||
|
|
||||||
const stopKeepAlive = () => {
|
const stopKeepAlive = () => {
|
||||||
keepAlive?.stop();
|
keepAlive?.stop();
|
||||||
keepAlive = null;
|
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(
|
const resilient = createResilientEventSource(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
@@ -8471,17 +8519,14 @@ export function connectMissionInterviewStream(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (event) => {
|
error: (event) => {
|
||||||
try {
|
/*
|
||||||
const parsed = JSON.parse(event.data);
|
FNXC:MissionInterviewStream 2026-06-24-00:00:
|
||||||
handlers.onError?.(parsed.message || parsed);
|
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.
|
||||||
} catch {
|
*/
|
||||||
handlers.onError?.(event.data || "Stream error");
|
notifyTerminalError(normalizeMissionInterviewStreamError(event.data));
|
||||||
}
|
|
||||||
connection?.close();
|
|
||||||
},
|
},
|
||||||
complete: () => {
|
complete: () => {
|
||||||
handlers.onComplete?.();
|
notifyTerminalComplete();
|
||||||
connection?.close();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -8489,8 +8534,7 @@ export function connectMissionInterviewStream(
|
|||||||
maxReconnectAttempts: options?.maxReconnectAttempts,
|
maxReconnectAttempts: options?.maxReconnectAttempts,
|
||||||
onConnectionStateChange: handlers.onConnectionStateChange,
|
onConnectionStateChange: handlers.onConnectionStateChange,
|
||||||
onFatalError: (message) => {
|
onFatalError: (message) => {
|
||||||
stopKeepAlive();
|
notifyTerminalError(normalizeMissionInterviewStreamError(message));
|
||||||
handlers.onError?.(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 {
|
.roadmap-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ConversationHistory } from "./ConversationHistory";
|
import { ConversationHistory } from "./ConversationHistory";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
|
import { FloatingWindow } from "./FloatingWindow";
|
||||||
import { useSessionLock } from "../hooks/useSessionLock";
|
import { useSessionLock } from "../hooks/useSessionLock";
|
||||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
@@ -125,7 +126,6 @@ export function MissionInterviewModal({
|
|||||||
const [editedSummary, setEditedSummary] = useState<MissionPlanSummary | null>(null);
|
const [editedSummary, setEditedSummary] = useState<MissionPlanSummary | null>(null);
|
||||||
const [_hasProgress, setHasProgress] = useState(false);
|
const [_hasProgress, setHasProgress] = useState(false);
|
||||||
const hasAutoStartedRef = useRef(false);
|
const hasAutoStartedRef = useRef(false);
|
||||||
const overlayMouseDownOnSelfRef = useRef(false);
|
|
||||||
const [streamingOutput, setStreamingOutput] = useState("");
|
const [streamingOutput, setStreamingOutput] = useState("");
|
||||||
const [showThinking, setShowThinking] = useState(true);
|
const [showThinking, setShowThinking] = useState(true);
|
||||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||||
@@ -631,7 +631,6 @@ export function MissionInterviewModal({
|
|||||||
|
|
||||||
streamConnectionRef.current?.close();
|
streamConnectionRef.current?.close();
|
||||||
streamConnectionRef.current = null;
|
streamConnectionRef.current = null;
|
||||||
overlayMouseDownOnSelfRef.current = false;
|
|
||||||
setIsReconnecting(false);
|
setIsReconnecting(false);
|
||||||
setIsRetrying(false);
|
setIsRetrying(false);
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
@@ -641,7 +640,6 @@ export function MissionInterviewModal({
|
|||||||
const handleSendToBackground = useCallback(() => {
|
const handleSendToBackground = useCallback(() => {
|
||||||
streamConnectionRef.current?.close();
|
streamConnectionRef.current?.close();
|
||||||
streamConnectionRef.current = null;
|
streamConnectionRef.current = null;
|
||||||
overlayMouseDownOnSelfRef.current = false;
|
|
||||||
setIsReconnecting(false);
|
setIsReconnecting(false);
|
||||||
setIsRetrying(false);
|
setIsRetrying(false);
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
@@ -838,22 +836,23 @@ export function MissionInterviewModal({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<FloatingWindow
|
||||||
className="modal-overlay open"
|
windowKey="mission-interview"
|
||||||
onMouseDown={(e) => {
|
title={t("missions.planTitle", "Plan Mission with AI")}
|
||||||
overlayMouseDownOnSelfRef.current = e.target === e.currentTarget;
|
onClose={handleClose}
|
||||||
}}
|
hideHeader
|
||||||
onClick={(e) => {
|
dragHandleSelector=".mission-interview-modal__drag-handle"
|
||||||
if (e.target === e.currentTarget && overlayMouseDownOnSelfRef.current) {
|
className="floating-window--mission-interview"
|
||||||
handleClose();
|
defaultSize={{ width: 760, height: 680 }}
|
||||||
}
|
minSize={{ width: 560, height: 420 }}
|
||||||
overlayMouseDownOnSelfRef.current = false;
|
persistGeometryKey="floating-window:mission-interview"
|
||||||
}}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
>
|
>
|
||||||
<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">
|
<div className="detail-title-row">
|
||||||
<Target size={20} className="icon-triage" />
|
<Target size={20} className="icon-triage" />
|
||||||
<h3>{t("missions.planTitle", "Plan Mission with AI")}</h3>
|
<h3>{t("missions.planTitle", "Plan Mission with AI")}</h3>
|
||||||
@@ -1112,7 +1111,7 @@ export function MissionInterviewModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FloatingWindow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { MissionInterviewModal } from "../MissionInterviewModal";
|
import { MissionInterviewModal } from "../MissionInterviewModal";
|
||||||
|
|
||||||
|
const missionInterviewCss = readFileSync("app/components/MissionInterviewModal.css", "utf8");
|
||||||
|
|
||||||
const mockStartMissionInterview = vi.fn();
|
const mockStartMissionInterview = vi.fn();
|
||||||
const mockRespondToMissionInterview = vi.fn();
|
const mockRespondToMissionInterview = vi.fn();
|
||||||
const mockRetryMissionInterviewSession = vi.fn();
|
const mockRetryMissionInterviewSession = vi.fn();
|
||||||
@@ -133,6 +136,12 @@ describe("MissionInterviewModal", () => {
|
|||||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||||
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
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>> = {}) {
|
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 () => {
|
it("shows lock overlay and allows take-control", async () => {
|
||||||
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
||||||
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
|
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
|
||||||
@@ -378,6 +460,30 @@ describe("MissionInterviewModal", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
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 () => {
|
it("shows persisted mission interview errors after stream recovery refreshes the session", async () => {
|
||||||
mockFetchAiSession.mockResolvedValueOnce(
|
mockFetchAiSession.mockResolvedValueOnce(
|
||||||
buildMissionSession({
|
buildMissionSession({
|
||||||
@@ -582,14 +688,14 @@ describe("MissionInterviewModal", () => {
|
|||||||
expect(mockCancelMissionInterview).not.toHaveBeenCalled();
|
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 { onClose } = renderModal();
|
||||||
const overlay = screen.getByRole("dialog");
|
const overlay = screen.getByRole("dialog");
|
||||||
|
|
||||||
fireEvent.mouseDown(overlay);
|
fireEvent.mouseDown(overlay);
|
||||||
fireEvent.click(overlay);
|
fireEvent.click(overlay);
|
||||||
|
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
expect(mockCancelMissionInterview).not.toHaveBeenCalled();
|
expect(mockCancelMissionInterview).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user