feat(FN-1700): merge fusion/fn-1700
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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={[]} />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user