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:
Fusion
2026-04-29 23:41:35 -07:00
committed by gsxdsm
parent 74989993a3
commit 8c641a8291
4 changed files with 103 additions and 19 deletions

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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";

View File

@@ -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({