feat(FN-1621): merge fusion/fn-1621
This commit is contained in:
@@ -13,6 +13,7 @@ const mockParseConversationHistory = vi.fn();
|
||||
const mockAcquireSessionLock = vi.fn();
|
||||
const mockReleaseSessionLock = vi.fn();
|
||||
const mockForceAcquireSessionLock = vi.fn();
|
||||
const mockFetchModels = vi.fn();
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args),
|
||||
@@ -26,6 +27,7 @@ vi.mock("../api", () => ({
|
||||
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
|
||||
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
|
||||
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
|
||||
fetchModels: (...args: any[]) => mockFetchModels(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/modalPersistence", () => ({
|
||||
@@ -74,6 +76,7 @@ describe("MissionInterviewModal", () => {
|
||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
});
|
||||
|
||||
function renderModal() {
|
||||
@@ -121,7 +124,7 @@ describe("MissionInterviewModal", () => {
|
||||
fireEvent.click(screen.getByText("Start Interview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("Build a mission planning workflow", undefined);
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("Build a mission planning workflow", undefined, undefined);
|
||||
expect(streamHandlers).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
connectMissionInterviewStream,
|
||||
fetchAiSession,
|
||||
parseConversationHistory,
|
||||
fetchModels,
|
||||
type MissionPlanSummary,
|
||||
type ConversationHistoryEntry,
|
||||
type MissionPlanMilestone,
|
||||
type MissionPlanSlice,
|
||||
type MissionPlanFeature,
|
||||
type MissionWithHierarchy,
|
||||
type ModelInfo,
|
||||
} from "../api";
|
||||
import {
|
||||
saveMissionGoal,
|
||||
@@ -41,10 +43,32 @@ import {
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { useSessionLock } from "../hooks/useSessionLock";
|
||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
|
||||
// Helper functions for model selection
|
||||
function getModelSelectionValue(provider?: string, modelId?: string): string {
|
||||
return provider && modelId ? `${provider}/${modelId}` : "";
|
||||
}
|
||||
|
||||
function parseModelSelection(value: string): { provider?: string; modelId?: string } {
|
||||
if (!value) {
|
||||
return { provider: undefined, modelId: undefined };
|
||||
}
|
||||
|
||||
const slashIndex = value.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return { provider: undefined, modelId: undefined };
|
||||
}
|
||||
|
||||
return {
|
||||
provider: value.slice(0, slashIndex),
|
||||
modelId: value.slice(slashIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
interface MissionInterviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -113,6 +137,56 @@ export function MissionInterviewModal({
|
||||
broadcastHeartbeat,
|
||||
} = useAiSessionSync();
|
||||
|
||||
// Model selection state
|
||||
const [modelProvider, setModelProvider] = useState<string | undefined>(undefined);
|
||||
const [modelId, setModelId] = useState<string | undefined>(undefined);
|
||||
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(true);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||||
|
||||
const modelSelectionValue = getModelSelectionValue(modelProvider, modelId);
|
||||
|
||||
// Load models on mount
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
setModelsLoading(true);
|
||||
const resp = await fetchModels();
|
||||
setLoadedModels(resp.models);
|
||||
setFavoriteProviders(resp.favoriteProviders);
|
||||
setFavoriteModels(resp.favoriteModels);
|
||||
} catch (err: any) {
|
||||
setModelsError(err.message || "Failed to load models");
|
||||
} finally {
|
||||
setModelsLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const handleToggleFavoriteProvider = useCallback((provider: string) => {
|
||||
setFavoriteProviders((prev) =>
|
||||
prev.includes(provider) ? prev.filter((item) => item !== provider) : [...prev, provider],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleToggleFavoriteModel = useCallback((modelIdToToggle: string) => {
|
||||
setFavoriteModels((prev) =>
|
||||
prev.includes(modelIdToToggle) ? prev.filter((item) => item !== modelIdToToggle) : [...prev, modelIdToToggle],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const getModelBadgeLabel = useCallback(
|
||||
(provider?: string, mid?: string) => {
|
||||
if (!provider || !mid) return "Using default";
|
||||
const matched = loadedModels.find((model) => model.provider === provider && model.id === mid);
|
||||
return matched ? `${matched.provider}/${matched.id}` : `${provider}/${mid}`;
|
||||
},
|
||||
[loadedModels],
|
||||
);
|
||||
|
||||
const connectToMissionInterviewStream = useCallback(
|
||||
(sessionId: string) => {
|
||||
streamConnectionRef.current?.close();
|
||||
@@ -216,7 +290,11 @@ export function MissionInterviewModal({
|
||||
setView({ type: "loading" });
|
||||
|
||||
try {
|
||||
const { sessionId } = await startMissionInterview(goal.trim(), projectId);
|
||||
const { sessionId } = await startMissionInterview(
|
||||
goal.trim(),
|
||||
projectId,
|
||||
modelProvider && modelId ? { modelProvider, modelId } : undefined,
|
||||
);
|
||||
currentSessionIdRef.current = sessionId;
|
||||
setLockSessionId(sessionId);
|
||||
clearMissionGoal(projectId);
|
||||
@@ -231,7 +309,7 @@ export function MissionInterviewModal({
|
||||
setLockSessionId(null);
|
||||
}
|
||||
},
|
||||
[connectToMissionInterviewStream, missionGoal, projectId]
|
||||
[connectToMissionInterviewStream, missionGoal, modelProvider, modelId, projectId]
|
||||
);
|
||||
|
||||
// Focus textarea when opening
|
||||
@@ -446,6 +524,8 @@ export function MissionInterviewModal({
|
||||
setIsRetrying(false);
|
||||
setHasProgress(false);
|
||||
setIsCreating(false);
|
||||
setModelProvider(undefined);
|
||||
setModelId(undefined);
|
||||
currentSessionIdRef.current = null;
|
||||
setLockSessionId(null);
|
||||
onClose();
|
||||
@@ -659,6 +739,71 @@ export function MissionInterviewModal({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="planning-model-select-group">
|
||||
<label htmlFor="mission-interview-modal-model" className="form-label">
|
||||
Planning Model
|
||||
{modelsLoading && (
|
||||
<span className="text-muted text-muted-sm">
|
||||
Loading models…
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<CustomModelDropdown
|
||||
id="mission-interview-modal-model"
|
||||
label="Planning Model"
|
||||
value={modelSelectionValue}
|
||||
onChange={(value) => {
|
||||
const { provider, modelId: selectedModelId } = parseModelSelection(value);
|
||||
setModelProvider(provider);
|
||||
setModelId(selectedModelId);
|
||||
}}
|
||||
models={loadedModels}
|
||||
disabled={modelsLoading}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavoriteProvider}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={handleToggleFavoriteModel}
|
||||
/>
|
||||
{modelsError && (
|
||||
<div className="form-hint form-hint-error">
|
||||
{modelsError}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-link-btn"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setModelsLoading(true);
|
||||
const resp = await fetchModels();
|
||||
setLoadedModels(resp.models);
|
||||
setFavoriteProviders(resp.favoriteProviders);
|
||||
setFavoriteModels(resp.favoriteModels);
|
||||
setModelsError(null);
|
||||
} catch (err: any) {
|
||||
setModelsError(err.message || "Failed to load models");
|
||||
} finally {
|
||||
setModelsLoading(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="model-selector-current model-selector-current--spaced">
|
||||
<span
|
||||
className={`model-badge ${
|
||||
modelProvider && modelId
|
||||
? "model-badge-custom"
|
||||
: "model-badge-default"
|
||||
}`}
|
||||
>
|
||||
{getModelBadgeLabel(modelProvider, modelId)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="planning-view-footer">
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("../../api", () => ({
|
||||
acquireSessionLock: vi.fn(),
|
||||
releaseSessionLock: vi.fn(),
|
||||
forceAcquireSessionLock: vi.fn(),
|
||||
fetchModels: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/modalPersistence", () => ({
|
||||
@@ -35,6 +36,7 @@ const mockParseConversationHistory = vi.mocked(api.parseConversationHistory);
|
||||
const mockAcquireSessionLock = vi.mocked(api.acquireSessionLock);
|
||||
const mockReleaseSessionLock = vi.mocked(api.releaseSessionLock);
|
||||
const mockForceAcquireSessionLock = vi.mocked(api.forceAcquireSessionLock);
|
||||
const mockFetchModels = vi.mocked(api.fetchModels);
|
||||
const mockGetMissionGoal = vi.mocked(modalPersistence.getMissionGoal);
|
||||
|
||||
const sampleQuestionSingle: PlanningQuestion = {
|
||||
@@ -105,6 +107,7 @@ describe("MissionInterviewModal", () => {
|
||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
|
||||
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
||||
streamHandlers = handlers;
|
||||
@@ -136,7 +139,7 @@ describe("MissionInterviewModal", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Start Interview" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith(goal, undefined);
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith(goal, undefined, undefined);
|
||||
expect(streamHandlers).toBeDefined();
|
||||
});
|
||||
}
|
||||
@@ -385,7 +388,7 @@ describe("MissionInterviewModal", () => {
|
||||
renderModal({ initialGoal: "Auto-start goal" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("Auto-start goal", undefined);
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("Auto-start goal", undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock("../../api", async () => {
|
||||
skipMilestoneInterview: (...args: any[]) => mockSkipMilestoneInterview(...args),
|
||||
skipSliceInterview: (...args: any[]) => mockSkipSliceInterview(...args),
|
||||
triageFeature: (...args: any[]) => mockTriageFeature(...args),
|
||||
fetchModels: () => Promise.resolve({ models: [], favoriteProviders: [], favoriteModels: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -354,7 +354,7 @@ describe("ModalReentry", () => {
|
||||
|
||||
// Wait for auto-start
|
||||
await waitFor(() => {
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("From prop", undefined);
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("From prop", undefined, undefined);
|
||||
});
|
||||
|
||||
// localStorage should NOT be read since prop was provided
|
||||
|
||||
Reference in New Issue
Block a user