feat(ui): show recent used models in chat footer model picker (#11097)

Signed-off-by: Abhijay Jain <Abhijay007j@gmail.com>
This commit is contained in:
Abhijay Jain
2026-08-12 14:13:00 +05:30
committed by GitHub
parent a19ea60bf2
commit a04d89f869
22 changed files with 150 additions and 5 deletions
@@ -29,6 +29,7 @@ vi.mock('../../../ConfigContext', () => ({
vi.mock('../modelInterface', () => ({
getProviderMetadata: vi.fn().mockResolvedValue({ display_name: 'Config Provider' }),
fetchModelReasoning: vi.fn().mockResolvedValue(null),
}));
vi.mock('../predefinedModelsUtils', () => ({
@@ -1,5 +1,5 @@
import { Sliders, Bot, LoaderCircle, Settings } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { Sliders, Bot, LoaderCircle, Settings, History } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useModelAndProvider } from '../../../ModelAndProviderContext';
import { SwitchModelModal } from '../subcomponents/SwitchModelModal';
import { View } from '../../../../utils/navigationUtils';
@@ -7,15 +7,20 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../../../ui/dropdown-menu';
import { getProviderMetadata } from '../modelInterface';
import { getProviderMetadata, fetchModelReasoning } from '../modelInterface';
import { getModelDisplayName } from '../predefinedModelsUtils';
import { acpReadThinkingEffort } from '../../../../acp/providers';
import { ModelSettingsPanel } from '../../localInference/ModelSettingsPanel';
import { ScrollArea } from '../../../ui/scroll-area';
import { defineMessages, useIntl } from '../../../../i18n';
import type { Message } from '../../../../types/message';
import type { RecentModel } from '../../../../utils/settings';
import { addToRecentModels } from '../../../../utils/recentModels';
import { trackModelChanged } from '../../../../utils/analytics';
const i18n = defineMessages({
selectModel: {
@@ -46,6 +51,10 @@ const i18n = defineMessages({
id: 'modelsBottomBar.resolvedModel',
defaultMessage: 'Resolved model',
},
recentModels: {
id: 'modelsBottomBar.recentModels',
defaultMessage: 'Recent',
},
});
interface ModelsBottomBarProps {
@@ -71,7 +80,7 @@ export default function ModelsBottomBar({
}: ModelsBottomBarProps) {
// ChatInput owns the override state and passes effective model/provider as sessionModel/sessionProvider.
// Fall back to config defaults when no session-specific model is available.
const { currentModel: configModel, currentProvider: configProvider } = useModelAndProvider();
const { currentModel: configModel, currentProvider: configProvider, changeModel } = useModelAndProvider();
const currentModel = sessionModel ?? configModel;
const currentProvider = sessionProvider ?? configProvider;
@@ -83,6 +92,16 @@ export default function ModelsBottomBar({
const [isAddModelModalOpen, setIsAddModelModalOpen] = useState(false);
const [isLocalModelSettingsOpen, setIsLocalModelSettingsOpen] = useState(false);
const [providerDefaultModel, setProviderDefaultModel] = useState<string | null>(null);
const [recentModels, setRecentModels] = useState<RecentModel[]>([]);
const loadRecentModels = useCallback(async () => {
const stored = (await window.electron.getSetting('recentModels')) ?? [];
setRecentModels(stored);
}, []);
useEffect(() => {
void loadRecentModels();
}, [loadRecentModels]);
// Show a visible loading placeholder while session metadata is still being fetched,
// rather than flashing the config default or leaving the footer blank.
@@ -139,9 +158,41 @@ export default function ModelsBottomBar({
);
const handleModelSelected = (model: string, provider: string) => {
void loadRecentModels();
onModelChanged({ model, provider });
};
const handleRecentModelClick = async (recent: RecentModel) => {
const previousModel = currentModel;
const previousProvider = currentProvider;
const [reasoning, savedEffort] = await Promise.all([
fetchModelReasoning(recent.provider, recent.model),
acpReadThinkingEffort().catch(() => null),
]);
const modelArg = reasoning
? {
name: recent.model,
provider: recent.provider,
request_params: { thinking_effort: savedEffort ?? 'off' },
}
: { name: recent.model, provider: recent.provider };
const success = await changeModel(sessionId, modelArg);
if (success) {
trackModelChanged(recent.provider, recent.model);
if (previousModel && previousProvider) {
const updated = addToRecentModels(recentModels, previousProvider, previousModel);
await window.electron.setSetting('recentModels', updated);
setRecentModels(updated);
}
onModelChanged({ model: recent.model, provider: recent.provider });
}
};
const filteredRecentModels = recentModels.filter(
(r) => !(r.model === currentModel && r.provider === currentProvider)
);
return (
<div className="relative flex items-center" ref={dropdownRef}>
<DropdownMenu>
@@ -179,6 +230,23 @@ export default function ModelsBottomBar({
</p>
</div>
)}
{filteredRecentModels.length > 0 && (
<>
<h6 className="text-xs text-text-primary mt-2 ml-2">
{intl.formatMessage(i18n.recentModels)}
</h6>
{filteredRecentModels.map((recent) => (
<DropdownMenuItem
key={`${recent.provider}/${recent.model}`}
onClick={() => void handleRecentModelClick(recent)}
>
<History className="mr-2 h-3.5 w-3.5 flex-shrink-0 text-text-secondary" />
<span className="truncate">{getModelDisplayName(recent.model)} {recent.provider}</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={() => setIsAddModelModalOpen(true)}>
<span>{intl.formatMessage(i18n.changeModel)}</span>
<Sliders className="ml-auto h-4 w-4 rotate-90" />
@@ -29,6 +29,7 @@ import Model, {
import { getPredefinedModelsFromEnv, shouldShowPredefinedModels } from '../predefinedModelsUtils';
import type { ProviderDetails, ProviderType, ThinkingEffort } from '../../../../types/providers';
import { trackModelChanged } from '../../../../utils/analytics';
import { addToRecentModels } from '../../../../utils/recentModels';
const i18n = defineMessages({
thinkingEffortOff: {
@@ -425,8 +426,15 @@ export const SwitchModelModal = ({
const success = await changeModel(sessionId, modelObj);
if (success) {
onModelSelected?.(modelObj.name, modelObj.provider || '');
trackModelChanged(modelObj.provider || '', modelObj.name);
if (currentModel && currentProvider) {
const current = (await window.electron.getSetting('recentModels')) ?? [];
await window.electron.setSetting(
'recentModels',
addToRecentModels(current, currentProvider, currentModel)
);
}
onModelSelected?.(modelObj.name, modelObj.provider || '');
}
onClose();
+3
View File
@@ -4621,5 +4621,8 @@
},
"userMessage.removeImageFromEdit": {
"defaultMessage": "Bild aus Nachricht entfernen"
},
"modelsBottomBar.recentModels": {
"defaultMessage": "Zuletzt verwendet"
}
}
+3
View File
@@ -2330,6 +2330,9 @@
"modelsBottomBar.localModelSettingsTitle": {
"defaultMessage": "Local Model Settings — {modelName}"
},
"modelsBottomBar.recentModels": {
"defaultMessage": "Recent"
},
"modelsBottomBar.resolvedModel": {
"defaultMessage": "Resolved model"
},
+3
View File
@@ -4621,5 +4621,8 @@
},
"userMessage.removeImageFromEdit": {
"defaultMessage": "Eliminar imagen del mensaje"
},
"modelsBottomBar.recentModels": {
"defaultMessage": "Recientes"
}
}
+3
View File
@@ -4621,5 +4621,8 @@
},
"userMessage.removeImageFromEdit": {
"defaultMessage": "Supprimer l'image du message"
},
"modelsBottomBar.recentModels": {
"defaultMessage": "Récents"
}
}
+3
View File
@@ -4621,5 +4621,8 @@
},
"userMessage.removeImageFromEdit": {
"defaultMessage": "संदेश से छवि हटाएँ"
},
"modelsBottomBar.recentModels": {
"defaultMessage": "हाल के"
}
}
+3
View File
@@ -4621,5 +4621,8 @@
},
"userMessage.removeImageFromEdit": {
"defaultMessage": "Hapus gambar dari pesan"
},
"modelsBottomBar.recentModels": {
"defaultMessage": "Terbaru"
}
}
+3
View File
@@ -4621,5 +4621,8 @@
},
"userMessage.removeImageFromEdit": {
"defaultMessage": "Rimuovi immagine dal messaggio"
},
"modelsBottomBar.recentModels": {
"defaultMessage": "Recenti"
}
}
+3
View File
@@ -4621,5 +4621,8 @@
},
"userMessage.removeImageFromEdit": {
"defaultMessage": "メッセージから画像を削除"
},
"modelsBottomBar.recentModels": {
"defaultMessage": "最近"
}
}
+3
View File
@@ -4621,5 +4621,8 @@
},
"userMessage.removeImageFromEdit": {
"defaultMessage": "메시지에서 이미지 제거"
},
"modelsBottomBar.recentModels": {
"defaultMessage": "최근"
}
}
+3
View File
@@ -4621,5 +4621,8 @@
},
"userMessage.removeImageFromEdit": {
"defaultMessage": "Alih keluar imej daripada mesej"
},
"modelsBottomBar.recentModels": {
"defaultMessage": "Terkini"
}
}
+3
View File
@@ -4621,5 +4621,8 @@
},
"userMessage.removeImageFromEdit": {
"defaultMessage": "Remover imagem da mensagem"
},
"modelsBottomBar.recentModels": {
"defaultMessage": "Recentes"
}
}
+3
View File
@@ -4621,5 +4621,8 @@
},
"userMessage.removeImageFromEdit": {
"defaultMessage": "Удалить изображение из сообщения"
},
"modelsBottomBar.recentModels": {
"defaultMessage": "Недавние"
}
}
+3
View File
@@ -4621,5 +4621,8 @@
},
"userMessage.removeImageFromEdit": {
"defaultMessage": "Görseli mesajdan kaldır"
},
"modelsBottomBar.recentModels": {
"defaultMessage": "Son kullanılan"
}
}
+3
View File
@@ -4621,5 +4621,8 @@
},
"userMessage.removeImageFromEdit": {
"defaultMessage": "Xóa hình ảnh khỏi tin nhắn"
},
"modelsBottomBar.recentModels": {
"defaultMessage": "Gần đây"
}
}
+3
View File
@@ -4621,5 +4621,8 @@
},
"userMessage.removeImageFromEdit": {
"defaultMessage": "从消息中移除图片"
},
"modelsBottomBar.recentModels": {
"defaultMessage": "最近"
}
}
+3
View File
@@ -4621,5 +4621,8 @@
},
"userMessage.removeImageFromEdit": {
"defaultMessage": "從訊息中移除圖片"
},
"modelsBottomBar.recentModels": {
"defaultMessage": "最近"
}
}
+1
View File
@@ -1962,6 +1962,7 @@ const validSettingKeys: Set<string> = new Set([
'showPricing',
'seenAnnouncementIds',
'disableAutoDownload',
'recentModels',
]);
ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => {
+12
View File
@@ -0,0 +1,12 @@
import type { RecentModel } from './settings';
const MAX_RECENT_MODELS = 5;
export function addToRecentModels(
current: RecentModel[],
provider: string,
model: string
): RecentModel[] {
const filtered = current.filter((m) => !(m.provider === provider && m.model === model));
return [{ provider, model }, ...filtered].slice(0, MAX_RECENT_MODELS);
}
+7
View File
@@ -1,3 +1,8 @@
export type RecentModel = {
provider: string;
model: string;
};
export interface ExternalBackendConfig {
enabled: boolean;
url: string;
@@ -49,6 +54,7 @@ export interface Settings {
responseStyle: string;
showPricing: boolean;
seenAnnouncementIds: string[];
recentModels: RecentModel[];
}
export type SettingKey = keyof Settings;
@@ -89,6 +95,7 @@ export const defaultSettings: Settings = {
responseStyle: 'concise',
showPricing: true,
seenAnnouncementIds: [],
recentModels: [],
};
export function getKeyboardShortcuts(settings: Settings): KeyboardShortcuts {