Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9836ad0
Use ComposerDropdown for provider selector
friuns May 9, 2026
1e98502
Document app interaction component rules
friuns May 9, 2026
5d623d2
Use ComposerDropdown for remaining app selects
friuns May 9, 2026
3f7782e
Configure app-server with selected web port
friuns May 9, 2026
e81b072
Prefer configured model after provider switch
friuns May 9, 2026
b5e5f82
Reset new thread model on provider switch
friuns May 9, 2026
2493c2e
Scope model selections by provider
friuns May 9, 2026
5f109da
Fix Qodo review findings
friuns May 9, 2026
8be5068
Load provider models during background refresh
friuns May 9, 2026
67721b5
Preserve thread provider switching
friuns May 9, 2026
cdf4a08
Keep Zen model when selecting threads
friuns May 9, 2026
94db5ff
Fix provider model dropdown switching
friuns May 9, 2026
e33c850
Preview provider model during switch
friuns May 9, 2026
f939bcb
Limit Zen dropdown to free models
friuns May 9, 2026
5b14251
Add provider switching test checklist
friuns May 9, 2026
7633fbb
Refresh provider state on startup and switch
friuns May 9, 2026
1ea6ef9
Allow custom gpt-prefixed provider models
friuns May 9, 2026
cdeb442
Remove legacy model selection fallbacks
friuns May 9, 2026
31395bf
Keep provider model when switching chats
friuns May 9, 2026
54671ab
Normalize Codex provider model context
friuns May 9, 2026
7e0475a
Show provider model in composer
friuns May 9, 2026
77d3cc8
Respect valid resumed thread models
friuns May 9, 2026
0c8be16
Fix Qodo provider review issues
friuns May 9, 2026
dc7871a
Send visible provider model from composer
friuns May 9, 2026
28f7637
Hydrate provider model from free-mode status
friuns May 9, 2026
6ecfed9
Preserve thread model during provider status sync
friuns May 9, 2026
6491cb3
Update wiki for provider model verification
friuns May 10, 2026
5cbe96c
Keep Zen reasoning replay Responses-safe
friuns May 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@
- For shared route surfaces and large feature UIs, prefer putting the decisive dark-theme overrides in the global theme stylesheet (`src/style.css`) instead of relying only on component-scoped `:global(:root.dark)` blocks.
- Scoped dark overrides are fine for truly local elements, but if a full route still looks like light theme in dark mode, add or strengthen the global selectors for that surface.

## UI Interaction Component Rule

- Never add a normal HTML `<select>` for app UI. Use `ComposerDropdown` by default, or another existing app dropdown component when it is already the established pattern for that surface.
- Never use browser-native `prompt()` for app UI. Use an existing modal, inline editor, drawer, or app-styled form flow instead.
- When replacing legacy `<select>` or `prompt()` usage, preserve the existing behavior and state wiring while moving the interaction into the app component system.

## NPX Testing Rule

- For any `npx` package behavior test, **publish first**, then test the published `@latest` package.
Expand Down
203 changes: 159 additions & 44 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -207,13 +207,13 @@
</button>
<div class="sidebar-settings-row sidebar-settings-row--select" :title="t('Choose the interface language for the app.')">
<span class="sidebar-settings-label">{{ t('UI language') }}</span>
<select
class="sidebar-settings-provider-select"
:value="uiLanguage"
@change="setUiLanguage(($event.target as HTMLSelectElement).value as 'en' | 'zh-CN')"
>
<option v-for="option in uiLanguageOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
<ComposerDropdown
class="sidebar-settings-language-dropdown"
:model-value="uiLanguage"
:options="uiLanguageOptions"
open-direction="up"
@update:model-value="setUiLanguage($event as 'en' | 'zh-CN')"
/>
</div>
<button class="sidebar-settings-row" type="button" :title="SETTINGS_HELP.chatWidth" @click="cycleChatWidth">
<span class="sidebar-settings-label">{{ t('Chat width') }}</span>
Expand All @@ -230,17 +230,15 @@

<div class="sidebar-settings-row sidebar-settings-row--select" :title="t('Choose the API provider for the Codex backend')">
<span class="sidebar-settings-label">{{ t('Provider') }}</span>
<select
class="sidebar-settings-provider-select"
:value="selectedProvider"
<ComposerDropdown
class="sidebar-settings-provider-dropdown"
:model-value="selectedProvider"
:options="providerOptions"
:disabled="freeModeLoading"
@change="onProviderChange(($event.target as HTMLSelectElement).value)"
>
<option value="codex">Codex</option>
<option value="openrouter">OpenRouter</option>
<option value="opencode-zen">OpenCode Zen</option>
<option value="custom">Custom endpoint</option>
</select>
:placeholder="t('Provider')"
open-direction="up"
@update:model-value="onProviderChange"
/>
</div>
<div v-if="providerError" class="sidebar-settings-row sidebar-settings-error">
{{ providerError }}
Expand Down Expand Up @@ -779,6 +777,7 @@
:selected-reasoning-effort="selectedReasoningEffort"
:selected-speed-mode="selectedSpeedMode"
:is-updating-speed-mode="isUpdatingSpeedMode"
:disabled="freeModeLoading"
:skills="installedSkills"
:thread-token-usage="selectedThreadTokenUsage"
:codex-quota="codexQuota"
Expand Down Expand Up @@ -853,6 +852,7 @@
:selected-reasoning-effort="selectedReasoningEffort"
:selected-speed-mode="selectedSpeedMode"
:is-updating-speed-mode="isUpdatingSpeedMode"
:disabled="freeModeLoading"
:skills="installedSkills"
:thread-token-usage="selectedThreadTokenUsage"
:codex-quota="codexQuota"
Expand Down Expand Up @@ -1221,6 +1221,7 @@ const {
steerQueuedMessage,
setSelectedCollaborationMode,
readModelIdForThread,
previewProviderModelSelection,
setSelectedModelIdForThread,

setSelectedReasoningEffort,
Expand Down Expand Up @@ -1253,8 +1254,10 @@ const terminalStoredQuickCommands = ref<TerminalHeaderQuickCommand[]>(loadTermin
const terminalHeaderDropdownValue = ref('')
const editingQueuedMessageState = ref<{ threadId: string; queueIndex: number } | null>(null)
const isRouteSyncInProgress = ref(false)
const providerSwitchPreservedThreadId = ref('')
const directoryTryInFlightKey = ref('')
let hasPendingRouteSync = false
let providerSwitchRestoreTimer: number | null = null
const hasInitialized = ref(false)
const newThreadCwd = ref('')
const newThreadRuntime = ref<'local' | 'worktree'>('local')
Expand Down Expand Up @@ -1343,6 +1346,12 @@ const freeModeCustomKeyMasked = ref<string | null>(null)
const freeModeCustomKeySaving = ref(false)
const providerError = ref('')
const selectedProvider = ref<'codex' | 'openrouter' | 'opencode-zen' | 'custom'>('codex')
const providerOptions = computed(() => [
{ value: 'codex', label: 'Codex' },
{ value: 'openrouter', label: 'OpenRouter' },
{ value: 'opencode-zen', label: 'OpenCode Zen' },
{ value: 'custom', label: t('Custom endpoint') },
])
const customEndpointUrl = ref('')
const customEndpointKey = ref('')
const customEndpointWireApi = ref<'responses' | 'chat'>('responses')
Expand Down Expand Up @@ -1780,7 +1789,7 @@ onMounted(() => {
void refreshDefaultProjectName()
void refreshTelegramConfig()
void refreshTelegramStatus()
void loadFreeModeStatus()
void loadInitialFreeModeStatus()
void refreshThreadTerminalStatus()
void refreshTerminalQuickCommands()
})
Expand Down Expand Up @@ -3573,46 +3582,124 @@ function toggleDictationAutoSend(): void {
window.localStorage.setItem(DICTATION_AUTO_SEND_KEY, dictationAutoSend.value ? '1' : '0')
}

async function restoreThreadRouteAfterProviderChange(threadId: string): Promise<void> {
const normalizedThreadId = threadId.trim()
if (!normalizedThreadId) return
primeSelectedThread(normalizedThreadId)
if (route.name !== 'thread' || routeThreadId.value !== normalizedThreadId) {
await router.replace({ name: 'thread', params: { threadId: normalizedThreadId } })
}
}

function finishProviderSwitchRoutePreservation(threadId: string): void {
const normalizedThreadId = threadId.trim()
if (!normalizedThreadId) return
if (providerSwitchRestoreTimer !== null) {
window.clearTimeout(providerSwitchRestoreTimer)
}
providerSwitchRestoreTimer = window.setTimeout(() => {
void restoreThreadRouteAfterProviderChange(normalizedThreadId).finally(() => {
if (providerSwitchPreservedThreadId.value === normalizedThreadId) {
providerSwitchPreservedThreadId.value = ''
}
providerSwitchRestoreTimer = null
})
}, 250)
}

function withProviderSwitchTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeout = window.setTimeout(() => {
reject(new Error(`${label} timed out`))
}, 15_000)
promise.then(
(value) => {
window.clearTimeout(timeout)
resolve(value)
},
(error) => {
window.clearTimeout(timeout)
reject(error)
},
)
})
}

async function onProviderChange(provider: string): Promise<void> {
if (freeModeLoading.value) return
freeModeLoading.value = true
const activeThreadIdBeforeProviderChange = (
route.name === 'thread' ? routeThreadId.value.trim() : selectedThreadId.value.trim()
)
if (activeThreadIdBeforeProviderChange) {
providerSwitchPreservedThreadId.value = activeThreadIdBeforeProviderChange
}
try {
if (provider === 'codex') {
selectedProvider.value = 'codex'
const result = await setFreeMode(false)
previewProviderModelSelection(provider)
const result = await withProviderSwitchTimeout(setFreeMode(false), 'Codex provider switch')
freeModeEnabled.value = result.enabled
} else if (provider === 'openrouter') {
selectedProvider.value = 'openrouter'
const result = await setFreeMode(true)
previewProviderModelSelection(provider)
const result = await withProviderSwitchTimeout(setFreeMode(true), 'OpenRouter provider switch')
freeModeEnabled.value = result.enabled
await setCustomProvider('', '', {
wireApi: openRouterWireApi.value,
provider: 'openrouter',
})
await withProviderSwitchTimeout(
setCustomProvider('', '', {
wireApi: openRouterWireApi.value,
provider: 'openrouter',
}),
'OpenRouter provider configuration',
)
} else if (provider === 'opencode-zen') {
selectedProvider.value = 'opencode-zen'
await setCustomProvider('', opencodeZenKey.value.trim(), {
wireApi: 'chat',
provider: 'opencode-zen',
})
previewProviderModelSelection(provider)
await withProviderSwitchTimeout(
setCustomProvider('', opencodeZenKey.value.trim(), {
wireApi: 'chat',
provider: 'opencode-zen',
}),
'OpenCode Zen provider configuration',
)
freeModeEnabled.value = true
} else if (provider === 'custom') {
selectedProvider.value = 'custom'
previewProviderModelSelection(provider)
if (customEndpointUrl.value.trim() && customEndpointKey.value.trim()) {
await setCustomProvider(customEndpointUrl.value.trim(), customEndpointKey.value.trim(), {
wireApi: customEndpointWireApi.value,
})
await withProviderSwitchTimeout(
setCustomProvider(customEndpointUrl.value.trim(), customEndpointKey.value.trim(), {
wireApi: customEndpointWireApi.value,
}),
'Custom provider configuration',
)
freeModeEnabled.value = true
}
}
providerError.value = ''
await refreshAll({ includeSelectedThreadMessages: false, providerChanged: true, awaitAncillaryRefreshes: true })
if (route.name === 'thread') {
void router.push({ name: 'home' })
await withProviderSwitchTimeout(
refreshAll({ includeSelectedThreadMessages: false, providerChanged: true, awaitAncillaryRefreshes: true }),
'Provider refresh',
)
if (activeThreadIdBeforeProviderChange) {
const providerModelId = readModelIdForThread('__new-thread__').trim() || selectedModelId.value.trim()
if (providerModelId) {
setSelectedModelIdForThread(activeThreadIdBeforeProviderChange, providerModelId)
}
Comment on lines +3696 to +3701
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Thread model overwritten on switch 🐞 Bug ≡ Correctness

onProviderChange() always writes the active thread’s model to the provider’s new-thread model
after a provider refresh, which can overwrite an existing per-thread/per-provider selection and
prevent restoring that thread’s prior model when switching back.
Agent Prompt
### Issue description
Provider switching currently persists the provider’s *new-thread* model into the *active existing thread* after refresh, which can overwrite a previously saved per-thread/per-provider model choice.

### Issue Context
- Existing thread model selections are stored provider-scoped in `useDesktopState`.
- The provider’s new-thread model (`__new-thread__`) is a different context than an existing thread’s provider-scoped model.

### Fix Focus Areas
- src/App.vue[3695-3700]

### Suggested change
After the provider refresh completes, only call `setSelectedModelIdForThread(activeThreadIdBeforeProviderChange, providerModelId)` if the active thread does **not** already have a provider-scoped model for the newly active provider (i.e., `readModelIdForThread(activeThreadIdBeforeProviderChange).trim()` is empty). This preserves existing per-thread/provider selections while still initializing the thread model when missing.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

await restoreThreadRouteAfterProviderChange(activeThreadIdBeforeProviderChange)
await withProviderSwitchTimeout(
ensureThreadMessagesLoaded(activeThreadIdBeforeProviderChange, { silent: true }).catch(() => {}),
'Provider thread refresh',
)
await nextTick()
await restoreThreadRouteAfterProviderChange(activeThreadIdBeforeProviderChange)
finishProviderSwitchRoutePreservation(activeThreadIdBeforeProviderChange)
}
} catch (err) {
providerError.value = err instanceof Error ? err.message : 'Failed to switch provider'
if (activeThreadIdBeforeProviderChange && providerSwitchPreservedThreadId.value === activeThreadIdBeforeProviderChange) {
providerSwitchPreservedThreadId.value = ''
}
} finally {
freeModeLoading.value = false
}
Expand Down Expand Up @@ -3735,6 +3822,16 @@ async function loadFreeModeStatus(): Promise<void> {
}
}

async function loadInitialFreeModeStatus(): Promise<void> {
await loadFreeModeStatus()
if (selectedProvider.value === 'codex') return
await refreshAll({
includeSelectedThreadMessages: false,
providerChanged: true,
awaitAncillaryRefreshes: true,
})
}

function onDictationLanguageChange(nextValue: string): void {
const normalized = normalizeToWhisperLanguage(nextValue.trim())
const value = normalized || 'auto'
Expand Down Expand Up @@ -3881,6 +3978,11 @@ async function syncThreadSelectionWithRoute(): Promise<void> {
hasPendingRouteSync = false

if (route.name === 'home' || route.name === 'skills') {
const preservedThreadId = providerSwitchPreservedThreadId.value.trim()
if (route.name === 'home' && preservedThreadId) {
await restoreThreadRouteAfterProviderChange(preservedThreadId)
continue
}
if (selectedThreadId.value !== '') {
await selectThread('')
}
Expand All @@ -3893,11 +3995,7 @@ async function syncThreadSelectionWithRoute(): Promise<void> {

if (selectedThreadId.value !== threadId) {
if (!threadExistsInSidebar(threadId)) {
if (selectedThreadId.value) {
await router.replace({ name: 'thread', params: { threadId: selectedThreadId.value } })
} else {
await router.replace({ name: 'home' })
}
await selectThread(threadId)
continue
}
await selectThread(threadId)
Expand Down Expand Up @@ -3950,6 +4048,11 @@ watch(
if (isHomeRoute.value || isSkillsRoute.value) return

if (!threadId) {
const preservedThreadId = providerSwitchPreservedThreadId.value.trim()
if (preservedThreadId) {
await restoreThreadRouteAfterProviderChange(preservedThreadId)
return
}
if (route.name !== 'home') {
await router.replace({ name: 'home' })
}
Expand Down Expand Up @@ -5090,11 +5193,23 @@ async function loadWorktreeBranches(sourceCwd: string): Promise<void> {
@apply shrink-0 w-6 h-6 flex items-center justify-center rounded-full border border-zinc-200 text-xs text-zinc-400 transition-colors hover:text-zinc-600 hover:border-zinc-300 disabled:opacity-40;
}

.sidebar-settings-provider-select {
@apply min-w-0 max-w-40 rounded-md border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 outline-none transition-colors cursor-pointer;
.sidebar-settings-provider-dropdown {
@apply min-w-0 max-w-40;
}

.sidebar-settings-provider-dropdown :deep(.composer-dropdown-trigger) {
@apply h-auto rounded-md border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700;
}

.sidebar-settings-provider-dropdown :deep(.composer-dropdown-value) {
@apply max-w-32;
}

.sidebar-settings-provider-dropdown :deep(.composer-dropdown-menu-wrap) {
@apply left-auto right-0;
}

.sidebar-settings-provider-select:focus {
.sidebar-settings-provider-dropdown :deep(.composer-dropdown-trigger:focus-visible) {
@apply border-zinc-400 ring-2 ring-zinc-200;
}

Expand All @@ -5118,11 +5233,11 @@ async function loadWorktreeBranches(sourceCwd: string): Promise<void> {
@apply text-xs text-blue-600 hover:text-blue-700 underline shrink-0;
}

:root.dark .sidebar-settings-provider-select {
:root.dark .sidebar-settings-provider-dropdown :deep(.composer-dropdown-trigger) {
@apply border-zinc-600 bg-zinc-800 text-zinc-200;
}

:root.dark .sidebar-settings-provider-select:focus {
:root.dark .sidebar-settings-provider-dropdown :deep(.composer-dropdown-trigger:focus-visible) {
@apply border-zinc-500 ring-zinc-700;
}

Expand Down
Loading