import { useState, useCallback, useEffect, useRef, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import type { PlanningQuestion } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { startMilestoneInterview, startSliceInterview, respondToMilestoneInterview, respondToSliceInterview, connectMilestoneInterviewStream, connectSliceInterviewStream, applyMilestoneInterview, applySliceInterview, skipMilestoneInterview, skipSliceInterview, fetchAiSession, parseConversationHistory, type TargetInterviewSummary, } from "../api"; import { X, Loader2, CheckCircle, ArrowRight, Sparkles, ChevronRight, ChevronDown, Minimize2, } from "lucide-react"; import { ConversationHistory } from "./ConversationHistory"; import { useAiSessionSync } from "../hooks/useAiSessionSync"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useViewportMode } from "../hooks/useViewportMode"; const WARNING_ICON = "⚠️"; const MILESTONE_SLICE_OTHER_RESPONSE_KEY = "_other"; const MILESTONE_SLICE_OTHER_OPTION_ID = "__other__"; interface MilestoneSliceInterviewModalProps { isOpen: boolean; onClose: () => void; onApplied: () => void; targetType: "milestone" | "slice"; targetId: string; targetTitle: string; missionContext?: string; projectId?: string; /** Resume a session from background (fetches session and restores state) */ resumeSessionId?: string; } interface QuestionResponse { [key: string]: unknown; } interface ConversationHistoryEntry { question?: PlanningQuestion; response?: Record; thinkingOutput?: string; } type ViewState = | { type: "initial" } | { type: "loading" } | { type: "question"; sessionId: string; question: PlanningQuestion } | { type: "summary"; sessionId: string; summary: TargetInterviewSummary } | { type: "applied" } | { type: "error"; sessionId: string; errorMessage: string }; export function MilestoneSliceInterviewModal({ isOpen, onClose, onApplied, targetType, targetId, targetTitle, missionContext, projectId, resumeSessionId, }: MilestoneSliceInterviewModalProps) { const { t } = useTranslation("app"); const viewportMode = useViewportMode(); useMobileScrollLock(isOpen); const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: viewportMode === "mobile", }); const keyboardStyle: CSSProperties = keyboardOpen ? ({ "--keyboard-overlap": `${keyboardOverlap}px`, "--vv-offset-top": `${viewportOffsetTop}px`, ...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}), } as CSSProperties) : {}; const [view, setView] = useState({ type: "initial" }); const [error, setError] = useState(null); const [responseHistory, setResponseHistory] = useState([]); const [conversationHistory, setConversationHistory] = useState([]); const [editedSummary, setEditedSummary] = useState(null); const [streamingOutput, setStreamingOutput] = useState(""); const [showThinking, setShowThinking] = useState(true); const [isReconnecting, setIsReconnecting] = useState(false); const [isApplying, setIsApplying] = useState(false); const textareaRef = useRef(null); const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const currentSessionIdRef = useRef(null); /* FNXC:PlanningMultiTab 2026-07-14-00:00: No tab lock: the persisted session row is the shared source of truth, so every tab may read and interact with this interview. The lock state, ownership broadcasts, heartbeat, and the "active in another tab" banner were removed; only session-status sync remains. */ const { broadcastUpdate, broadcastCompleted } = useAiSessionSync(); // Select the right API functions based on targetType const startInterview = targetType === "milestone" ? startMilestoneInterview : startSliceInterview; const respondToInterview = targetType === "milestone" ? respondToMilestoneInterview : respondToSliceInterview; const connectToStream = targetType === "milestone" ? connectMilestoneInterviewStream : connectSliceInterviewStream; const applyInterview = targetType === "milestone" ? applyMilestoneInterview : applySliceInterview; const skipInterview = targetType === "milestone" ? skipMilestoneInterview : skipSliceInterview; const targetLabel = targetType === "milestone" ? t("interview.targetLabel.milestone", "Milestone") : t("interview.targetLabel.slice", "Slice"); const interviewType = targetType === "milestone" ? "milestone_interview" : "slice_interview"; const connectToInterviewStream = useCallback( (sessionId: string) => { streamConnectionRef.current?.close(); const connection = connectToStream(sessionId, projectId, { onThinking: (data) => { setStreamingOutput((prev) => prev + data); broadcastUpdate({ sessionId, status: "generating", needsInput: false, type: interviewType, title: targetTitle, projectId: projectId ?? null, }); }, onQuestion: (question) => { setIsReconnecting(false); clearSummary(); setView({ type: "question", sessionId, question }); setStreamingOutput(""); broadcastUpdate({ sessionId, status: "awaiting_input", needsInput: true, type: interviewType, title: targetTitle, projectId: projectId ?? null, }); }, onSummary: (summary) => { setIsReconnecting(false); clearSummary(); setView({ type: "summary", sessionId, summary }); setEditedSummary(summary); setStreamingOutput(""); broadcastUpdate({ sessionId, status: "complete", needsInput: false, type: interviewType, title: targetTitle, projectId: projectId ?? null, }); }, onError: (message) => { const errorMessage = message || t("interview.error.sessionFailed", "Session failed while contacting the AI."); setIsReconnecting(false); setError(null); setView({ type: "error", sessionId, errorMessage }); setStreamingOutput(""); currentSessionIdRef.current = sessionId; broadcastUpdate({ sessionId, status: "error", needsInput: false, type: interviewType, title: targetTitle, projectId: projectId ?? null, }); broadcastCompleted({ sessionId, status: "error" }); }, onComplete: () => { setIsReconnecting(false); currentSessionIdRef.current = null; broadcastCompleted({ sessionId, status: "complete" }); }, onConnectionStateChange: (state) => { setIsReconnecting(state === "reconnecting"); }, }); streamConnectionRef.current = connection; }, [broadcastCompleted, broadcastUpdate, connectToStream, interviewType, projectId, targetTitle], ); const clearSummary = () => { setEditedSummary(null); setResponseHistory([]); setConversationHistory([]); setStreamingOutput(""); }; const handleStartInterview = useCallback(async () => { setError(null); clearSummary(); setIsReconnecting(false); setView({ type: "loading" }); try { const { sessionId } = await startInterview(targetId, projectId); currentSessionIdRef.current = sessionId; connectToInterviewStream(sessionId); } catch (err) { setIsReconnecting(false); setError(getErrorMessage(err) || t("interview.error.failedToStart", "Failed to start {{targetLabel}} interview", { targetLabel: targetLabel.toLowerCase() })); setView({ type: "initial" }); currentSessionIdRef.current = null; } }, [connectToInterviewStream, projectId, startInterview, targetId, targetLabel]); const handleUseMissionContext = useCallback(async () => { setError(null); setIsApplying(true); try { await skipInterview(targetId, projectId); onApplied(); setView({ type: "applied" }); } catch (err) { setError(getErrorMessage(err) || t("interview.error.failedToSkip", "Failed to skip {{targetLabel}} interview", { targetLabel: targetLabel.toLowerCase() })); setIsApplying(false); } }, [onApplied, projectId, skipInterview, targetId, targetLabel]); // Focus textarea when opening useEffect(() => { if (isOpen && view.type === "initial") { textareaRef.current?.focus(); } }, [isOpen, view.type]); // Reconnect to a persisted session when resumeSessionId is provided useEffect(() => { if (!isOpen || !resumeSessionId || view.type !== "initial") return; let cancelled = false; fetchAiSession(resumeSessionId).then((session) => { if (cancelled || !session) return; const parsedHistory = parseConversationHistory(session.conversationHistory); setConversationHistory(parsedHistory); setResponseHistory( parsedHistory .map((entry) => entry.response) .filter((response): response is QuestionResponse => Boolean(response && typeof response === "object" && !Array.isArray(response)), ), ); if (session.status === "awaiting_input" && session.currentQuestion) { try { const question = JSON.parse(session.currentQuestion) as PlanningQuestion; currentSessionIdRef.current = session.id; setView({ type: "question", sessionId: session.id, question }); } catch { setError(t("interview.error.failedToRestoreQuestion", "Failed to restore session question.")); } } else if (session.status === "complete" && session.result) { try { const summary = JSON.parse(session.result) as TargetInterviewSummary; currentSessionIdRef.current = session.id; setEditedSummary(summary); setView({ type: "summary", sessionId: session.id, summary }); } catch { setError(t("interview.error.failedToRestoreResult", "Failed to restore session result.")); } } else if (session.status === "generating") { currentSessionIdRef.current = session.id; if (session.thinkingOutput) { setStreamingOutput(session.thinkingOutput); } setView({ type: "loading" }); connectToInterviewStream(session.id); } else if (session.status === "error") { currentSessionIdRef.current = session.id; setError(null); setView({ type: "error", sessionId: session.id, errorMessage: session.error ?? t("interview.error.sessionEncounteredError", "The session encountered an error."), }); } }).catch(() => { if (!cancelled) setError(t("interview.error.failedToResume", "Failed to resume session.")); }); return () => { cancelled = true; }; }, [connectToInterviewStream, isOpen, resumeSessionId, view.type]); // Cleanup on close useEffect(() => { if (!isOpen) { setIsReconnecting(false); } }, [isOpen]); // Cleanup stream on unmount useEffect(() => { return () => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; }; }, []); const handleSendToBackground = useCallback(() => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; onClose(); }, [onClose]); const handleCancel = useCallback(async () => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; clearSummary(); setView({ type: "initial" }); setError(null); currentSessionIdRef.current = null; onClose(); }, [onClose]); const handleSubmitResponse = useCallback( async (responses: QuestionResponse) => { if (view.type !== "question") return; const { sessionId } = view; setError(null); setResponseHistory((prev) => [...prev, responses]); setConversationHistory((prev) => [ ...prev, { question: view.question, response: responses, }, ]); setView({ type: "loading" }); setStreamingOutput(""); try { connectToInterviewStream(sessionId); await respondToInterview(sessionId, responses, projectId); } catch (err) { streamConnectionRef.current?.close(); streamConnectionRef.current = null; setError(getErrorMessage(err) || t("interview.error.failedToSubmit", "Failed to submit response")); setView({ type: "question", sessionId, question: view.question }); } }, [connectToInterviewStream, projectId, respondToInterview, view], ); const handleApply = useCallback(async () => { if (view.type !== "summary") return; setError(null); setIsApplying(true); try { await applyInterview(view.sessionId, editedSummary || undefined, projectId); onApplied(); setView({ type: "applied" }); } catch (err) { setError(getErrorMessage(err) || t("interview.error.failedToApply", "Failed to apply interview results")); setIsApplying(false); } }, [applyInterview, editedSummary, onApplied, projectId, view]); const getProgress = () => { if (view.type === "question") { return Math.min(responseHistory.length + 1, 6); } return 6; }; const showSendToBackgroundButton = view.type === "loading" || view.type === "question" || view.type === "summary" || view.type === "error"; if (!isOpen) return null; return (
e.target === e.currentTarget && handleCancel()} role="dialog" aria-modal="true" data-testid="milestone-slice-interview-modal" >

{t("missions.planTargetTitle", "Plan {{targetLabel}}: {{targetTitle}}", { targetLabel, targetTitle })}

{showSendToBackgroundButton && ( )}
{error &&
{error}
} {isReconnecting &&
{t("interview.reconnecting", "Reconnecting…")}
} {view.type === "initial" && (

{t("interview.refineScope", `Refine ${targetLabel} scope with AI`)}

{t("interview.interviewDescription", `The AI will interview you to refine the ${targetType}'s scope, acceptance criteria, and verification methods. Each ${targetType} can have its own refined plan or inherit context from the mission level.`)}

{missionContext && (
{t("interview.missionContext", "Mission context:")} {missionContext}
)}
)} {view.type === "loading" && (

{streamingOutput ? t("interview.aiThinking", "AI is thinking...") : t("interview.preparingQuestion", "Preparing next question...")}

{showThinking && streamingOutput && (
{streamingOutput}
)}
)} {view.type === "error" && (
{conversationHistory.length > 0 && ( <>
)}
{WARNING_ICON}
{view.errorMessage}
)} {view.type === "question" && ( )} {view.type === "summary" && editedSummary && ( )} {view.type === "applied" && (

{t("interview.updated", `${targetLabel} Updated`)}

{t("interview.appliedMessage", `The ${targetType}'s scope and verification have been applied.`)}

)}
); } // ── Question Form Component ──────────────────────────────────────────────── interface InterviewQuestionFormProps { question: PlanningQuestion; progress: number; historyEntries: ConversationHistoryEntry[]; onSubmit: (responses: QuestionResponse) => void; } function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) { const { t } = useTranslation("app"); const questionOptions = question.options ?? []; const [response, setResponse] = useState({}); const [textValue, setTextValue] = useState(""); const [commentValue, setCommentValue] = useState(""); const [otherValue, setOtherValue] = useState(""); const [isOtherSelected, setIsOtherSelected] = useState(false); const handleSubmit = useCallback(() => { let nextResponse: QuestionResponse; if (question.type === "text") { nextResponse = { [question.id]: textValue }; } else if (question.type === "confirm") { nextResponse = { [question.id]: response[question.id] === true }; } else if (question.type === "single_select") { const trimmedOther = otherValue.trim(); /* FNXC:PlanningInterview 2026-06-26-00:00: GitHub #1794 requires milestone and slice interviews to let users decline every AI-provided single-select option and submit their own answer through `_other`. */ nextResponse = isOtherSelected && trimmedOther.length > 0 ? { [MILESTONE_SLICE_OTHER_RESPONSE_KEY]: trimmedOther } : response; } else if (question.type === "multi_select") { const trimmedOther = otherValue.trim(); /* FNXC:PlanningInterview 2026-06-26-00:00: Milestone and slice multi-select questions must support Other-only answers and Other-plus-option answers without forcing users into suggested choices they reject. */ nextResponse = isOtherSelected && trimmedOther.length > 0 ? { ...response, [MILESTONE_SLICE_OTHER_RESPONSE_KEY]: trimmedOther } : response; } else { nextResponse = response; } const trimmedComment = commentValue.trim(); if (trimmedComment.length > 0) { nextResponse = { ...nextResponse, _comment: trimmedComment }; } onSubmit(nextResponse); }, [commentValue, isOtherSelected, otherValue, question, response, textValue, onSubmit]); useEffect(() => { setResponse({}); setTextValue(""); setCommentValue(""); setOtherValue(""); setIsOtherSelected(false); }, [question.id]); const isValid = () => { switch (question.type) { case "text": return textValue.trim().length > 0; case "single_select": /* FNXC:PlanningInterview 2026-06-26-00:00: Continue is valid for milestone/slice single-select questions when the user writes a non-empty Other answer, even with no provided option selected. */ return response[question.id] !== undefined || (isOtherSelected && otherValue.trim().length > 0); case "multi_select": /* FNXC:PlanningInterview 2026-06-26-00:00: Continue is valid for milestone/slice multi-select questions when Other has non-whitespace text, including the Other-only case. */ return (Array.isArray(response[question.id] as unknown) && (response[question.id] as unknown[]).length > 0) || (isOtherSelected && otherValue.trim().length > 0); case "confirm": return response[question.id] !== undefined; default: return true; } }; return (
{historyEntries.length > 0 && ( <>
)}
{[1, 2, 3, 4, 5, 6].map((step) => (
))}
{t("interview.progressText", `Question ${progress} of ~6`)}

{question.question}

{question.description && (

{question.description}

)}
{question.type === "text" && (