feat(FN-3006): merge fusion/fn-3006
Commits merged: - fix(FN-3006): complete Step 3 — repair dashboard typecheck mapper signatures - test(FN-3006): complete Step 2 — cover Use default reselection flow - feat(FN-3006): complete Step 1 — resolve default model selection target Files changed: .../app/components/ModelOnboardingModal.tsx | 24 ++++++--- packages/dashboard/app/components/QuickChatFAB.tsx | 13 +++-- .../dashboard/app/components/SettingsModal.tsx | 24 ++++++--- .../app/components/__tests__/QuickChatFAB.test.tsx | 61 ++++++++++++++++++++++ 4 files changed, 103 insertions(+), 19 deletions(-) Fusion-Task-Id: FN-3006
This commit is contained in:
@@ -25,14 +25,22 @@ import { LoginInstructions } from "./LoginInstructions";
|
||||
import { CustomProviderForm } from "./CustomProviderForm";
|
||||
import { appendTokenQuery } from "../auth";
|
||||
|
||||
const mapLegacyCustomProviderToConfig = (provider: CustomProvider): CustomProviderConfig => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
baseUrl: provider.baseUrl,
|
||||
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-responses",
|
||||
apiKey: provider.apiKey,
|
||||
models: provider.models?.map((model) => ({ id: model.id, name: model.name })) ?? [],
|
||||
});
|
||||
const mapLegacyCustomProviderToConfig = (
|
||||
provider: CustomProvider | CustomProviderConfig,
|
||||
): CustomProviderConfig => {
|
||||
if ("api" in provider) {
|
||||
return provider;
|
||||
}
|
||||
|
||||
return {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
baseUrl: provider.baseUrl,
|
||||
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-responses",
|
||||
apiKey: provider.apiKey,
|
||||
models: provider.models?.map((model) => ({ id: model.id, name: model.name })) ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
/** Provider-specific API key setup metadata for onboarding form rendering */
|
||||
interface ApiKeyInfo {
|
||||
|
||||
@@ -727,6 +727,7 @@ export function QuickChatFAB({
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const [configuredDefaultModelSelection, setConfiguredDefaultModelSelection] = useState<string>("");
|
||||
const [messageInput, setMessageInput] = useState("");
|
||||
const [mentionFilter, setMentionFilter] = useState("");
|
||||
const [mentionPopupVisible, setMentionPopupVisible] = useState(false);
|
||||
@@ -805,10 +806,12 @@ export function QuickChatFAB({
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
|
||||
|
||||
const parsedModelSelection = useMemo(() => parseModelSelection(selectedModel), [selectedModel]);
|
||||
const resolvedModelSelection = selectedModel || configuredDefaultModelSelection;
|
||||
|
||||
const parsedModelSelection = useMemo(() => parseModelSelection(resolvedModelSelection), [resolvedModelSelection]);
|
||||
const selectedModelInfo = useMemo(
|
||||
() => models.find((model) => `${model.provider}/${model.id}` === selectedModel) ?? null,
|
||||
[models, selectedModel],
|
||||
() => models.find((model) => `${model.provider}/${model.id}` === resolvedModelSelection) ?? null,
|
||||
[models, resolvedModelSelection],
|
||||
);
|
||||
const selectedModelTag = useMemo(
|
||||
() => formatModelTagName(selectedModelInfo, parsedModelSelection),
|
||||
@@ -870,6 +873,7 @@ export function QuickChatFAB({
|
||||
(model) => `${model.provider}/${model.id}` === defaultSelection,
|
||||
);
|
||||
if (hasDefaultModel) {
|
||||
setConfiguredDefaultModelSelection(defaultSelection);
|
||||
setSelectedModel(defaultSelection);
|
||||
// Switch to model mode regardless of whether agents are present —
|
||||
// a configured default model is an explicit user preference and
|
||||
@@ -881,6 +885,8 @@ export function QuickChatFAB({
|
||||
}
|
||||
}
|
||||
|
||||
setConfiguredDefaultModelSelection("");
|
||||
|
||||
// Always pre-select the first model so users can start chatting in model mode
|
||||
// without having to manually pick from the dropdown.
|
||||
const firstModel = loadedModels[0];
|
||||
@@ -891,6 +897,7 @@ export function QuickChatFAB({
|
||||
.catch((error: unknown) => {
|
||||
console.error("[QuickChatFAB] Failed to load models:", error);
|
||||
setModels([]);
|
||||
setConfiguredDefaultModelSelection("");
|
||||
})
|
||||
.finally(() => {
|
||||
setModelsLoading(false);
|
||||
|
||||
@@ -46,14 +46,22 @@ const GITHUB_STAR_CACHE_KEY = "fusion_github_star_count";
|
||||
const GITHUB_STAR_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const GITHUB_STAR_CLICKED_KEY = "fusion:github-star-clicked";
|
||||
|
||||
const mapLegacyCustomProviderToConfig = (provider: CustomProvider): CustomProviderConfig => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
baseUrl: provider.baseUrl,
|
||||
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-responses",
|
||||
apiKey: provider.apiKey,
|
||||
models: provider.models?.map((model) => ({ id: model.id, name: model.name })) ?? [],
|
||||
});
|
||||
const mapLegacyCustomProviderToConfig = (
|
||||
provider: CustomProvider | CustomProviderConfig,
|
||||
): CustomProviderConfig => {
|
||||
if ("api" in provider) {
|
||||
return provider;
|
||||
}
|
||||
|
||||
return {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
baseUrl: provider.baseUrl,
|
||||
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-responses",
|
||||
apiKey: provider.apiKey,
|
||||
models: provider.models?.map((model) => ({ id: model.id, name: model.name })) ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error"): string {
|
||||
if (status === "online") return "Online";
|
||||
|
||||
@@ -298,6 +298,67 @@ describe("QuickChatFAB", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reselecting Use default resolves to configured default model and keeps send enabled", async () => {
|
||||
mockAgentsHook([]);
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: mockModels,
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-4o",
|
||||
});
|
||||
|
||||
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-chat-model-tag")).toHaveTextContent("GPT-4o");
|
||||
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await selectModelOption("Claude Sonnet 4.5");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-chat-model-tag")).toHaveTextContent("Claude Sonnet 4.5");
|
||||
expect(mockCreateChatSession).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: "__fn_agent__",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
},
|
||||
"proj-123",
|
||||
);
|
||||
});
|
||||
|
||||
await selectModelOption("Use default");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-chat-model-tag")).toHaveTextContent("GPT-4o");
|
||||
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
|
||||
expect(mockCreateChatSession).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: "__fn_agent__",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
},
|
||||
"proj-123",
|
||||
);
|
||||
});
|
||||
|
||||
const input = screen.getByTestId("quick-chat-input");
|
||||
fireEvent.change(input, { target: { value: "use default still sends" } });
|
||||
fireEvent.click(screen.getByTestId("quick-chat-send"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledWith(
|
||||
"session-001",
|
||||
"use default still sends",
|
||||
expect.any(Object),
|
||||
[],
|
||||
"proj-123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves existing behavior when no default model is configured and agents exist", async () => {
|
||||
mockAgentsHook(mockAgents);
|
||||
mockFetchModels.mockResolvedValue({
|
||||
|
||||
Reference in New Issue
Block a user