Skip to content

Use app dropdowns and fix Android Zen app-server config#152

Open
friuns2 wants to merge 27 commits intomainfrom
codex/zen-reasoning-proxy-fix
Open

Use app dropdowns and fix Android Zen app-server config#152
friuns2 wants to merge 27 commits intomainfrom
codex/zen-reasoning-proxy-fix

Conversation

@friuns2
Copy link
Copy Markdown
Owner

@friuns2 friuns2 commented May 9, 2026

Summary

  • replace remaining app UI select controls with ComposerDropdown, including UI language and review/automation/pending-request selectors
  • document the no raw select / no prompt UI rule in AGENTS.md
  • set CODEXUI_SERVER_PORT after binding so Android Zen app-server uses the local Responses proxy instead of unsupported direct chat wire API

Tests

  • pnpm run build
  • published codexui-android@0.1.105
  • Android 18923: model/list and config/read return HTTP 200
  • Android 18923: provider-models returns Zen models including big-pickle
  • Android 18923: Zen proxy hi request with big-pickle returns HTTP 200

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented May 9, 2026

Review Summary by Qodo

(Agentic_describe updated until commit dc7871a)

Scope model selections by provider and replace app selects with ComposerDropdown

✨ Enhancement 🐞 Bug fix

Grey Divider

Walkthroughs

Description
• Scope model selections per thread and active provider to prevent stale model leakage
• Replace remaining app select controls with ComposerDropdown for consistent UI
• Load provider models during normal refresh and startup, not only after provider switch
• Fix app-server bridge startup ordering to use resolved web port for Zen proxy config
• Preview provider-scoped model selection before refresh completes
Diagram
flowchart LR
  A["Model Selection Storage"] -->|"per thread + provider"| B["Thread Provider Context"]
  C["Provider Switch"] -->|"preview model"| D["Composer Model Display"]
  C -->|"refresh with flag"| E["Load Provider Models"]
  E -->|"before resume"| F["Validate Thread Model"]
  G["App Selects"] -->|"replace with"| H["ComposerDropdown"]
  I["Bridge Startup"] -->|"defer until"| J["Port Resolved"]
  J -->|"then start"| K["Background Services"]
Loading

Grey Divider

File Changes

1. src/composables/useDesktopState.ts ✨ Enhancement +134/-60

Implement provider-scoped model selection and preview logic

src/composables/useDesktopState.ts


2. src/composables/useDesktopState.test.ts 🧪 Tests +628/-0

Add comprehensive model selection and provider switching tests

src/composables/useDesktopState.test.ts


3. src/server/codexAppServerBridge.ts 🐞 Bug fix +45/-17

Defer bridge background services and fix Zen model filtering

src/server/codexAppServerBridge.ts


View more (15)
4. src/server/freeMode.ts ✨ Enhancement +15/-0

Add OpenCode Zen free model filtering and default model constant

src/server/freeMode.ts


5. src/server/freeMode.test.ts 🧪 Tests +27/-0

Test OpenCode Zen free model list filtering logic

src/server/freeMode.test.ts


6. src/server/httpServer.ts ✨ Enhancement +6/-1

Add deferred bridge background services startup option

src/server/httpServer.ts


7. src/api/codexGateway.ts 🐞 Bug fix +11/-2

Prioritize enabled free-mode config over stale Codex config

src/api/codexGateway.ts


8. src/api/codexGateway.test.ts 🧪 Tests +47/-1

Test free-mode status priority in model config retrieval

src/api/codexGateway.test.ts


9. src/cli/index.ts 🐞 Bug fix +6/-1

Record web port before starting bridge background services

src/cli/index.ts


10. src/App.vue ✨ Enhancement +173/-47

Replace provider select with ComposerDropdown and preserve thread on provider switch

src/App.vue


11. src/components/content/ThreadPendingRequestPanel.vue ✨ Enhancement +50/-37

Replace pending request selects with ComposerDropdown

src/components/content/ThreadPendingRequestPanel.vue


12. src/components/content/ThreadComposer.vue ✨ Enhancement +2/-0

Add selectedModel to submit payload for provider-scoped sending

src/components/content/ThreadComposer.vue


13. src/components/sidebar/SidebarThreadTree.vue ✨ Enhancement +41/-16

Replace automation schedule and status selects with ComposerDropdown

src/components/sidebar/SidebarThreadTree.vue


14. src/components/content/ReviewPane.vue ✨ Enhancement +19/-22

Replace review branch select with ComposerDropdown

src/components/content/ReviewPane.vue


15. src/style.css ✨ Enhancement +8/-0

Add dark theme overrides for automation and review dropdowns

src/style.css


16. tests.md 📝 Documentation +269/-9

Document provider switching, model scoping, and dropdown replacement tests

tests.md


17. whatToTest.md 📝 Documentation +58/-0

Add provider and model switching smoke test checklist

whatToTest.md


18. AGENTS.md 📝 Documentation +6/-0

Document UI interaction component rules for selects and prompts

AGENTS.md


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented May 9, 2026

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (1) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Thread model overwritten on switch 🐞 Bug ≡ Correctness
Description
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.
Code

src/App.vue[R3695-3699]

+    if (activeThreadIdBeforeProviderChange) {
+      const providerModelId = readModelIdForThread('__new-thread__').trim() || selectedModelId.value.trim()
+      if (providerModelId) {
+        setSelectedModelIdForThread(activeThreadIdBeforeProviderChange, providerModelId)
+      }
Evidence
After switching providers, App.vue derives providerModelId from the new-thread context
(readModelIdForThread('__new-thread__')) and unconditionally persists it onto the previously
active thread via `setSelectedModelIdForThread(activeThreadIdBeforeProviderChange,
providerModelId). In useDesktopState`, existing-thread model preferences are stored under
provider-scoped thread keys (__thread-provider__::::), while __new-thread__ reads the provider
default (__new-thread-provider__::). Writing the new-thread provider model into the thread key can
clobber an existing per-thread/provider choice and contradicts the documented expectation that
switching back restores the prior thread model.

src/App.vue[3696-3699]
src/composables/useDesktopState.ts[1561-1571]
src/composables/useDesktopState.ts[1618-1633]
tests.md[370-401]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## 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


2. review-pane-branch-select missing dark override ✓ Resolved 📘 Rule violation ≡ Correctness
Description
ReviewPane.vue hard-codes a light dropdown trigger style (bg-white, light border/text) for the
base-branch picker but adds no corresponding dark-theme override in src/style.css, so the control
can render like light theme in dark mode. This violates the requirement to put decisive dark-theme
overrides for shared/large surfaces in the global stylesheet.
Code

src/components/content/ReviewPane.vue[R1137-1139]

+.review-pane-branch-select :deep(.composer-dropdown-trigger) {
+  @apply h-auto rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-[11px] font-medium text-zinc-700 shadow-sm;
+}
Evidence
The checklist requires decisive dark-theme overrides for shared route/feature UIs to live in
src/style.css. The PR introduces a light-only trigger style for the review branch dropdown without
any matching global dark override, while the global stylesheet only applies minimal generic dark
styling to .composer-dropdown-trigger (and adds surface-specific overrides only for automation
dropdowns).

AGENTS.md
src/components/content/ReviewPane.vue[1133-1143]
src/style.css[290-297]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The Review base-branch `ComposerDropdown` trigger is styled as a light surface (`bg-white`, light border/text) inside `ReviewPane.vue`, but there is no corresponding global dark-theme override in `src/style.css`. This can cause the review surface dropdown to look like light theme in dark mode and violates the preference for centralized dark overrides for shared/large UIs.
## Issue Context
`src/style.css` already contains global dark-theme rules for `.composer-dropdown-*` and newly adds automation-specific trigger overrides, but the review branch dropdown’s decisive trigger styling remains component-local and light-only.
## Fix Focus Areas
- src/components/content/ReviewPane.vue[1133-1143]
- src/style.css[290-305]

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


3. Resumed model dropped startup ✓ Resolved 🐞 Bug ≡ Correctness
Description
On initial /thread/:id startup, refreshAll() loads messages (and resumes the thread) before
refreshModelPreferences() populates availableModelIds, so
setResumedThreadModelIdForActiveProvider() refuses to persist the resumed model and future sends
use the provider default model instead of the resumed thread model.
Code

src/composables/useDesktopState.ts[R1679-1682]

+  function setResumedThreadModelIdForActiveProvider(threadId: string, modelId: string): void {
+    const normalizedModelId = modelId.trim()
+    if (!normalizedModelId || !availableModelIds.value.includes(normalizedModelId)) return
+    setThreadModelId(threadId, normalizedModelId)
Evidence
availableModelIds starts empty, but setResumedThreadModelIdForActiveProvider() requires the
resumed model to already be present in availableModelIds to persist it. During initial
thread-route boot, App.vue calls refreshAll({ includeSelectedThreadMessages: true }), and
refreshAll() calls loadMessages() (which resumes the thread and attempts to persist the resumed
model) before the ancillary refresh that populates availableModelIds. Because loadMessages()
also marks resumedThreadById[threadId]=true, startTurnForThread() will not re-resume later and
will compute the model via readModelIdForActiveThreadSelection(), resulting in sending with the
provider default rather than the resumed thread’s model.

src/composables/useDesktopState.ts[1389-1404]
src/composables/useDesktopState.ts[1679-1683]
src/App.vue[3949-3958]
src/composables/useDesktopState.ts[4379-4402]
src/composables/useDesktopState.ts[4216-4264]
src/composables/useDesktopState.ts[4831-4845]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
During initial `/thread/:id` startup, `loadMessages()` can resume a thread before `availableModelIds` has been populated, causing `setResumedThreadModelIdForActiveProvider()` to drop the resumed model and later sends to use a provider default model.
## Issue Context
- App startup on a thread route calls `refreshAll({ includeSelectedThreadMessages: true })`.
- `refreshAll()` currently calls `loadMessages()` before `refreshAncillaryState()`.
- `loadMessages()` marks the thread as resumed even if it did not persist the resumed model.
## Fix focus areas
- Make resumed model persistence robust when `availableModelIds` is still empty (e.g., allow saving when the list is empty, or defer validation until after model lists load).
- Alternatively (or additionally), adjust the startup refresh ordering so model preferences/model lists are populated before resuming/loading messages for the selected thread.
### Suggested implementation direction
- Option A (localized): Change `setResumedThreadModelIdForActiveProvider` to persist when `availableModelIds.value.length === 0` OR when it contains the model; then later, once model lists load, prune/ignore invalid persisted values if needed.
- Option B (ordering): In `refreshAll()`, call `refreshAncillaryState()` (or at least `refreshModelPreferences()`) before `loadMessages()` when `includeSelectedThreadMessages` is true.
## Fix Focus Areas
- src/composables/useDesktopState.ts[1679-1683]
- src/composables/useDesktopState.ts[4216-4264]
- src/composables/useDesktopState.ts[4379-4402]
- src/App.vue[3949-3958]

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


View more (3)
4. Custom model selection broken ✓ Resolved 🐞 Bug ≡ Correctness
Description
For non-Codex providers, readModelIdForThread() ignores any per-thread provider model that starts
with "gpt-", so selecting a custom endpoint model like "gpt-4o" cannot stick and will immediately
fall back to the provider default/empty model.
Code

src/composables/useDesktopState.ts[R1593-1599]

+      if (!isCodexProviderContextId(activeProviderId.value)) {
+        const providerModelId = providerContextId
+          ? normalizeStoredModelId(selectedModelIdByContext.value[providerContextId])
+          : ''
+        if (providerThreadModelId && !isCodexBuiltinModelId(providerThreadModelId)) return providerThreadModelId
+        return providerModelId
+      }
Evidence
useDesktopState drops provider-thread model IDs with a "gpt-" prefix for any non-Codex provider, but
the server’s /codex-api/provider-models custom endpoint path returns whatever the upstream /models
returns (no filtering), and codexGateway can return that provider list exclusively—so "gpt-*" is a
plausible/likely custom model ID that the UI would offer but the state layer will refuse to
persist/read back.

src/composables/useDesktopState.ts[156-160]
src/composables/useDesktopState.ts[1580-1603]
src/composables/useDesktopState.ts[1639-1673]
src/server/codexAppServerBridge.ts[6114-6169]
src/api/codexGateway.ts[1758-1797]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`readModelIdForThread()` treats any `gpt-*` model ID as “Codex builtin” and ignores it for non-Codex providers. This breaks custom endpoints that legitimately return `gpt-*` IDs from `/models`.
## Issue Context
- `/codex-api/provider-models` for `custom` returns the upstream `/models` list without filtering.
- `getAvailableModelIds()` can return that provider list as `exclusive`, meaning the UI will legitimately offer `gpt-*` IDs.
## Fix Focus Areas
- Adjust the non-Codex branch in `readModelIdForThread()` to accept the provider-thread scoped model ID regardless of prefix (or validate against the provider model list rather than using a prefix heuristic).
- If you still need to protect against stale Codex IDs, scope that protection to legacy keys (non-provider-scoped) or to “model not present in current provider model list” after refresh.
- src/composables/useDesktopState.ts[156-160]
- src/composables/useDesktopState.ts[1580-1603]
- src/composables/useDesktopState.ts[1639-1673]
- src/server/codexAppServerBridge.ts[6114-6169]
- src/api/codexGateway.ts[1758-1797]

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


5. Automation dropdowns missing dark styles ✓ Resolved 📘 Rule violation ≡ Correctness
Description
The new automation dialog dropdown trigger styles hard-code light theme colors (bg-white, light
borders/text) without any dark-theme override, which can make automation controls look like light UI
in dark mode. This violates the requirement to ensure shared/large UI surfaces have decisive
dark-theme overrides in src/style.css.
Code

src/components/sidebar/SidebarThreadTree.vue[R3032-3038]

+.automation-thread-select :deep(.composer-dropdown-trigger) {
+  @apply rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-500;
+}
+
.automation-schedule-mode-group {
@apply grid grid-cols-3 gap-1 rounded-lg border border-zinc-200 bg-zinc-100 p-1;
}
Evidence
PR Compliance ID 2 requires dark-theme overrides for shared/large surfaces to be applied via the
global theme stylesheet. The PR adds :deep(.composer-dropdown-trigger) rules for automation
dropdowns with bg-white and other light theme tokens and provides no corresponding global dark
selector for those automation surfaces; global dropdown trigger dark styling only changes text
color, not backgrounds set by these component overrides.

AGENTS.md
src/components/sidebar/SidebarThreadTree.vue[3032-3063]
src/style.css[290-297]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`SidebarThreadTree.vue` styles `ComposerDropdown` triggers for automation schedule/status with light-only tokens (`bg-white`, light borders/text) and does not add any dark-theme override, so these controls can appear as light UI in dark mode.
## Issue Context
Compliance expects decisive dark-theme styling for shared/large surfaces to live in `src/style.css` for consistency.
## Fix Focus Areas
- src/components/sidebar/SidebarThreadTree.vue[3032-3063]
- src/style.css[286-306]

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


6. Server port env race ✓ Resolved 🐞 Bug ☼ Reliability
Description
In startServer(), CODEXUI_SERVER_PORT is assigned only after awaiting listenWithFallback(), but
createApp() constructs the Codex bridge which immediately starts background work (skills sync) that
can call appServer.rpc and spawn the Codex app-server before the env var is set. If the app-server
starts in that window, free-mode config for OpenCode Zen/custom will be generated without serverPort
(falling back to OPENCODE_ZEN_BASE_URL and wire_api="chat") and won’t self-correct until the
app-server restarts.
Code

src/cli/index.ts[540]

+  process.env.CODEXUI_SERVER_PORT = String(port)
Evidence
The CLI creates the HTTP app/bridge before setting CODEXUI_SERVER_PORT, and it only sets the env var
after an awaited bind. Meanwhile, bridge creation eagerly kicks off
initializeSkillsSyncOnStartup(appServer) (fire-and-forget), which on Android bootstrap flows into
appServer.rpc('skills/list'). AppServerProcess reads CODEXUI_SERVER_PORT when building app-server
config; Zen provider config explicitly changes base_url/wire_api based on whether serverPort is
defined, so a spawn before the env var is set will lock in the wrong settings for that app-server
process.

src/cli/index.ts[506-542]
src/server/httpServer.ts[75-87]
src/server/codexAppServerBridge.ts[5299-5326]
src/server/skillsRoutes.ts[1285-1306]
src/server/codexAppServerBridge.ts[4306-4337]
src/server/freeMode.ts[219-240]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`CODEXUI_SERVER_PORT` is set after `await listenWithFallback(...)`, but the bridge middleware starts background tasks immediately and can spawn `codex app-server` before the env var is set. When that happens, free-mode provider config (notably OpenCode Zen) is computed without `serverPort`, producing the wrong `base_url`/`wire_api` for the lifetime of the spawned app-server.
### Issue Context
- `createApp()` (HTTP server factory) constructs the Codex bridge middleware, which calls `void initializeSkillsSyncOnStartup(appServer)`.
- On Android bootstrap, skills sync triggers `appServer.rpc('skills/list', ...)`, which starts the app-server and reads `process.env.CODEXUI_SERVER_PORT` to decide whether to use the local proxy.
- Updating `process.env.CODEXUI_SERVER_PORT` after the app-server has started does not retroactively reconfigure it.
### Fix Focus Areas
- src/cli/index.ts[506-542]
- src/server/httpServer.ts[75-87]
- src/server/codexAppServerBridge.ts[5299-5326]
### Suggested approach
1. Ensure `CODEXUI_SERVER_PORT` is set before creating the bridge (i.e., before `createApp({ password })`) to avoid an unset value during early app-server startup.
2. If you still need the *actual* bound port (because of fallback), avoid races by either:
- delaying bridge startup tasks until after binding, or
- passing the resolved port into the bridge/app-server config (instead of using `process.env` as the source of truth), and/or
- restarting/rebuilding the shared `AppServerProcess` if the port differs from the initially assumed port.

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



Remediation recommended

7. Free-mode status fetch can hang ✓ Resolved 🐞 Bug ☼ Reliability
Description
getCurrentModelConfig() now awaits getFreeModeStatus() without any timeout; if
/codex-api/free-mode/status stalls, refreshModelPreferences() (and thus refreshAll() startup)
can hang indefinitely.
Code

src/api/codexGateway.ts[R1806-1811]

+  try {
+    const freeModeStatus = await getFreeModeStatus()
+    if (freeModeStatus.enabled) {
+      model = freeModeStatus.currentModel ?? model
+      providerId = freeModeStatus.provider ?? providerId
+    }
Evidence
The new logic in getCurrentModelConfig() makes an additional fetch to
/codex-api/free-mode/status and awaits it. The surrounding try/catch only helps if the fetch
rejects; it does not protect against a never-resolving/stalled request.
useDesktopState.refreshModelPreferences() awaits getCurrentModelConfig(), and refreshAll()
awaits refreshModelPreferences() during initial load when thread messages are included—so a
stalled status request can block initial rendering. In contrast, provider model discovery fetches
use AbortSignal.timeout, showing an established pattern for bounding waits.

src/api/codexGateway.ts[1800-1816]
src/api/codexGateway.ts[1771-1775]
src/composables/useDesktopState.ts[1881-1889]
src/composables/useDesktopState.ts[4379-4395]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`getCurrentModelConfig()` now awaits `getFreeModeStatus()` without a timeout. If the request stalls, model refresh and startup refresh flows can hang.
### Issue Context
- `try/catch` only handles rejection; it does not bound a stalled promise.
- `refreshModelPreferences()` and `refreshAll()` await the model config during startup.
- Other gateway fetches already use `AbortSignal.timeout(...)`.
### Fix Focus Areas
- src/api/codexGateway.ts[1717-1720]
- src/api/codexGateway.ts[1806-1811]
### Suggested change
Add a bounded timeout to the free-mode status fetch, e.g.:
- `fetch('/codex-api/free-mode/status', { signal: AbortSignal.timeout(8000) })`
Optionally also check `response.ok` before `response.json()` and treat non-OK as a failure that falls back to the `config/read` values.

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


8. sidebar-settings-provider-dropdown uses scoped dark 📘 Rule violation ⚙ Maintainability
Description
The settings Provider dropdown introduces component-scoped :root.dark overrides in src/App.vue
rather than placing the decisive shared-surface dark-theme styling in src/style.css. This
conflicts with the guidance to centralize dark-theme overrides for shared route surfaces/large UIs
in the global stylesheet.
Code

src/App.vue[R5238-5243]

+: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;
Evidence
The checklist requires shared/large UI dark-theme overrides to be implemented in src/style.css.
The PR adds dark-theme styling for the settings Provider dropdown trigger directly in src/App.vue
via :root.dark ..., which is the component-scoped pattern the rule aims to avoid for shared
surfaces.

AGENTS.md
src/App.vue[5202-5204]
src/App.vue[5238-5244]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Dark-theme styling for the settings Provider `ComposerDropdown` trigger is defined in `src/App.vue` using component-scoped `:root.dark` selectors. For shared UI surfaces like the settings panel, the decisive dark overrides should be centralized in `src/style.css`.
## Issue Context
The PR already adds global dark-theme overrides for automation dropdown triggers in `src/style.css`, but the Provider dropdown keeps its dark styling locally in the component.
## Fix Focus Areas
- src/App.vue[5202-5216]
- src/App.vue[5238-5244]
- src/style.css[290-305]

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


9. Provider switch no rollback ✓ Resolved 🐞 Bug ☼ Reliability
Description
onProviderChange() updates selectedProvider and previews provider-scoped model state before any
network call, but on failure it only sets providerError and does not restore the previous
provider/model state, leaving the UI/state potentially inconsistent after a failed switch.
Code

src/App.vue[R3638-3702]

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)
+      }
+      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 = ''
+    }
Evidence
App.vue calls previewProviderModelSelection() prior to setFreeMode/setCustomProvider/refreshAll;
previewProviderModelSelection overwrites activeProviderId, selectedModelId, and availableModelIds.
The catch block does not restore these values (nor selectedProvider), so a thrown error leaves the
app in a partially-switched client state.

src/App.vue[3628-3705]
src/composables/useDesktopState.ts[1632-1637]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Provider switching previews state changes early, but failures don’t roll back client state (provider + model selection state), which can leave the UI showing a provider/model that does not match the backend configuration.
## Issue Context
- `previewProviderModelSelection()` mutates `activeProviderId`, `selectedModelId`, and `availableModelIds`.
- `onProviderChange()` sets `selectedProvider` before awaiting remote operations.
## Fix Focus Areas
- Snapshot previous provider/model state before preview.
- In `catch`, restore the snapshot and/or re-sync from the server (e.g., `loadFreeModeStatus()` + `refreshAll({ providerChanged: true, awaitAncillaryRefreshes: true })`) so UI reflects actual backend state.
- src/App.vue[3628-3705]
- src/composables/useDesktopState.ts[1632-1637]

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


View more (1)
10. Missing thread route loops 🐞 Bug ☼ Reliability
Description
syncThreadSelectionWithRoute() now selects a /thread/:id route even when the thread is not present
in the sidebar, and selectThread() commits selectedThreadId before loading messages; if the thread
ID is invalid/unavailable, the app can remain on that route with repeated load failures instead of
navigating away.
Code

src/App.vue[R3996-4001]

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)
Evidence
App.vue intentionally allows selecting threads absent from the sidebar, but
useDesktopState.selectThread() sets selectedThreadId first and does not revert it on loadMessages()
failure; loadMessages() propagates failures. This means invalid/unavailable thread IDs can become
the persisted selected thread, keeping the app stuck on a broken thread route until the user
manually navigates away.

src/App.vue[3964-4005]
src/composables/useDesktopState.ts[4420-4429]
src/composables/useDesktopState.ts[4228-4334]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When a `/thread/:id` route refers to a thread that can’t be loaded, the app currently commits that ID into `selectedThreadId` and just surfaces an error. This can leave the UI stuck on an invalid thread route.
## Issue Context
- `syncThreadSelectionWithRoute()` will call `selectThread(threadId)` even if `threadExistsInSidebar(threadId)` is false.
- `selectThread()` sets `selectedThreadId` before attempting `loadMessages()`.
## Fix Focus Areas
- In `selectThread()` (or in `syncThreadSelectionWithRoute()`), if `loadMessages()` fails with a not-found/unavailable error, clear selection and navigate to `{ name: 'home' }`.
- Alternatively, validate thread existence via a lightweight API call before committing `selectedThreadId`.
- src/App.vue[3964-4005]
- src/composables/useDesktopState.ts[4420-4429]
- src/composables/useDesktopState.ts[4228-4334]

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


Grey Divider

Qodo Logo

Comment thread src/components/sidebar/SidebarThreadTree.vue
Comment thread src/cli/index.ts
@friuns2 friuns2 marked this pull request as draft May 9, 2026 21:32
@friuns2 friuns2 marked this pull request as ready for review May 9, 2026 21:32
@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented May 9, 2026

Persistent review updated to latest commit 7633fbb

Comment thread src/composables/useDesktopState.ts Outdated
@friuns2 friuns2 marked this pull request as draft May 9, 2026 22:47
@friuns2 friuns2 marked this pull request as ready for review May 9, 2026 22:48
@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented May 9, 2026

Persistent review updated to latest commit 77d3cc8

Comment thread src/components/content/ReviewPane.vue
Comment thread src/composables/useDesktopState.ts
@friuns2 friuns2 marked this pull request as draft May 9, 2026 23:23
@friuns2 friuns2 marked this pull request as ready for review May 9, 2026 23:23
@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented May 9, 2026

Persistent review updated to latest commit dc7871a

Comment thread src/App.vue
Comment on lines +3695 to +3699
if (activeThreadIdBeforeProviderChange) {
const providerModelId = readModelIdForThread('__new-thread__').trim() || selectedModelId.value.trim()
if (providerModelId) {
setSelectedModelIdForThread(activeThreadIdBeforeProviderChange, providerModelId)
}
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants