feat(chat): switch models from the chat panel

The chat model chip is a dropdown for built-in providers but a static label
for OpenAI-compatible and custom-model setups, so anyone using a custom
endpoint had to open Settings to change models -- even with several models
already configured.

Add a profile switcher that reassigns the design role from the chat panel.
It lists saved profiles that can use tools, shows the profile name rather
than the raw model ID, and badges the vision-capable ones. It writes the
same assignments.design field the Settings dropdown does, so the two stay in
sync.

Only renders when more than one tools-capable profile exists; a single
profile keeps the existing label. Built-in providers keep ProviderModelSelect
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rob Coenen 2026-07-31 11:38:54 -04:00 committed by Danila Poyarkov
parent 9be70db9f9
commit 4bdaeb6d6e
3 changed files with 113 additions and 0 deletions

View file

@ -2,11 +2,13 @@
import { TooltipProvider } from 'reka-ui'
import { computed, ref } from 'vue'
import ChatProfileSelect from '@/components/chat/ChatProfileSelect.vue'
import ProviderModelSelect from '@/components/chat/ProviderModelSelect.vue'
import AppInput from '@/components/ui/AppInput.vue'
import Tip from '@/components/ui/Tip.vue'
import { useButtonUI } from '@/components/ui/button'
import { useAIChat } from '@/app/ai/chat/use'
import { aiModelSettings, designModelProfile } from '@/app/ai/models'
import { openSettingsDialog } from '@/app/settings/dialog'
import { useI18n } from '@open-pencil/vue'
@ -58,6 +60,15 @@ const selectedModelName = computed(() => {
return providerDef.value.models.find((m) => m.id === modelID.value)?.name ?? modelID.value
})
// Switching between saved profiles only makes sense once more than one can drive the design agent.
const switchableProfiles = computed(() =>
aiModelSettings.value.models.filter((profile) => profile.capabilities.includes('tools'))
)
const canSwitchProfile = computed(() => switchableProfiles.value.length > 1)
const selectedProfileName = computed(
() => designModelProfile.value?.name ?? selectedModelName.value
)
function handleSubmit(e: Event) {
e.preventDefault()
const text = input.value.trim()
@ -78,6 +89,9 @@ function handleSubmit(e: Event) {
{{ acpAgentName }}
</div>
</template>
<ChatProfileSelect v-else-if="canSwitchProfile">
<template #value>{{ selectedProfileName }}</template>
</ChatProfileSelect>
<template v-else-if="isCustomProvider || usesCustomModel">
<div
class="flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-muted"

View file

@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
SelectContent,
SelectItem,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectViewport
} from 'reka-ui'
import { useI18n } from '@open-pencil/vue'
import { aiModelSettings, setModelRoleAssignment } from '@/app/ai/models'
import type { AIModelProfileId } from '@/app/ai/models'
import AppBadge from '@/components/ui/AppBadge.vue'
import { useSelectUI } from '@/components/ui/select'
const { dialogs } = useI18n()
// Only profiles that can drive the design agent — setModelRoleAssignment rejects the rest.
const profiles = computed(() =>
aiModelSettings.value.models.filter((profile) => profile.capabilities.includes('tools'))
)
const selectedProfileId = computed({
get: () => aiModelSettings.value.assignments.design,
set: (profileId: AIModelProfileId) => setModelRoleAssignment('design', profileId)
})
const selectCls = useSelectUI({
trigger: 'gap-1 rounded border-none bg-transparent px-1.5 py-0.5 text-[10px] text-muted',
content: 'max-h-60 overflow-y-auto',
item: 'gap-2 rounded px-2 py-1.5 text-[11px]'
})
</script>
<template>
<SelectRoot v-model="selectedProfileId">
<SelectTrigger
data-test-id="chat-profile-selector"
:aria-label="dialogs.models"
:class="selectCls.trigger"
>
<icon-lucide-bot class="size-3" />
<slot name="value" />
<icon-lucide-chevron-down class="size-2.5" />
</SelectTrigger>
<SelectPortal>
<SelectContent position="popper" side="top" :side-offset="4" :class="selectCls.content">
<SelectViewport>
<SelectItem
v-for="profile in profiles"
:key="profile.id"
:value="profile.id"
:class="selectCls.item"
>
<SelectItemText class="flex-1">{{ profile.name }}</SelectItemText>
<AppBadge v-if="profile.capabilities.includes('vision')">
{{ dialogs.modelCapabilityVisionShort }}
</AppBadge>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>

View file

@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
aiModelSettings,
createAIModelRuntime,
createModelProfileDraft,
modelSettingsSnapshot,
@ -99,6 +100,36 @@ describe('AI model profiles and role assignments', () => {
expect(resolveAIModelRole('vision')).toBeNull()
})
test('switches the design agent between saved profiles', () => {
// Backs the chat model switcher: it lists tools-capable profiles and reassigns the design role.
const switchable = aiModelSettings.value.models.filter((profile) =>
profile.capabilities.includes('tools')
)
expect(switchable.map((profile) => profile.id)).toEqual(['model-design', 'model-fast'])
setModelRoleAssignment('design', 'model-fast')
expect(resolveAIModelRole('design')?.profile.id).toBe('model-fast')
expect(resolveAIModelRole('design')?.connection.id).toBe('connection-google')
setModelRoleAssignment('design', 'model-design')
expect(resolveAIModelRole('design')?.profile.id).toBe('model-design')
})
test('refuses to assign a profile that cannot use tools as the design agent', () => {
aiModelSettings.value.models.push({
id: 'model-textonly',
name: 'Text only',
connectionId: 'connection-google',
modelID: 'text-only',
customModelID: '',
maxOutputTokens: 4096,
capabilities: []
})
setModelRoleAssignment('design', 'model-textonly')
expect(resolveAIModelRole('design')?.profile.id).toBe('model-design')
})
test('reuses matching provider connections when adding models', () => {
const draft = createModelProfileDraft()
draft.name = 'Review model'