feat(FN-1700): merge fusion/fn-1700

This commit is contained in:
gsxdsm
2026-04-13 16:20:04 -07:00
parent 4e5067f68d
commit 63c26579fa
5 changed files with 320 additions and 1 deletions

View File

@@ -35,6 +35,7 @@ import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
import { NodeProvider, useNodeContext } from "./context/NodeContext";
import type { AiSessionSummary } from "./api";
import { fetchAiSession } from "./api";
function AppInner() {
const { toasts, addToast, removeToast } = useToast();
@@ -123,6 +124,7 @@ function AppInner() {
const [nodesOpen, setNodesOpen] = useState(false);
const [missionResumeSessionId, setMissionResumeSessionId] = useState<string | undefined>(undefined);
const [missionTargetId, setMissionTargetId] = useState<string | undefined>(undefined);
const [milestoneSliceResumeSessionId, setMilestoneSliceResumeSessionId] = useState<string | undefined>(undefined);
const [quickChatOpen, setQuickChatOpen] = useState(false);
// Settings state
@@ -163,6 +165,7 @@ function AppInner() {
if (newView === "missions") {
setMissionResumeSessionId(undefined);
setMissionTargetId(undefined);
setMilestoneSliceResumeSessionId(undefined);
}
handleChangeTaskView(newView);
}, [handleChangeTaskView]);
@@ -260,6 +263,14 @@ function AppInner() {
} else if (session.type === "mission_interview") {
setMissionTargetId(undefined);
setMissionResumeSessionId(session.id);
setMilestoneSliceResumeSessionId(undefined);
handleChangeTaskView("missions");
} else if (session.type === "milestone_interview" || session.type === "slice_interview") {
// For milestone/slice interviews, we need to fetch the session to get the target ID
// Then navigate to missions view with the resume session ID
setMissionResumeSessionId(undefined);
setMissionTargetId(undefined);
setMilestoneSliceResumeSessionId(session.id);
handleChangeTaskView("missions");
}
}, [handleChangeTaskView, modalManager]);
@@ -320,6 +331,7 @@ function AppInner() {
onClose={() => {
setMissionTargetId(undefined);
setMissionResumeSessionId(undefined);
setMilestoneSliceResumeSessionId(undefined);
handleChangeTaskView("board");
}}
addToast={addToast}
@@ -331,6 +343,7 @@ function AppInner() {
availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))}
resumeSessionId={missionResumeSessionId}
targetMissionId={missionTargetId}
milestoneSliceResumeSessionId={milestoneSliceResumeSessionId}
/>
</PageErrorBoundary>
);

View File

@@ -15,6 +15,8 @@ const mockConnectSliceInterviewStream = vi.fn();
const mockAcquireSessionLock = vi.fn();
const mockReleaseSessionLock = vi.fn();
const mockForceAcquireSessionLock = vi.fn();
const mockFetchAiSession = vi.fn();
const mockParseConversationHistory = vi.fn();
vi.mock("../api", () => ({
startMilestoneInterview: (...args: any[]) => mockStartMilestoneInterview(...args),
@@ -30,6 +32,8 @@ vi.mock("../api", () => ({
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
}));
vi.mock("../hooks/useSessionLock", () => ({
@@ -95,6 +99,9 @@ describe("MilestoneSliceInterviewModal", () => {
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue(undefined);
mockFetchAiSession.mockReset();
mockParseConversationHistory.mockReset();
mockParseConversationHistory.mockReturnValue([]);
// Setup stream handlers capture
mockConnectMilestoneInterviewStream.mockImplementation((sessionId, projectId, handlers) => {
@@ -403,4 +410,141 @@ describe("MilestoneSliceInterviewModal", () => {
expect(screen.getByText("Refined Scope")).toBeDefined();
});
});
describe("resume session rehydration", () => {
const mockSessionAwaitingInput = {
id: "session-resume-123",
type: "milestone_interview" as const,
status: "awaiting_input" as const,
title: "Plan milestone scope",
projectId: "proj-1",
lockedByTab: null,
updatedAt: new Date().toISOString(),
inputPayload: JSON.stringify({
targetType: "milestone",
targetId: "MS-001",
targetTitle: "Test Milestone",
missionContext: "Test Mission",
}),
conversationHistory: JSON.stringify([
{ question: { id: "q1", type: "text", question: "What is the scope?" }, response: { q1: "MVP" } } ]),
currentQuestion: JSON.stringify(SAMPLE_QUESTION),
result: null,
thinkingOutput: "",
error: null,
createdAt: new Date().toISOString(),
lockedAt: null,
};
const mockSessionGenerating = {
...mockSessionAwaitingInput,
id: "session-resume-456",
status: "generating" as const,
currentQuestion: null,
thinkingOutput: "Analyzing requirements...",
};
const mockSessionError = {
...mockSessionAwaitingInput,
id: "session-resume-789",
status: "error" as const,
currentQuestion: null,
result: null,
error: "AI service unavailable",
};
it("restores awaiting_input session with question when resumeSessionId is provided", async () => {
mockFetchAiSession.mockResolvedValue(mockSessionAwaitingInput);
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
resumeSessionId="session-resume-123"
/>,
);
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-resume-123");
});
await waitFor(() => {
expect(screen.getByText("What is the target scope?")).toBeDefined();
expect(screen.getByText("Pick the size for this feature.")).toBeDefined();
});
});
it("reconnects to stream for generating session when resumeSessionId is provided", async () => {
mockFetchAiSession.mockResolvedValue(mockSessionGenerating);
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
resumeSessionId="session-resume-456"
/>,
);
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-resume-456");
expect(mockConnectMilestoneInterviewStream).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByText(/AI is thinking/)).toBeDefined();
});
});
it("shows error state for error session when resumeSessionId is provided", async () => {
mockFetchAiSession.mockResolvedValue(mockSessionError);
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
resumeSessionId="session-resume-789"
/>,
);
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-resume-789");
});
await waitFor(() => {
expect(screen.getByText("AI service unavailable")).toBeDefined();
});
});
it("does not resume when resumeSessionId is not provided", async () => {
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>,
);
expect(mockFetchAiSession).not.toHaveBeenCalled();
expect(screen.getByText("Start Interview")).toBeDefined();
});
});
});

View File

@@ -11,6 +11,8 @@ import {
applySliceInterview,
skipMilestoneInterview,
skipSliceInterview,
fetchAiSession,
parseConversationHistory,
type TargetInterviewSummary,
} from "../api";
import {
@@ -37,6 +39,8 @@ interface MilestoneSliceInterviewModalProps {
targetTitle: string;
missionContext?: string;
projectId?: string;
/** Resume a session from background (fetches session and restores state) */
resumeSessionId?: string;
}
interface QuestionResponse {
@@ -66,6 +70,7 @@ export function MilestoneSliceInterviewModal({
targetTitle,
missionContext,
projectId,
resumeSessionId,
}: MilestoneSliceInterviewModalProps) {
const [view, setView] = useState<ViewState>({ type: "initial" });
const [error, setError] = useState<string | null>(null);
@@ -239,6 +244,68 @@ export function MilestoneSliceInterviewModal({
}
}, [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) {

View File

@@ -96,6 +96,7 @@ import {
fetchValidationRun,
fetchAssertion,
fetchAiSessions,
fetchAiSession,
type AiSessionSummary,
} from "../api";
import type { AutopilotStatus as AutopilotStatusType, AutopilotState } from "./mission-types";
@@ -111,6 +112,8 @@ interface MissionManagerProps {
resumeSessionId?: string;
/** Pre-select and load this mission when the modal opens */
targetMissionId?: string;
/** Resume session ID for milestone/slice interview sessions */
milestoneSliceResumeSessionId?: string;
}
// Status badge colors — use CSS custom-property-compatible tokens
@@ -406,7 +409,7 @@ function getAutopilotActivitySummary(state: AutopilotState, lastActivityAt?: str
return `Last activation ${getRelativeTime(lastActivityAt)}`;
}
export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId }: MissionManagerProps) {
export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId }: MissionManagerProps) {
const isActive = isInline || isOpen;
const [missions, setMissions] = useState<MissionWithSummary[]>([]);
const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
@@ -454,6 +457,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
type: "milestone" | "slice";
id: string;
title: string;
resumeSessionId?: string;
} | null>(null);
// Triage preview state
@@ -484,6 +488,38 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
return () => { cancelled = true; };
}, [isActive, projectId, effectiveResumeSessionId]);
// Auto-open milestone/slice interview modal when resuming from background session
useEffect(() => {
if (!isActive || !milestoneSliceResumeSessionId) return;
let cancelled = false;
fetchAiSession(milestoneSliceResumeSessionId).then((session) => {
if (cancelled || !session) return;
// Parse the inputPayload to get target info
try {
const payload = JSON.parse(session.inputPayload || "{}");
if (payload.targetId && payload.targetType) {
setInterviewTarget({
type: payload.targetType as "milestone" | "slice",
id: payload.targetId,
title: payload.targetTitle || session.title,
resumeSessionId: milestoneSliceResumeSessionId,
});
}
} catch {
// If parsing fails, try to use session title as fallback
setInterviewTarget({
type: "milestone",
id: "",
title: session.title,
resumeSessionId: milestoneSliceResumeSessionId,
});
}
}).catch(() => {});
return () => { cancelled = true; };
}, [isActive, milestoneSliceResumeSessionId]);
// Delete confirmation
const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string } | null>(null);
@@ -3408,6 +3444,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
targetTitle={interviewTarget.title}
missionContext={selectedMission?.title}
projectId={projectId}
resumeSessionId={interviewTarget.resumeSessionId}
/>
) : null;

View File

@@ -122,6 +122,64 @@ describe("Utility component mobile adaptations", () => {
expect(container.firstChild).toBeNull();
});
it("calls onOpenSession when clicking on a milestone_interview session item", () => {
const onOpenSession = vi.fn();
const sessions: AiSessionSummary[] = [
{
id: "sess-milestone-1",
type: "milestone_interview",
status: "awaiting_input",
title: "Plan milestone scope",
projectId: "proj-1",
updatedAt: new Date().toISOString(),
},
];
render(
<BackgroundTasksIndicator
sessions={sessions}
generating={0}
needsInput={1}
onOpenSession={onOpenSession}
onDismissSession={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /AI 1/i }));
fireEvent.click(screen.getByText("Plan milestone scope"));
expect(onOpenSession).toHaveBeenCalledWith(sessions[0]);
});
it("calls onOpenSession when clicking on a slice_interview session item", () => {
const onOpenSession = vi.fn();
const sessions: AiSessionSummary[] = [
{
id: "sess-slice-1",
type: "slice_interview",
status: "error",
title: "Plan slice scope",
projectId: "proj-1",
updatedAt: new Date().toISOString(),
},
];
render(
<BackgroundTasksIndicator
sessions={sessions}
generating={0}
needsInput={0}
onOpenSession={onOpenSession}
onDismissSession={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /AI 1/i }));
fireEvent.click(screen.getByText("Plan slice scope"));
expect(onOpenSession).toHaveBeenCalledWith(sessions[0]);
});
it("renders ExecutorStatusBar segments", () => {
render(<ExecutorStatusBar tasks={[]} />);