import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import type { PlanningQuestion } 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 { useSessionLock } from "../hooks/useSessionLock"; import { useAiSessionSync } from "../hooks/useAiSessionSync"; import { getSessionTabId } from "../utils/getSessionTabId"; 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 [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); const trackedLockSessionRef = useRef(null); const [lockSessionId, setLockSessionId] = useState(null); const sessionTabId = useMemo(() => getSessionTabId(), []); const { isLockedByOther, takeControl, isLoading: isLockLoading, } = useSessionLock(isOpen ? lockSessionId : null); const { activeTabMap, broadcastUpdate, broadcastCompleted, broadcastLock, broadcastUnlock, broadcastHeartbeat, } = 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" ? "Milestone" : "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, owningTabId: sessionTabId, 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, owningTabId: sessionTabId, 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, owningTabId: sessionTabId, type: interviewType, title: targetTitle, projectId: projectId ?? null, }); }, onError: (message) => { const errorMessage = message || "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, owningTabId: sessionTabId, 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, sessionTabId, 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; setLockSessionId(sessionId); connectToInterviewStream(sessionId); } catch (err: any) { setIsReconnecting(false); setError(err.message || `Failed to start ${targetLabel.toLowerCase()} interview`); setView({ type: "initial" }); currentSessionIdRef.current = null; setLockSessionId(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: any) { setError(err.message || `Failed to skip ${targetLabel.toLowerCase()} interview`); 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); setLockSessionId(session.id); 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("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("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 ?? "The session encountered an error.", }); } }).catch(() => { if (!cancelled) setError("Failed to resume session."); }); return () => { cancelled = true; }; }, [connectToInterviewStream, isOpen, resumeSessionId, view.type]); // Cleanup on close useEffect(() => { if (!isOpen) { setIsReconnecting(false); setLockSessionId(null); } }, [isOpen]); // Session locking useEffect(() => { if (!isOpen || !lockSessionId) return; if (trackedLockSessionRef.current !== lockSessionId) { if (trackedLockSessionRef.current) { broadcastUnlock(trackedLockSessionRef.current, sessionTabId); } broadcastLock(lockSessionId, sessionTabId); trackedLockSessionRef.current = lockSessionId; return; } if (!lockSessionId && trackedLockSessionRef.current) { broadcastUnlock(trackedLockSessionRef.current, sessionTabId); trackedLockSessionRef.current = null; } }, [broadcastLock, broadcastUnlock, isOpen, lockSessionId, sessionTabId]); // Keep heartbeat alive useEffect(() => { if (!isOpen || !lockSessionId || trackedLockSessionRef.current !== lockSessionId) { return; } broadcastHeartbeat(sessionTabId); const timer = setInterval(() => { broadcastHeartbeat(sessionTabId); }, 30_000); return () => { clearInterval(timer); }; }, [broadcastHeartbeat, isOpen, lockSessionId, sessionTabId]); // Cleanup stream on unmount useEffect(() => { return () => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; if (trackedLockSessionRef.current) { broadcastUnlock(trackedLockSessionRef.current, sessionTabId); trackedLockSessionRef.current = null; } }; }, [broadcastUnlock, sessionTabId]); 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; setLockSessionId(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, sessionTabId); } catch (err: any) { streamConnectionRef.current?.close(); streamConnectionRef.current = null; setError(err.message || "Failed to submit response"); setView({ type: "question", sessionId, question: view.question }); } }, [connectToInterviewStream, projectId, respondToInterview, sessionTabId, 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: any) { setError(err.message || "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"; const activeLockInfo = lockSessionId ? activeTabMap.get(lockSessionId) : null; const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId; const activeInAnotherTab = Boolean(activeRemoteTab && !activeLockInfo.stale); if (!isOpen) return null; return (
e.target === e.currentTarget && handleCancel()} role="dialog" aria-modal="true" data-testid="milestone-slice-interview-modal" >

Plan {targetLabel}: {targetTitle}

{showSendToBackgroundButton && ( )}
{error &&
{error}
} {isReconnecting &&
Reconnecting…
} {activeInAnotherTab && (
Session is active in another tab.
)} {view.type === "initial" && (

Refine {targetLabel} scope with AI

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 && (
Mission context: {missionContext}
)}
)} {view.type === "loading" && (

{streamingOutput ? "AI is thinking..." : "Preparing next question..."}

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

{targetLabel} Updated

The {targetType}'s scope and verification have been {view.type === "applied" ? "applied" : "updated"}.

)}
); } // ── Question Form Component ──────────────────────────────────────────────── interface InterviewQuestionFormProps { question: PlanningQuestion; progress: number; historyEntries: ConversationHistoryEntry[]; onSubmit: (responses: QuestionResponse) => void; } function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) { const [response, setResponse] = useState({}); const [textValue, setTextValue] = useState(""); const handleSubmit = useCallback(() => { if (question.type === "text") { onSubmit({ [question.id]: textValue }); } else if (question.type === "confirm") { onSubmit({ [question.id]: response[question.id] === true }); } else { onSubmit(response); } }, [question, response, textValue, onSubmit]); useEffect(() => { setResponse({}); setTextValue(""); }, [question.id]); const isValid = () => { switch (question.type) { case "text": return textValue.trim().length > 0; case "single_select": return response[question.id] !== undefined; case "multi_select": return Array.isArray(response[question.id] as unknown) && (response[question.id] as unknown[]).length > 0; case "confirm": return response[question.id] !== undefined; default: return true; } }; return (
{historyEntries.length > 0 && ( <>
)}
{[1, 2, 3, 4, 5, 6].map((step) => (
))}
Question {progress} of ~6

{question.question}

{question.description && (

{question.description}

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