feat(FN-936): add modal description persistence for re-entry
- Add modalPersistence hook for saving/restoring modal state (description, goal) via localStorage - Update PlanningModeModal to persist and restore description across re-opens - Update SubtaskBreakdownModal to persist and clear description on resume flow - Update MissionInterviewModal to clear goal after starting interview - Add unit tests for modalPersistence hook - Add integration tests for modal re-entry behavior across all three modals - Add changeset for published package patch bump
This commit is contained in:
7
.changeset/fn-936-modal-reentry.md
Normal file
7
.changeset/fn-936-modal-reentry.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Enable re-entering closed modals (Planning, Subtask, Mission) with persisted descriptions.
|
||||||
|
|
||||||
|
Users can now close any of these modals and re-enter by clicking the respective button again. The last entered description is persisted in localStorage and auto-populated on re-entry.
|
||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
type MissionPlanFeature,
|
type MissionPlanFeature,
|
||||||
type MissionWithHierarchy,
|
type MissionWithHierarchy,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
import {
|
||||||
|
saveMissionGoal,
|
||||||
|
getMissionGoal,
|
||||||
|
clearMissionGoal,
|
||||||
|
} from "../hooks/modalPersistence";
|
||||||
import {
|
import {
|
||||||
Target,
|
Target,
|
||||||
X,
|
X,
|
||||||
@@ -90,17 +95,20 @@ export function MissionInterviewModal({
|
|||||||
try {
|
try {
|
||||||
const { sessionId } = await startMissionInterview(goal.trim(), projectId);
|
const { sessionId } = await startMissionInterview(goal.trim(), projectId);
|
||||||
currentSessionIdRef.current = sessionId;
|
currentSessionIdRef.current = sessionId;
|
||||||
|
clearMissionGoal();
|
||||||
|
|
||||||
const connection = connectMissionInterviewStream(sessionId, projectId, {
|
const connection = connectMissionInterviewStream(sessionId, projectId, {
|
||||||
onThinking: (data) => {
|
onThinking: (data) => {
|
||||||
setStreamingOutput((prev) => prev + data);
|
setStreamingOutput((prev) => prev + data);
|
||||||
},
|
},
|
||||||
onQuestion: (question) => {
|
onQuestion: (question) => {
|
||||||
|
clearMissionGoal();
|
||||||
setView({ type: "question", sessionId, question });
|
setView({ type: "question", sessionId, question });
|
||||||
setStreamingOutput("");
|
setStreamingOutput("");
|
||||||
setHasProgress(true);
|
setHasProgress(true);
|
||||||
},
|
},
|
||||||
onSummary: (summary) => {
|
onSummary: (summary) => {
|
||||||
|
clearMissionGoal();
|
||||||
setView({ type: "summary", sessionId, summary });
|
setView({ type: "summary", sessionId, summary });
|
||||||
setEditedSummary(summary);
|
setEditedSummary(summary);
|
||||||
setStreamingOutput("");
|
setStreamingOutput("");
|
||||||
@@ -144,6 +152,12 @@ export function MissionInterviewModal({
|
|||||||
handleStartInterview(initialGoalProp);
|
handleStartInterview(initialGoalProp);
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
|
} else if (isOpen && !initialGoalProp && !hasAutoStartedRef.current && view.type === "initial") {
|
||||||
|
// Check localStorage for persisted goal when no prop provided
|
||||||
|
const persisted = getMissionGoal();
|
||||||
|
if (persisted) {
|
||||||
|
setMissionGoal(persisted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isOpen, initialGoalProp, view.type, handleStartInterview]);
|
}, [isOpen, initialGoalProp, view.type, handleStartInterview]);
|
||||||
|
|
||||||
@@ -164,6 +178,7 @@ export function MissionInterviewModal({
|
|||||||
|
|
||||||
if (session.status === "awaiting_input" && session.currentQuestion) {
|
if (session.status === "awaiting_input" && session.currentQuestion) {
|
||||||
try {
|
try {
|
||||||
|
clearMissionGoal();
|
||||||
const question = JSON.parse(session.currentQuestion) as import("@fusion/core").PlanningQuestion;
|
const question = JSON.parse(session.currentQuestion) as import("@fusion/core").PlanningQuestion;
|
||||||
currentSessionIdRef.current = session.id;
|
currentSessionIdRef.current = session.id;
|
||||||
setHasProgress(true);
|
setHasProgress(true);
|
||||||
@@ -173,6 +188,7 @@ export function MissionInterviewModal({
|
|||||||
}
|
}
|
||||||
} else if (session.status === "complete" && session.result) {
|
} else if (session.status === "complete" && session.result) {
|
||||||
try {
|
try {
|
||||||
|
clearMissionGoal();
|
||||||
const summary = JSON.parse(session.result) as MissionPlanSummary;
|
const summary = JSON.parse(session.result) as MissionPlanSummary;
|
||||||
currentSessionIdRef.current = session.id;
|
currentSessionIdRef.current = session.id;
|
||||||
setHasProgress(true);
|
setHasProgress(true);
|
||||||
@@ -194,10 +210,12 @@ export function MissionInterviewModal({
|
|||||||
setStreamingOutput((prev) => prev + data);
|
setStreamingOutput((prev) => prev + data);
|
||||||
},
|
},
|
||||||
onQuestion: (question) => {
|
onQuestion: (question) => {
|
||||||
|
clearMissionGoal();
|
||||||
setView({ type: "question", sessionId: session.id, question });
|
setView({ type: "question", sessionId: session.id, question });
|
||||||
setStreamingOutput("");
|
setStreamingOutput("");
|
||||||
},
|
},
|
||||||
onSummary: (summary) => {
|
onSummary: (summary) => {
|
||||||
|
clearMissionGoal();
|
||||||
setView({ type: "summary", sessionId: session.id, summary });
|
setView({ type: "summary", sessionId: session.id, summary });
|
||||||
setEditedSummary(summary);
|
setEditedSummary(summary);
|
||||||
setStreamingOutput("");
|
setStreamingOutput("");
|
||||||
@@ -251,6 +269,11 @@ export function MissionInterviewModal({
|
|||||||
}, [isOpen, view]);
|
}, [isOpen, view]);
|
||||||
|
|
||||||
const handleCancel = useCallback(async () => {
|
const handleCancel = useCallback(async () => {
|
||||||
|
// Save to localStorage BEFORE any cleanup
|
||||||
|
if (missionGoal) {
|
||||||
|
saveMissionGoal(missionGoal);
|
||||||
|
}
|
||||||
|
|
||||||
if (hasProgress) {
|
if (hasProgress) {
|
||||||
if (!confirm("Are you sure you want to close? Your interview progress will be lost.")) {
|
if (!confirm("Are you sure you want to close? Your interview progress will be lost.")) {
|
||||||
return;
|
return;
|
||||||
@@ -278,7 +301,7 @@ export function MissionInterviewModal({
|
|||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
currentSessionIdRef.current = null;
|
currentSessionIdRef.current = null;
|
||||||
onClose();
|
onClose();
|
||||||
}, [hasProgress, view, onClose, projectId]);
|
}, [missionGoal, hasProgress, view, onClose, projectId]);
|
||||||
|
|
||||||
// Escape key handler
|
// Escape key handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -330,6 +353,7 @@ export function MissionInterviewModal({
|
|||||||
try {
|
try {
|
||||||
const mission = await createMissionFromInterview(view.sessionId, editedSummary || undefined, projectId);
|
const mission = await createMissionFromInterview(view.sessionId, editedSummary || undefined, projectId);
|
||||||
onMissionCreated(mission);
|
onMissionCreated(mission);
|
||||||
|
clearMissionGoal();
|
||||||
// Reset state without confirmation
|
// Reset state without confirmation
|
||||||
streamConnectionRef.current?.close();
|
streamConnectionRef.current?.close();
|
||||||
streamConnectionRef.current = null;
|
streamConnectionRef.current = null;
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
type PlanningSession,
|
type PlanningSession,
|
||||||
type SubtaskItem,
|
type SubtaskItem,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
import {
|
||||||
|
savePlanningDescription,
|
||||||
|
getPlanningDescription,
|
||||||
|
clearPlanningDescription,
|
||||||
|
} from "../hooks/modalPersistence";
|
||||||
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2 } from "lucide-react";
|
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
interface PlanningModeModalProps {
|
interface PlanningModeModalProps {
|
||||||
@@ -83,6 +88,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
setStreamingOutput((prev) => prev + data);
|
setStreamingOutput((prev) => prev + data);
|
||||||
},
|
},
|
||||||
onQuestion: (question) => {
|
onQuestion: (question) => {
|
||||||
|
clearPlanningDescription();
|
||||||
setView({
|
setView({
|
||||||
type: "question",
|
type: "question",
|
||||||
session: { sessionId, currentQuestion: question, summary: null },
|
session: { sessionId, currentQuestion: question, summary: null },
|
||||||
@@ -91,6 +97,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
setHasProgress(true);
|
setHasProgress(true);
|
||||||
},
|
},
|
||||||
onSummary: (summary) => {
|
onSummary: (summary) => {
|
||||||
|
clearPlanningDescription();
|
||||||
setView({
|
setView({
|
||||||
type: "summary",
|
type: "summary",
|
||||||
session: { sessionId, currentQuestion: null, summary },
|
session: { sessionId, currentQuestion: null, summary },
|
||||||
@@ -138,6 +145,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
handleStartPlanning(initialPlanProp);
|
handleStartPlanning(initialPlanProp);
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
|
} else if (isOpen && !initialPlanProp && !hasAutoStartedRef.current && view.type === "initial") {
|
||||||
|
// Check localStorage for persisted description when no prop provided
|
||||||
|
const persisted = getPlanningDescription();
|
||||||
|
if (persisted) {
|
||||||
|
setInitialPlan(persisted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isOpen, initialPlanProp, view.type, handleStartPlanning]);
|
}, [isOpen, initialPlanProp, view.type, handleStartPlanning]);
|
||||||
|
|
||||||
@@ -153,11 +166,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
currentSessionIdRef.current = resumeSessionId;
|
currentSessionIdRef.current = resumeSessionId;
|
||||||
|
|
||||||
if (session.status === "awaiting_input" && session.currentQuestion) {
|
if (session.status === "awaiting_input" && session.currentQuestion) {
|
||||||
|
clearPlanningDescription();
|
||||||
const question = JSON.parse(session.currentQuestion);
|
const question = JSON.parse(session.currentQuestion);
|
||||||
setView({ type: "question", session: { sessionId: resumeSessionId, currentQuestion: question, summary: null } });
|
setView({ type: "question", session: { sessionId: resumeSessionId, currentQuestion: question, summary: null } });
|
||||||
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
|
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
|
||||||
setHasProgress(true);
|
setHasProgress(true);
|
||||||
} else if (session.status === "complete" && session.result) {
|
} else if (session.status === "complete" && session.result) {
|
||||||
|
clearPlanningDescription();
|
||||||
const summary = JSON.parse(session.result);
|
const summary = JSON.parse(session.result);
|
||||||
setView({ type: "summary", session: { sessionId: resumeSessionId, currentQuestion: null, summary }, summary });
|
setView({ type: "summary", session: { sessionId: resumeSessionId, currentQuestion: null, summary }, summary });
|
||||||
setEditedSummary(summary);
|
setEditedSummary(summary);
|
||||||
@@ -169,11 +184,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
const connection = connectPlanningStream(resumeSessionId, projectId, {
|
const connection = connectPlanningStream(resumeSessionId, projectId, {
|
||||||
onThinking: (data) => setStreamingOutput((prev) => prev + data),
|
onThinking: (data) => setStreamingOutput((prev) => prev + data),
|
||||||
onQuestion: (question) => {
|
onQuestion: (question) => {
|
||||||
|
clearPlanningDescription();
|
||||||
setView({ type: "question", session: { sessionId: resumeSessionId, currentQuestion: question, summary: null } });
|
setView({ type: "question", session: { sessionId: resumeSessionId, currentQuestion: question, summary: null } });
|
||||||
setStreamingOutput("");
|
setStreamingOutput("");
|
||||||
setHasProgress(true);
|
setHasProgress(true);
|
||||||
},
|
},
|
||||||
onSummary: (summary) => {
|
onSummary: (summary) => {
|
||||||
|
clearPlanningDescription();
|
||||||
setView({ type: "summary", session: { sessionId: resumeSessionId, currentQuestion: null, summary }, summary });
|
setView({ type: "summary", session: { sessionId: resumeSessionId, currentQuestion: null, summary }, summary });
|
||||||
setEditedSummary(summary);
|
setEditedSummary(summary);
|
||||||
setStreamingOutput("");
|
setStreamingOutput("");
|
||||||
@@ -227,6 +244,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
}, [isOpen, view]);
|
}, [isOpen, view]);
|
||||||
|
|
||||||
const handleCancel = useCallback(async () => {
|
const handleCancel = useCallback(async () => {
|
||||||
|
// Save to localStorage BEFORE any cleanup (preserve for re-entry)
|
||||||
|
if (initialPlan) {
|
||||||
|
savePlanningDescription(initialPlan);
|
||||||
|
}
|
||||||
|
|
||||||
// Show confirmation if user has made progress
|
// Show confirmation if user has made progress
|
||||||
if (hasProgress) {
|
if (hasProgress) {
|
||||||
if (!confirm("Are you sure you want to close? Your planning progress will be lost.")) {
|
if (!confirm("Are you sure you want to close? Your planning progress will be lost.")) {
|
||||||
@@ -254,7 +276,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
setHasProgress(false);
|
setHasProgress(false);
|
||||||
currentSessionIdRef.current = null;
|
currentSessionIdRef.current = null;
|
||||||
onClose();
|
onClose();
|
||||||
}, [hasProgress, view, onClose]);
|
}, [initialPlan, hasProgress, view, onClose]);
|
||||||
|
|
||||||
// Handle escape key to close
|
// Handle escape key to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ vi.mock("../api", () => ({
|
|||||||
cancelSubtaskBreakdown: (...args: any[]) => mockCancelSubtaskBreakdown(...args),
|
cancelSubtaskBreakdown: (...args: any[]) => mockCancelSubtaskBreakdown(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../hooks/modalPersistence", () => ({
|
||||||
|
saveSubtaskDescription: vi.fn(),
|
||||||
|
getSubtaskDescription: vi.fn(() => ""),
|
||||||
|
clearSubtaskDescription: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const SAMPLE_SUBTASKS = [
|
const SAMPLE_SUBTASKS = [
|
||||||
{ id: "subtask-1", title: "First", description: "Do first", suggestedSize: "S" as const, dependsOn: [] },
|
{ id: "subtask-1", title: "First", description: "Do first", suggestedSize: "S" as const, dependsOn: [] },
|
||||||
{ id: "subtask-2", title: "Second", description: "Do second", suggestedSize: "M" as const, dependsOn: ["subtask-1"] },
|
{ id: "subtask-2", title: "Second", description: "Do second", suggestedSize: "M" as const, dependsOn: ["subtask-1"] },
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
fetchAiSession,
|
fetchAiSession,
|
||||||
type SubtaskItem,
|
type SubtaskItem,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
import {
|
||||||
|
saveSubtaskDescription,
|
||||||
|
getSubtaskDescription,
|
||||||
|
clearSubtaskDescription,
|
||||||
|
} from "../hooks/modalPersistence";
|
||||||
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown } from "lucide-react";
|
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown } from "lucide-react";
|
||||||
|
|
||||||
interface SubtaskBreakdownModalProps {
|
interface SubtaskBreakdownModalProps {
|
||||||
@@ -61,6 +66,8 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
|||||||
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([]);
|
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([]);
|
||||||
const [thinkingOutput, setThinkingOutput] = useState("");
|
const [thinkingOutput, setThinkingOutput] = useState("");
|
||||||
const [showThinking, setShowThinking] = useState(true);
|
const [showThinking, setShowThinking] = useState(true);
|
||||||
|
// Local description: synced from prop, can fall back to localStorage
|
||||||
|
const [localDescription, setLocalDescription] = useState(initialDescription);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
@@ -84,6 +91,10 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
|||||||
}, [subtasks]);
|
}, [subtasks]);
|
||||||
|
|
||||||
const resetState = useCallback(() => {
|
const resetState = useCallback(() => {
|
||||||
|
// Save to localStorage before cleanup (preserve for re-entry)
|
||||||
|
if (localDescription) {
|
||||||
|
saveSubtaskDescription(localDescription);
|
||||||
|
}
|
||||||
streamRef.current?.close();
|
streamRef.current?.close();
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
setView({ type: "initial" });
|
setView({ type: "initial" });
|
||||||
@@ -93,7 +104,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
|||||||
setError(null);
|
setError(null);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
autoStartedRef.current = false;
|
autoStartedRef.current = false;
|
||||||
}, []);
|
}, [localDescription]);
|
||||||
|
|
||||||
const handleClose = useCallback(async () => {
|
const handleClose = useCallback(async () => {
|
||||||
if ((dirty || view.type === "editing" || view.type === "creating") && !confirm("Close subtask breakdown? Unsaved changes will be lost.")) {
|
if ((dirty || view.type === "editing" || view.type === "creating") && !confirm("Close subtask breakdown? Unsaved changes will be lost.")) {
|
||||||
@@ -111,17 +122,18 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
|||||||
}, [dirty, onClose, resetState, sessionId, view.type, projectId]);
|
}, [dirty, onClose, resetState, sessionId, view.type, projectId]);
|
||||||
|
|
||||||
const beginBreakdown = useCallback(async () => {
|
const beginBreakdown = useCallback(async () => {
|
||||||
if (!initialDescription.trim()) return;
|
if (!localDescription.trim()) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setThinkingOutput("");
|
setThinkingOutput("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { sessionId } = await startSubtaskBreakdown(initialDescription.trim(), projectId);
|
const { sessionId } = await startSubtaskBreakdown(localDescription.trim(), projectId);
|
||||||
setView({ type: "generating", sessionId });
|
setView({ type: "generating", sessionId });
|
||||||
streamRef.current?.close();
|
streamRef.current?.close();
|
||||||
streamRef.current = connectSubtaskStream(sessionId, projectId, {
|
streamRef.current = connectSubtaskStream(sessionId, projectId, {
|
||||||
onThinking: (data) => setThinkingOutput((prev) => prev + data),
|
onThinking: (data) => setThinkingOutput((prev) => prev + data),
|
||||||
onSubtasks: (items) => {
|
onSubtasks: (items) => {
|
||||||
|
clearSubtaskDescription();
|
||||||
setSubtasks(items);
|
setSubtasks(items);
|
||||||
setView({ type: "editing", sessionId });
|
setView({ type: "editing", sessionId });
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
@@ -135,7 +147,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
|||||||
setError(err.message || "Failed to start subtask breakdown");
|
setError(err.message || "Failed to start subtask breakdown");
|
||||||
setView({ type: "initial" });
|
setView({ type: "initial" });
|
||||||
}
|
}
|
||||||
}, [initialDescription]);
|
}, [localDescription, projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -144,8 +156,15 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isOpen && initialDescription && !autoStartedRef.current) {
|
if (isOpen && initialDescription && !autoStartedRef.current) {
|
||||||
|
setLocalDescription(initialDescription);
|
||||||
autoStartedRef.current = true;
|
autoStartedRef.current = true;
|
||||||
void beginBreakdown();
|
void beginBreakdown();
|
||||||
|
} else if (isOpen && !initialDescription && !autoStartedRef.current) {
|
||||||
|
// Check localStorage for persisted description when no prop provided
|
||||||
|
const persisted = getSubtaskDescription();
|
||||||
|
if (persisted) {
|
||||||
|
setLocalDescription(persisted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isOpen, initialDescription, beginBreakdown, resetState]);
|
}, [isOpen, initialDescription, beginBreakdown, resetState]);
|
||||||
|
|
||||||
@@ -163,6 +182,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
|||||||
streamRef.current = connectSubtaskStream(resumeSessionId, projectId, {
|
streamRef.current = connectSubtaskStream(resumeSessionId, projectId, {
|
||||||
onThinking: (data) => setThinkingOutput((prev) => prev + data),
|
onThinking: (data) => setThinkingOutput((prev) => prev + data),
|
||||||
onSubtasks: (items) => {
|
onSubtasks: (items) => {
|
||||||
|
clearSubtaskDescription();
|
||||||
setSubtasks(items);
|
setSubtasks(items);
|
||||||
setView({ type: "editing", sessionId: resumeSessionId });
|
setView({ type: "editing", sessionId: resumeSessionId });
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
@@ -173,6 +193,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (session.status === "complete" && session.result) {
|
} else if (session.status === "complete" && session.result) {
|
||||||
|
clearSubtaskDescription();
|
||||||
const items = JSON.parse(session.result) as SubtaskItem[];
|
const items = JSON.parse(session.result) as SubtaskItem[];
|
||||||
setSubtasks(items);
|
setSubtasks(items);
|
||||||
setView({ type: "editing", sessionId: resumeSessionId });
|
setView({ type: "editing", sessionId: resumeSessionId });
|
||||||
@@ -345,7 +366,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
|||||||
<div className="planning-initial">
|
<div className="planning-initial">
|
||||||
<div className="planning-view-scroll">
|
<div className="planning-view-scroll">
|
||||||
<p className="text-muted">Preparing to break this task into subtasks.</p>
|
<p className="text-muted">Preparing to break this task into subtasks.</p>
|
||||||
<pre className="planning-thinking-output">{initialDescription}</pre>
|
<pre className="planning-thinking-output">{localDescription}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||||
|
|
||||||
|
// Use vi.hoisted to ensure mock functions are defined before vi.mock factory runs
|
||||||
|
const {
|
||||||
|
mockSavePlanningDescription,
|
||||||
|
mockGetPlanningDescription,
|
||||||
|
mockClearPlanningDescription,
|
||||||
|
mockSaveSubtaskDescription,
|
||||||
|
mockGetSubtaskDescription,
|
||||||
|
mockClearSubtaskDescription,
|
||||||
|
mockSaveMissionGoal,
|
||||||
|
mockGetMissionGoal,
|
||||||
|
mockClearMissionGoal,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockSavePlanningDescription: vi.fn(),
|
||||||
|
mockGetPlanningDescription: vi.fn(() => ""),
|
||||||
|
mockClearPlanningDescription: vi.fn(),
|
||||||
|
mockSaveSubtaskDescription: vi.fn(),
|
||||||
|
mockGetSubtaskDescription: vi.fn(() => ""),
|
||||||
|
mockClearSubtaskDescription: vi.fn(),
|
||||||
|
mockSaveMissionGoal: vi.fn(),
|
||||||
|
mockGetMissionGoal: vi.fn(() => ""),
|
||||||
|
mockClearMissionGoal: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/modalPersistence", () => ({
|
||||||
|
savePlanningDescription: (...args: any[]) => mockSavePlanningDescription(...args),
|
||||||
|
getPlanningDescription: (...args: any[]) => mockGetPlanningDescription(...args),
|
||||||
|
clearPlanningDescription: (...args: any[]) => mockClearPlanningDescription(...args),
|
||||||
|
saveSubtaskDescription: (...args: any[]) => mockSaveSubtaskDescription(...args),
|
||||||
|
getSubtaskDescription: (...args: any[]) => mockGetSubtaskDescription(...args),
|
||||||
|
clearSubtaskDescription: (...args: any[]) => mockClearSubtaskDescription(...args),
|
||||||
|
saveMissionGoal: (...args: any[]) => mockSaveMissionGoal(...args),
|
||||||
|
getMissionGoal: (...args: any[]) => mockGetMissionGoal(...args),
|
||||||
|
clearMissionGoal: (...args: any[]) => mockClearMissionGoal(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the API functions
|
||||||
|
const {
|
||||||
|
mockStartPlanningStreaming,
|
||||||
|
mockConnectPlanningStream,
|
||||||
|
mockCancelPlanning,
|
||||||
|
mockCreateTaskFromPlanning,
|
||||||
|
mockRespondToPlanning,
|
||||||
|
mockStartSubtaskBreakdown,
|
||||||
|
mockConnectSubtaskStream,
|
||||||
|
mockCancelSubtaskBreakdown,
|
||||||
|
mockCreateTasksFromBreakdown,
|
||||||
|
mockStartMissionInterview,
|
||||||
|
mockConnectMissionInterviewStream,
|
||||||
|
mockCancelMissionInterview,
|
||||||
|
mockCreateMissionFromInterview,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockStartPlanningStreaming: vi.fn(),
|
||||||
|
mockConnectPlanningStream: vi.fn(),
|
||||||
|
mockCancelPlanning: vi.fn(),
|
||||||
|
mockCreateTaskFromPlanning: vi.fn(),
|
||||||
|
mockRespondToPlanning: vi.fn(),
|
||||||
|
mockStartSubtaskBreakdown: vi.fn(),
|
||||||
|
mockConnectSubtaskStream: vi.fn(),
|
||||||
|
mockCancelSubtaskBreakdown: vi.fn(),
|
||||||
|
mockCreateTasksFromBreakdown: vi.fn(),
|
||||||
|
mockStartMissionInterview: vi.fn(),
|
||||||
|
mockConnectMissionInterviewStream: vi.fn(),
|
||||||
|
mockCancelMissionInterview: vi.fn(),
|
||||||
|
mockCreateMissionFromInterview: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
startPlanningStreaming: (...args: any[]) => mockStartPlanningStreaming(...args),
|
||||||
|
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
|
||||||
|
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
|
||||||
|
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
|
||||||
|
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
|
||||||
|
startSubtaskBreakdown: (...args: any[]) => mockStartSubtaskBreakdown(...args),
|
||||||
|
connectSubtaskStream: (...args: any[]) => mockConnectSubtaskStream(...args),
|
||||||
|
cancelSubtaskBreakdown: (...args: any[]) => mockCancelSubtaskBreakdown(...args),
|
||||||
|
createTasksFromBreakdown: (...args: any[]) => mockCreateTasksFromBreakdown(...args),
|
||||||
|
startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args),
|
||||||
|
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
|
||||||
|
cancelMissionInterview: (...args: any[]) => mockCancelMissionInterview(...args),
|
||||||
|
createMissionFromInterview: (...args: any[]) => mockCreateMissionFromInterview(...args),
|
||||||
|
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
|
||||||
|
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }),
|
||||||
|
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||||
|
refineText: vi.fn(),
|
||||||
|
getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"),
|
||||||
|
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
duplicateTask: vi.fn().mockResolvedValue({}),
|
||||||
|
uploadAttachment: vi.fn(),
|
||||||
|
deleteAttachment: vi.fn(),
|
||||||
|
updateTask: vi.fn(),
|
||||||
|
pauseTask: vi.fn(),
|
||||||
|
unpauseTask: vi.fn(),
|
||||||
|
fetchTaskDetail: vi.fn(),
|
||||||
|
requestSpecRevision: vi.fn(),
|
||||||
|
approvePlan: vi.fn(),
|
||||||
|
rejectPlan: vi.fn(),
|
||||||
|
refineTask: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import components AFTER mocking
|
||||||
|
import { PlanningModeModal } from "../PlanningModeModal";
|
||||||
|
import { SubtaskBreakdownModal } from "../SubtaskBreakdownModal";
|
||||||
|
import { MissionInterviewModal } from "../MissionInterviewModal";
|
||||||
|
|
||||||
|
describe("ModalReentry", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockGetPlanningDescription.mockReturnValue("");
|
||||||
|
mockGetSubtaskDescription.mockReturnValue("");
|
||||||
|
mockGetMissionGoal.mockReturnValue("");
|
||||||
|
|
||||||
|
// Default API mocks
|
||||||
|
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "planning-session-1" });
|
||||||
|
mockConnectPlanningStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||||
|
mockCancelPlanning.mockResolvedValue(undefined);
|
||||||
|
mockCreateTaskFromPlanning.mockResolvedValue({ id: "FN-100" });
|
||||||
|
|
||||||
|
mockStartSubtaskBreakdown.mockResolvedValue({ sessionId: "subtask-session-1" });
|
||||||
|
mockConnectSubtaskStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||||
|
mockCancelSubtaskBreakdown.mockResolvedValue(undefined);
|
||||||
|
mockCreateTasksFromBreakdown.mockResolvedValue({ tasks: [{ id: "FN-101" }, { id: "FN-102" }] });
|
||||||
|
|
||||||
|
mockStartMissionInterview.mockResolvedValue({ sessionId: "mission-session-1" });
|
||||||
|
mockConnectMissionInterviewStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||||
|
mockCancelMissionInterview.mockResolvedValue(undefined);
|
||||||
|
mockCreateMissionFromInterview.mockResolvedValue({
|
||||||
|
mission: { id: "MSN-001" },
|
||||||
|
slices: [],
|
||||||
|
features: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("confirm", vi.fn(() => true));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── PlanningModeModal ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("PlanningModal re-entry", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
isOpen: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onTaskCreated: vi.fn(),
|
||||||
|
onTasksCreated: vi.fn(),
|
||||||
|
tasks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("reads persisted description from localStorage when no prop provided", async () => {
|
||||||
|
mockGetPlanningDescription.mockReturnValue("Persisted planning description");
|
||||||
|
|
||||||
|
render(<PlanningModeModal {...defaultProps} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockGetPlanningDescription).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the textarea has the persisted value
|
||||||
|
const textarea = document.getElementById("initial-plan") as HTMLTextAreaElement;
|
||||||
|
expect(textarea).toBeTruthy();
|
||||||
|
expect(textarea.value).toBe("Persisted planning description");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses prop value instead of localStorage when initialPlan prop is provided", async () => {
|
||||||
|
mockGetPlanningDescription.mockReturnValue("From localStorage");
|
||||||
|
|
||||||
|
render(<PlanningModeModal {...defaultProps} initialPlan="From prop" />);
|
||||||
|
|
||||||
|
// Wait for auto-start (which reads the prop)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("From prop", undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
// localStorage should NOT be read since prop was provided
|
||||||
|
expect(mockGetPlanningDescription).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears localStorage when planning session produces events", async () => {
|
||||||
|
// Set up stream to trigger onQuestion which calls clearPlanningDescription
|
||||||
|
mockConnectPlanningStream.mockImplementation((_sid, _pid, handlers) => {
|
||||||
|
setTimeout(() => handlers.onQuestion({ id: "q1", type: "text", question: "Test?" }), 0);
|
||||||
|
return { close: vi.fn(), isConnected: () => true };
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PlanningModeModal {...defaultProps} initialPlan="Build auth" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockClearPlanningDescription).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves description to localStorage on cancel", async () => {
|
||||||
|
vi.stubGlobal("confirm", vi.fn(() => true));
|
||||||
|
|
||||||
|
const { unmount } = render(<PlanningModeModal {...defaultProps} />);
|
||||||
|
|
||||||
|
// Type something in the textarea
|
||||||
|
const textarea = document.getElementById("initial-plan") as HTMLTextAreaElement;
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "My planning text" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click the close button
|
||||||
|
const closeButton = screen.getByLabelText("Close");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(closeButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSavePlanningDescription).toHaveBeenCalledWith("My planning text");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not save empty description to localStorage on cancel", async () => {
|
||||||
|
vi.stubGlobal("confirm", vi.fn(() => true));
|
||||||
|
|
||||||
|
const { unmount } = render(<PlanningModeModal {...defaultProps} />);
|
||||||
|
|
||||||
|
// Click the close button without typing anything
|
||||||
|
const closeButton = screen.getByLabelText("Close");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(closeButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSavePlanningDescription).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── SubtaskBreakdownModal ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("SubtaskBreakdownModal re-entry", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
isOpen: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
initialDescription: "",
|
||||||
|
onTasksCreated: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("reads persisted description from localStorage when no prop provided", async () => {
|
||||||
|
mockGetSubtaskDescription.mockReturnValue("Persisted subtask description");
|
||||||
|
|
||||||
|
render(<SubtaskBreakdownModal {...defaultProps} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockGetSubtaskDescription).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the persisted description is shown in the pre element
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Persisted subtask description")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses prop value and starts breakdown immediately when initialDescription is provided", async () => {
|
||||||
|
render(
|
||||||
|
<SubtaskBreakdownModal
|
||||||
|
{...defaultProps}
|
||||||
|
initialDescription="Build a complex feature"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockStartSubtaskBreakdown).toHaveBeenCalledWith("Build a complex feature", undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears localStorage when subtasks are received", async () => {
|
||||||
|
// Set up the stream to emit subtasks
|
||||||
|
mockConnectSubtaskStream.mockImplementation((_sid, _pid, handlers) => {
|
||||||
|
// Simulate subtasks arriving synchronously
|
||||||
|
handlers.onSubtasks([{ id: "subtask-1", title: "First", description: "", suggestedSize: "M", dependsOn: [] }]);
|
||||||
|
return { close: vi.fn(), isConnected: () => true };
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SubtaskBreakdownModal
|
||||||
|
{...defaultProps}
|
||||||
|
initialDescription="Break this down"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockClearSubtaskDescription).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves description to localStorage on close", async () => {
|
||||||
|
vi.stubGlobal("confirm", vi.fn(() => true));
|
||||||
|
|
||||||
|
// Set up the stream so the modal can start
|
||||||
|
mockConnectSubtaskStream.mockImplementation((_sid, _pid, handlers) => {
|
||||||
|
handlers.onSubtasks([{ id: "subtask-1", title: "First", description: "", suggestedSize: "M", dependsOn: [] }]);
|
||||||
|
return { close: vi.fn(), isConnected: () => true };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<SubtaskBreakdownModal
|
||||||
|
{...defaultProps}
|
||||||
|
initialDescription="Some description"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close the modal (resetState is called which saves to localStorage)
|
||||||
|
const closeButton = screen.getByLabelText("Close");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(closeButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSaveSubtaskDescription).toHaveBeenCalledWith("Some description");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── MissionInterviewModal ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("MissionInterviewModal re-entry", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
isOpen: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onMissionCreated: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("reads persisted goal from localStorage when no prop provided", async () => {
|
||||||
|
mockGetMissionGoal.mockReturnValue("Persisted mission goal");
|
||||||
|
|
||||||
|
render(<MissionInterviewModal {...defaultProps} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockGetMissionGoal).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the textarea has the persisted value
|
||||||
|
const textarea = document.getElementById("mission-goal") as HTMLTextAreaElement;
|
||||||
|
expect(textarea).toBeTruthy();
|
||||||
|
expect(textarea.value).toBe("Persisted mission goal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses prop value instead of localStorage when initialGoal prop is provided", async () => {
|
||||||
|
mockGetMissionGoal.mockReturnValue("From localStorage");
|
||||||
|
|
||||||
|
render(<MissionInterviewModal {...defaultProps} initialGoal="From prop" />);
|
||||||
|
|
||||||
|
// Wait for auto-start
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockStartMissionInterview).toHaveBeenCalledWith("From prop", undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
// localStorage should NOT be read since prop was provided
|
||||||
|
expect(mockGetMissionGoal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears localStorage when interview starts successfully", async () => {
|
||||||
|
render(<MissionInterviewModal {...defaultProps} initialGoal="Build a platform" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockStartMissionInterview).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// clearMissionGoal is called immediately after startMissionInterview
|
||||||
|
expect(mockClearMissionGoal).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves goal to localStorage on cancel", async () => {
|
||||||
|
vi.stubGlobal("confirm", vi.fn(() => true));
|
||||||
|
|
||||||
|
const { unmount } = render(<MissionInterviewModal {...defaultProps} />);
|
||||||
|
|
||||||
|
// Type something in the textarea
|
||||||
|
const textarea = document.getElementById("mission-goal") as HTMLTextAreaElement;
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "My mission goal" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click the close button
|
||||||
|
const closeButton = screen.getByLabelText("Close");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(closeButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSaveMissionGoal).toHaveBeenCalledWith("My mission goal");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not save empty goal to localStorage on cancel", async () => {
|
||||||
|
vi.stubGlobal("confirm", vi.fn(() => true));
|
||||||
|
|
||||||
|
const { unmount } = render(<MissionInterviewModal {...defaultProps} />);
|
||||||
|
|
||||||
|
// Click the close button without typing anything
|
||||||
|
const closeButton = screen.getByLabelText("Close");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(closeButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSaveMissionGoal).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Cross-modal storage independence ────────────────────────────────
|
||||||
|
|
||||||
|
describe("Storage independence", () => {
|
||||||
|
it("each modal type uses independent persistence functions", () => {
|
||||||
|
// Verify the mock functions are distinct (unit-level independence)
|
||||||
|
expect(mockSavePlanningDescription).not.toBe(mockSaveSubtaskDescription);
|
||||||
|
expect(mockSavePlanningDescription).not.toBe(mockSaveMissionGoal);
|
||||||
|
expect(mockSaveSubtaskDescription).not.toBe(mockSaveMissionGoal);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
144
packages/dashboard/app/hooks/__tests__/modalPersistence.test.ts
Normal file
144
packages/dashboard/app/hooks/__tests__/modalPersistence.test.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
STORED_PLANNING_KEY,
|
||||||
|
STORED_SUBTASK_KEY,
|
||||||
|
STORED_MISSION_KEY,
|
||||||
|
savePlanningDescription,
|
||||||
|
getPlanningDescription,
|
||||||
|
clearPlanningDescription,
|
||||||
|
saveSubtaskDescription,
|
||||||
|
getSubtaskDescription,
|
||||||
|
clearSubtaskDescription,
|
||||||
|
saveMissionGoal,
|
||||||
|
getMissionGoal,
|
||||||
|
clearMissionGoal,
|
||||||
|
} from "../modalPersistence";
|
||||||
|
|
||||||
|
describe("modalPersistence", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Storage keys are exported", () => {
|
||||||
|
it("exports planning key", () => {
|
||||||
|
expect(STORED_PLANNING_KEY).toBe("kb-planning-last-description");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports subtask key", () => {
|
||||||
|
expect(STORED_SUBTASK_KEY).toBe("kb-subtask-last-description");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports mission key", () => {
|
||||||
|
expect(STORED_MISSION_KEY).toBe("kb-mission-last-goal");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Planning persistence", () => {
|
||||||
|
it("saves and retrieves planning description", () => {
|
||||||
|
savePlanningDescription("Build authentication");
|
||||||
|
expect(getPlanningDescription()).toBe("Build authentication");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string when nothing saved", () => {
|
||||||
|
expect(getPlanningDescription()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears correctly", () => {
|
||||||
|
savePlanningDescription("Test");
|
||||||
|
clearPlanningDescription();
|
||||||
|
expect(getPlanningDescription()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string when localStorage returns null", () => {
|
||||||
|
vi.spyOn(Storage.prototype, "getItem").mockReturnValue(null);
|
||||||
|
expect(getPlanningDescription()).toBe("");
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overwrites previous value", () => {
|
||||||
|
savePlanningDescription("First");
|
||||||
|
savePlanningDescription("Second");
|
||||||
|
expect(getPlanningDescription()).toBe("Second");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Subtask persistence", () => {
|
||||||
|
it("saves and retrieves subtask description", () => {
|
||||||
|
saveSubtaskDescription("Implement login feature");
|
||||||
|
expect(getSubtaskDescription()).toBe("Implement login feature");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string when nothing saved", () => {
|
||||||
|
expect(getSubtaskDescription()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears correctly", () => {
|
||||||
|
saveSubtaskDescription("Test");
|
||||||
|
clearSubtaskDescription();
|
||||||
|
expect(getSubtaskDescription()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overwrites previous value", () => {
|
||||||
|
saveSubtaskDescription("First");
|
||||||
|
saveSubtaskDescription("Second");
|
||||||
|
expect(getSubtaskDescription()).toBe("Second");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Mission persistence", () => {
|
||||||
|
it("saves and retrieves mission goal", () => {
|
||||||
|
saveMissionGoal("Build a SaaS platform");
|
||||||
|
expect(getMissionGoal()).toBe("Build a SaaS platform");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string when nothing saved", () => {
|
||||||
|
expect(getMissionGoal()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears correctly", () => {
|
||||||
|
saveMissionGoal("Test");
|
||||||
|
clearMissionGoal();
|
||||||
|
expect(getMissionGoal()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overwrites previous value", () => {
|
||||||
|
saveMissionGoal("First");
|
||||||
|
saveMissionGoal("Second");
|
||||||
|
expect(getMissionGoal()).toBe("Second");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Storage keys are independent", () => {
|
||||||
|
it("planning and subtask do not interfere", () => {
|
||||||
|
savePlanningDescription("planning desc");
|
||||||
|
saveSubtaskDescription("subtask desc");
|
||||||
|
expect(getPlanningDescription()).toBe("planning desc");
|
||||||
|
expect(getSubtaskDescription()).toBe("subtask desc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("planning and mission do not interfere", () => {
|
||||||
|
savePlanningDescription("planning desc");
|
||||||
|
saveMissionGoal("mission goal");
|
||||||
|
expect(getPlanningDescription()).toBe("planning desc");
|
||||||
|
expect(getMissionGoal()).toBe("mission goal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtask and mission do not interfere", () => {
|
||||||
|
saveSubtaskDescription("subtask desc");
|
||||||
|
saveMissionGoal("mission goal");
|
||||||
|
expect(getSubtaskDescription()).toBe("subtask desc");
|
||||||
|
expect(getMissionGoal()).toBe("mission goal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearing one does not affect others", () => {
|
||||||
|
savePlanningDescription("planning");
|
||||||
|
saveSubtaskDescription("subtask");
|
||||||
|
saveMissionGoal("mission");
|
||||||
|
|
||||||
|
clearSubtaskDescription();
|
||||||
|
expect(getPlanningDescription()).toBe("planning");
|
||||||
|
expect(getSubtaskDescription()).toBe("");
|
||||||
|
expect(getMissionGoal()).toBe("mission");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
61
packages/dashboard/app/hooks/modalPersistence.ts
Normal file
61
packages/dashboard/app/hooks/modalPersistence.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Storage keys — each modal type has independent storage
|
||||||
|
export const STORED_PLANNING_KEY = "kb-planning-last-description";
|
||||||
|
export const STORED_SUBTASK_KEY = "kb-subtask-last-description";
|
||||||
|
export const STORED_MISSION_KEY = "kb-mission-last-goal";
|
||||||
|
|
||||||
|
// Planning persistence
|
||||||
|
|
||||||
|
export function savePlanningDescription(description: string): void {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem(STORED_PLANNING_KEY, description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlanningDescription(): string {
|
||||||
|
if (typeof window === "undefined") return "";
|
||||||
|
return localStorage.getItem(STORED_PLANNING_KEY) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPlanningDescription(): void {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.removeItem(STORED_PLANNING_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtask persistence
|
||||||
|
|
||||||
|
export function saveSubtaskDescription(description: string): void {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem(STORED_SUBTASK_KEY, description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSubtaskDescription(): string {
|
||||||
|
if (typeof window === "undefined") return "";
|
||||||
|
return localStorage.getItem(STORED_SUBTASK_KEY) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSubtaskDescription(): void {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.removeItem(STORED_SUBTASK_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mission persistence
|
||||||
|
|
||||||
|
export function saveMissionGoal(goal: string): void {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem(STORED_MISSION_KEY, goal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMissionGoal(): string {
|
||||||
|
if (typeof window === "undefined") return "";
|
||||||
|
return localStorage.getItem(STORED_MISSION_KEY) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearMissionGoal(): void {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.removeItem(STORED_MISSION_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user