diff --git a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.test.tsx b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.test.tsx index 7610b3eff..877703609 100644 --- a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.test.tsx +++ b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.test.tsx @@ -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', () => ({ diff --git a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx index 5db18972a..11e88bc24 100644 --- a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx +++ b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx @@ -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(null); + const [recentModels, setRecentModels] = useState([]); + + 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 (
@@ -179,6 +230,23 @@ export default function ModelsBottomBar({

)} + {filteredRecentModels.length > 0 && ( + <> +
+ {intl.formatMessage(i18n.recentModels)} +
+ {filteredRecentModels.map((recent) => ( + void handleRecentModelClick(recent)} + > + + {getModelDisplayName(recent.model)} — {recent.provider} + + ))} + + + )} setIsAddModelModalOpen(true)}> {intl.formatMessage(i18n.changeModel)} diff --git a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx index 326ca8e21..3091b3305 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx @@ -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(); diff --git a/ui/desktop/src/i18n/messages/de.json b/ui/desktop/src/i18n/messages/de.json index 70f34ad93..1394f52d0 100644 --- a/ui/desktop/src/i18n/messages/de.json +++ b/ui/desktop/src/i18n/messages/de.json @@ -4621,5 +4621,8 @@ }, "userMessage.removeImageFromEdit": { "defaultMessage": "Bild aus Nachricht entfernen" + }, + "modelsBottomBar.recentModels": { + "defaultMessage": "Zuletzt verwendet" } } diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index abd77a155..97dccaec4 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -2330,6 +2330,9 @@ "modelsBottomBar.localModelSettingsTitle": { "defaultMessage": "Local Model Settings — {modelName}" }, + "modelsBottomBar.recentModels": { + "defaultMessage": "Recent" + }, "modelsBottomBar.resolvedModel": { "defaultMessage": "Resolved model" }, diff --git a/ui/desktop/src/i18n/messages/es.json b/ui/desktop/src/i18n/messages/es.json index 76640aa20..a36515f56 100644 --- a/ui/desktop/src/i18n/messages/es.json +++ b/ui/desktop/src/i18n/messages/es.json @@ -4621,5 +4621,8 @@ }, "userMessage.removeImageFromEdit": { "defaultMessage": "Eliminar imagen del mensaje" + }, + "modelsBottomBar.recentModels": { + "defaultMessage": "Recientes" } } diff --git a/ui/desktop/src/i18n/messages/fr.json b/ui/desktop/src/i18n/messages/fr.json index 0caa9b180..f9b34b292 100644 --- a/ui/desktop/src/i18n/messages/fr.json +++ b/ui/desktop/src/i18n/messages/fr.json @@ -4621,5 +4621,8 @@ }, "userMessage.removeImageFromEdit": { "defaultMessage": "Supprimer l'image du message" + }, + "modelsBottomBar.recentModels": { + "defaultMessage": "Récents" } } diff --git a/ui/desktop/src/i18n/messages/hi.json b/ui/desktop/src/i18n/messages/hi.json index 4d1bdbca9..53f8843f5 100644 --- a/ui/desktop/src/i18n/messages/hi.json +++ b/ui/desktop/src/i18n/messages/hi.json @@ -4621,5 +4621,8 @@ }, "userMessage.removeImageFromEdit": { "defaultMessage": "संदेश से छवि हटाएँ" + }, + "modelsBottomBar.recentModels": { + "defaultMessage": "हाल के" } } diff --git a/ui/desktop/src/i18n/messages/id.json b/ui/desktop/src/i18n/messages/id.json index d630ddc1c..171c459ba 100644 --- a/ui/desktop/src/i18n/messages/id.json +++ b/ui/desktop/src/i18n/messages/id.json @@ -4621,5 +4621,8 @@ }, "userMessage.removeImageFromEdit": { "defaultMessage": "Hapus gambar dari pesan" + }, + "modelsBottomBar.recentModels": { + "defaultMessage": "Terbaru" } } diff --git a/ui/desktop/src/i18n/messages/it.json b/ui/desktop/src/i18n/messages/it.json index 03b10bd69..dc9ef517f 100644 --- a/ui/desktop/src/i18n/messages/it.json +++ b/ui/desktop/src/i18n/messages/it.json @@ -4621,5 +4621,8 @@ }, "userMessage.removeImageFromEdit": { "defaultMessage": "Rimuovi immagine dal messaggio" + }, + "modelsBottomBar.recentModels": { + "defaultMessage": "Recenti" } } diff --git a/ui/desktop/src/i18n/messages/ja.json b/ui/desktop/src/i18n/messages/ja.json index 0dce047b7..c6ace5135 100644 --- a/ui/desktop/src/i18n/messages/ja.json +++ b/ui/desktop/src/i18n/messages/ja.json @@ -4621,5 +4621,8 @@ }, "userMessage.removeImageFromEdit": { "defaultMessage": "メッセージから画像を削除" + }, + "modelsBottomBar.recentModels": { + "defaultMessage": "最近" } } diff --git a/ui/desktop/src/i18n/messages/ko.json b/ui/desktop/src/i18n/messages/ko.json index 81bfea7f1..8ae95337d 100644 --- a/ui/desktop/src/i18n/messages/ko.json +++ b/ui/desktop/src/i18n/messages/ko.json @@ -4621,5 +4621,8 @@ }, "userMessage.removeImageFromEdit": { "defaultMessage": "메시지에서 이미지 제거" + }, + "modelsBottomBar.recentModels": { + "defaultMessage": "최근" } } diff --git a/ui/desktop/src/i18n/messages/ms.json b/ui/desktop/src/i18n/messages/ms.json index 05662dd8a..5f5034466 100644 --- a/ui/desktop/src/i18n/messages/ms.json +++ b/ui/desktop/src/i18n/messages/ms.json @@ -4621,5 +4621,8 @@ }, "userMessage.removeImageFromEdit": { "defaultMessage": "Alih keluar imej daripada mesej" + }, + "modelsBottomBar.recentModels": { + "defaultMessage": "Terkini" } } diff --git a/ui/desktop/src/i18n/messages/pt.json b/ui/desktop/src/i18n/messages/pt.json index ec21b49d1..99080d278 100644 --- a/ui/desktop/src/i18n/messages/pt.json +++ b/ui/desktop/src/i18n/messages/pt.json @@ -4621,5 +4621,8 @@ }, "userMessage.removeImageFromEdit": { "defaultMessage": "Remover imagem da mensagem" + }, + "modelsBottomBar.recentModels": { + "defaultMessage": "Recentes" } } diff --git a/ui/desktop/src/i18n/messages/ru.json b/ui/desktop/src/i18n/messages/ru.json index 025f95485..7314709eb 100644 --- a/ui/desktop/src/i18n/messages/ru.json +++ b/ui/desktop/src/i18n/messages/ru.json @@ -4621,5 +4621,8 @@ }, "userMessage.removeImageFromEdit": { "defaultMessage": "Удалить изображение из сообщения" + }, + "modelsBottomBar.recentModels": { + "defaultMessage": "Недавние" } } diff --git a/ui/desktop/src/i18n/messages/tr.json b/ui/desktop/src/i18n/messages/tr.json index 8f56cb5d3..2a8ea5731 100644 --- a/ui/desktop/src/i18n/messages/tr.json +++ b/ui/desktop/src/i18n/messages/tr.json @@ -4621,5 +4621,8 @@ }, "userMessage.removeImageFromEdit": { "defaultMessage": "Görseli mesajdan kaldır" + }, + "modelsBottomBar.recentModels": { + "defaultMessage": "Son kullanılan" } } diff --git a/ui/desktop/src/i18n/messages/vi.json b/ui/desktop/src/i18n/messages/vi.json index 762abaafb..ccdf799d2 100644 --- a/ui/desktop/src/i18n/messages/vi.json +++ b/ui/desktop/src/i18n/messages/vi.json @@ -4621,5 +4621,8 @@ }, "userMessage.removeImageFromEdit": { "defaultMessage": "Xóa hình ảnh khỏi tin nhắn" + }, + "modelsBottomBar.recentModels": { + "defaultMessage": "Gần đây" } } diff --git a/ui/desktop/src/i18n/messages/zh-CN.json b/ui/desktop/src/i18n/messages/zh-CN.json index 5b46cbb47..a21fd2a47 100644 --- a/ui/desktop/src/i18n/messages/zh-CN.json +++ b/ui/desktop/src/i18n/messages/zh-CN.json @@ -4621,5 +4621,8 @@ }, "userMessage.removeImageFromEdit": { "defaultMessage": "从消息中移除图片" + }, + "modelsBottomBar.recentModels": { + "defaultMessage": "最近" } } diff --git a/ui/desktop/src/i18n/messages/zh-TW.json b/ui/desktop/src/i18n/messages/zh-TW.json index a85aa01c3..edd14fc6e 100644 --- a/ui/desktop/src/i18n/messages/zh-TW.json +++ b/ui/desktop/src/i18n/messages/zh-TW.json @@ -4621,5 +4621,8 @@ }, "userMessage.removeImageFromEdit": { "defaultMessage": "從訊息中移除圖片" + }, + "modelsBottomBar.recentModels": { + "defaultMessage": "最近" } } diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 294c950c8..9904bbf83 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1962,6 +1962,7 @@ const validSettingKeys: Set = new Set([ 'showPricing', 'seenAnnouncementIds', 'disableAutoDownload', + 'recentModels', ]); ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => { diff --git a/ui/desktop/src/utils/recentModels.ts b/ui/desktop/src/utils/recentModels.ts new file mode 100644 index 000000000..c124bb599 --- /dev/null +++ b/ui/desktop/src/utils/recentModels.ts @@ -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); +} diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts index 2c703a8d2..ec69d46b0 100644 --- a/ui/desktop/src/utils/settings.ts +++ b/ui/desktop/src/utils/settings.ts @@ -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 {