feat(FN-2843): auto-select quick chat default model
- Extend /models API responses to include optional defaultProvider/defaultModelId from global settings, including empty and error paths - Update QuickChatFAB to prefer the configured default model on load and fall back to first-model selection only when no agents exist - Move quick chat model tag/title-wrap inline styles into QuickChatFAB.css using dashboard design tokens - Add QuickChatFAB tests covering default-model auto-selection with and without agents and legacy behavior when no default is configured
This commit is contained in:
@@ -1114,6 +1114,8 @@ export interface ModelsResponse {
|
|||||||
models: ModelInfo[];
|
models: ModelInfo[];
|
||||||
favoriteProviders: string[];
|
favoriteProviders: string[];
|
||||||
favoriteModels: string[];
|
favoriteModels: string[];
|
||||||
|
defaultProvider?: string;
|
||||||
|
defaultModelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch available AI models from the model registry along with favoriteProviders */
|
/** Fetch available AI models from the model registry along with favoriteProviders */
|
||||||
|
|||||||
@@ -188,6 +188,28 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-chat-panel-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-chat-model-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 18ch;
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--todo) 35%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--todo) 14%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.quick-chat-panel-header-actions {
|
.quick-chat-panel-header-actions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -46,29 +46,6 @@ interface ParsedModelSelection {
|
|||||||
modelId: string;
|
modelId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelTagStyle = {
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
maxWidth: "180px",
|
|
||||||
padding: "var(--space-xs) var(--space-sm)",
|
|
||||||
borderRadius: "var(--radius-pill)",
|
|
||||||
border: "1px solid color-mix(in srgb, var(--todo) 35%, var(--border))",
|
|
||||||
background: "color-mix(in srgb, var(--todo) 14%, transparent)",
|
|
||||||
color: "var(--text)",
|
|
||||||
fontSize: "11px",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const headerTitleWrapStyle = {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "var(--space-sm)",
|
|
||||||
minWidth: 0,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
|
|
||||||
function getAgentLabel(agent: Agent): string {
|
function getAgentLabel(agent: Agent): string {
|
||||||
const base = agent.name?.trim() || agent.id;
|
const base = agent.name?.trim() || agent.id;
|
||||||
return `${base} (${agent.role})`;
|
return `${base} (${agent.role})`;
|
||||||
@@ -807,8 +784,27 @@ export function QuickChatFAB({
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
const loadedModels = response.models ?? [];
|
const loadedModels = response.models ?? [];
|
||||||
setModels(loadedModels);
|
setModels(loadedModels);
|
||||||
// Auto-select first model when no agents exist and no model selected yet
|
|
||||||
if (agents.length === 0 && loadedModels.length > 0 && !selectedModel) {
|
if (selectedModel || loadedModels.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProvider = response.defaultProvider;
|
||||||
|
const defaultModelId = response.defaultModelId;
|
||||||
|
if (defaultProvider && defaultModelId) {
|
||||||
|
const defaultSelection = `${defaultProvider}/${defaultModelId}`;
|
||||||
|
const hasDefaultModel = loadedModels.some(
|
||||||
|
(model) => `${model.provider}/${model.id}` === defaultSelection,
|
||||||
|
);
|
||||||
|
if (hasDefaultModel) {
|
||||||
|
setSelectedModel(defaultSelection);
|
||||||
|
setChatMode("model");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: auto-select first model only when no agents exist.
|
||||||
|
if (agents.length === 0) {
|
||||||
const firstModel = loadedModels[0];
|
const firstModel = loadedModels[0];
|
||||||
if (firstModel) {
|
if (firstModel) {
|
||||||
setSelectedModel(`${firstModel.provider}/${firstModel.id}`);
|
setSelectedModel(`${firstModel.provider}/${firstModel.id}`);
|
||||||
@@ -1353,10 +1349,10 @@ export function QuickChatFAB({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="quick-chat-panel-header">
|
<div className="quick-chat-panel-header">
|
||||||
<div style={headerTitleWrapStyle}>
|
<div className="quick-chat-panel-title-wrap">
|
||||||
<h3>Quick Chat</h3>
|
<h3>Quick Chat</h3>
|
||||||
{selectedModelTag && (
|
{selectedModelTag && (
|
||||||
<span style={modelTagStyle} data-testid="quick-chat-model-tag" title={selectedModelTag}>
|
<span className="quick-chat-model-tag" data-testid="quick-chat-model-tag" title={selectedModelTag}>
|
||||||
{selectedModelTag}
|
{selectedModelTag}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -487,6 +487,7 @@ describe("ActivityLogModal", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("activity-log-modal")).toBeTruthy();
|
expect(screen.getByTestId("activity-log-modal")).toBeTruthy();
|
||||||
|
expect(screen.getAllByTestId("activity-entry").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify key structural classes that the mobile CSS targets
|
// Verify key structural classes that the mobile CSS targets
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ describe("QuickChatFAB", () => {
|
|||||||
models: mockModels,
|
models: mockModels,
|
||||||
favoriteProviders: [],
|
favoriteProviders: [],
|
||||||
favoriteModels: [],
|
favoriteModels: [],
|
||||||
|
defaultProvider: undefined,
|
||||||
|
defaultModelId: undefined,
|
||||||
});
|
});
|
||||||
createMockStreamResponse();
|
createMockStreamResponse();
|
||||||
});
|
});
|
||||||
@@ -185,6 +187,63 @@ describe("QuickChatFAB", () => {
|
|||||||
expect(screen.queryByTestId("quick-chat-agent-select")).toBeNull();
|
expect(screen.queryByTestId("quick-chat-agent-select")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("auto-selects configured default model when no agents exist", async () => {
|
||||||
|
mockAgentsHook([]);
|
||||||
|
mockFetchModels.mockResolvedValue({
|
||||||
|
models: mockModels,
|
||||||
|
favoriteProviders: [],
|
||||||
|
favoriteModels: [],
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModelId: "gpt-4o",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<QuickChatFAB addToast={addToast} />);
|
||||||
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("quick-chat-model-tag")).toHaveTextContent("GPT-4o");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-selects configured default model and switches to model mode when agents exist", async () => {
|
||||||
|
mockAgentsHook(mockAgents);
|
||||||
|
mockFetchModels.mockResolvedValue({
|
||||||
|
models: mockModels,
|
||||||
|
favoriteProviders: [],
|
||||||
|
favoriteModels: [],
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModelId: "gpt-4o",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<QuickChatFAB addToast={addToast} />);
|
||||||
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("quick-chat-model-select")).toBeDefined();
|
||||||
|
expect(screen.getByTestId("quick-chat-model-tag")).toHaveTextContent("GPT-4o");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves existing behavior when no default model is configured and agents exist", async () => {
|
||||||
|
mockAgentsHook(mockAgents);
|
||||||
|
mockFetchModels.mockResolvedValue({
|
||||||
|
models: mockModels,
|
||||||
|
favoriteProviders: [],
|
||||||
|
favoriteModels: [],
|
||||||
|
defaultProvider: undefined,
|
||||||
|
defaultModelId: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<QuickChatFAB addToast={addToast} />);
|
||||||
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("quick-chat-agent-select")).toBeDefined();
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId("quick-chat-model-select")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("quick-chat-model-tag")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders FAB button when agents exist", () => {
|
it("renders FAB button when agents exist", () => {
|
||||||
render(<QuickChatFAB addToast={addToast} />);
|
render(<QuickChatFAB addToast={addToast} />);
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,40 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
const { router, options, store, runtimeLogger } = ctx;
|
const { router, options, store, runtimeLogger } = ctx;
|
||||||
|
|
||||||
router.get("/models", async (_req, res) => {
|
router.get("/models", async (_req, res) => {
|
||||||
|
// Get favoriteProviders/favoriteModels and default model from global settings.
|
||||||
|
let favoriteProviders: string[] = [];
|
||||||
|
let favoriteModels: string[] = [];
|
||||||
|
let defaultProvider: string | undefined;
|
||||||
|
let defaultModelId: string | undefined;
|
||||||
|
let useClaudeCli = false;
|
||||||
|
if (store) {
|
||||||
|
try {
|
||||||
|
const globalStore = store.getGlobalSettingsStore();
|
||||||
|
const globalSettings = await globalStore.getSettings();
|
||||||
|
favoriteProviders = globalSettings.favoriteProviders ?? [];
|
||||||
|
favoriteModels = globalSettings.favoriteModels ?? [];
|
||||||
|
defaultProvider = globalSettings.defaultProvider;
|
||||||
|
defaultModelId = globalSettings.defaultModelId;
|
||||||
|
useClaudeCli = globalSettings.useClaudeCli === true;
|
||||||
|
} catch {
|
||||||
|
// Silently ignore settings errors - just return empty favorites/default model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultModelResponse =
|
||||||
|
defaultProvider && defaultModelId
|
||||||
|
? { defaultProvider, defaultModelId }
|
||||||
|
: {};
|
||||||
|
|
||||||
// Always return 200 with empty array instead of 404 when no models available.
|
// Always return 200 with empty array instead of 404 when no models available.
|
||||||
// This ensures the frontend can handle empty states gracefully.
|
// This ensures the frontend can handle empty states gracefully.
|
||||||
if (!options?.modelRegistry) {
|
if (!options?.modelRegistry) {
|
||||||
res.json({ models: [], favoriteProviders: [], favoriteModels: [] });
|
res.json({
|
||||||
|
models: [],
|
||||||
|
favoriteProviders,
|
||||||
|
favoriteModels,
|
||||||
|
...defaultModelResponse,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,22 +52,6 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
contextWindow: m.contextWindow,
|
contextWindow: m.contextWindow,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Get favoriteProviders and favoriteModels from global settings
|
|
||||||
let favoriteProviders: string[] = [];
|
|
||||||
let favoriteModels: string[] = [];
|
|
||||||
let useClaudeCli = false;
|
|
||||||
if (store) {
|
|
||||||
try {
|
|
||||||
const globalStore = store.getGlobalSettingsStore();
|
|
||||||
const globalSettings = await globalStore.getSettings();
|
|
||||||
favoriteProviders = globalSettings.favoriteProviders ?? [];
|
|
||||||
favoriteModels = globalSettings.favoriteModels ?? [];
|
|
||||||
useClaudeCli = globalSettings.useClaudeCli === true;
|
|
||||||
} catch {
|
|
||||||
// Silently ignore settings errors - just return empty favorites
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The vendored pi-claude-cli extension registers its provider as
|
// The vendored pi-claude-cli extension registers its provider as
|
||||||
// "pi-claude-cli" (distinct from "anthropic") whenever it loads.
|
// "pi-claude-cli" (distinct from "anthropic") whenever it loads.
|
||||||
// When the toggle is OFF, hide those entries from pickers so users
|
// When the toggle is OFF, hide those entries from pickers so users
|
||||||
@@ -48,14 +62,24 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
models = models.filter((m) => m.provider !== "pi-claude-cli");
|
models = models.filter((m) => m.provider !== "pi-claude-cli");
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ models, favoriteProviders, favoriteModels });
|
res.json({
|
||||||
|
models,
|
||||||
|
favoriteProviders,
|
||||||
|
favoriteModels,
|
||||||
|
...defaultModelResponse,
|
||||||
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
runtimeLogger.child("models").warn(`Failed to load models: ${message}`);
|
runtimeLogger.child("models").warn(`Failed to load models: ${message}`);
|
||||||
res.json({ models: [], favoriteProviders: [], favoriteModels: [] });
|
res.json({
|
||||||
|
models: [],
|
||||||
|
favoriteProviders,
|
||||||
|
favoriteModels,
|
||||||
|
...defaultModelResponse,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user