From 018e71c07e5fb94dce1e3ea7016f5ebb158ed436 Mon Sep 17 00:00:00 2001 From: Yan Wang Date: Thu, 3 Sep 2026 19:49:30 -0700 Subject: [PATCH 1/6] feat(models): user-managed model shortlist with /provider catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hardcoded 9-model list with a user-managed shortlist that mirrors the iOS mechanism: the chat picker shows only the shortlist, a new Settings screen adds/removes/reorders/renames models, and the candidate catalog is built dynamically from GET /provider (falling back to config/providers without a connected filter on older servers). Model selection is now persisted by model ID (providerId/modelId) instead of list index, with a one-time idempotent migration guarded by schema version 2 and first-launch seeding of the existing 9 presets so existing users are unaffected. Auto-add paths (session switch + message history inference) keep the picker in sync with what a session actually runs. Docs: RFC §4.5/§5.14, PRD feature + decisions, working log. --- .gitignore | 3 + .../com/yage/opencode_client/MainActivity.kt | 4 + .../opencode_client/data/api/OpenCodeApi.kt | 3 + .../yage/opencode_client/data/model/Config.kt | 35 ++- .../data/model/ModelShortlist.kt | 19 ++ .../data/repository/OpenCodeRepository.kt | 2 + .../yage/opencode_client/ui/MainViewModel.kt | 124 ++++++-- .../ui/MainViewModelConnectionActions.kt | 30 +- .../ui/MainViewModelSessionActions.kt | 82 ++++- .../yage/opencode_client/ui/ModelShortlist.kt | 216 +++++++++++++ .../opencode_client/ui/chat/ChatScreen.kt | 5 + .../opencode_client/ui/chat/ChatTopBar.kt | 10 + .../ui/settings/ModelShortlistScreen.kt | 295 ++++++++++++++++++ .../ui/settings/SettingsScreen.kt | 24 ++ .../ui/settings/SettingsSections.kt | 47 +++ .../opencode_client/util/SettingsManager.kt | 50 ++- app/src/main/res/values-zh/strings.xml | 17 + app/src/main/res/values/strings.xml | 17 + .../com/yage/opencode_client/AppStateTest.kt | 28 +- .../yage/opencode_client/ForkSessionTest.kt | 8 +- .../yage/opencode_client/MainViewModelTest.kt | 81 ++++- .../opencode_client/ModelShortlistTest.kt | 213 +++++++++++++ .../opencode_client/NfcQuickPromptTest.kt | 6 +- docs/PRD.md | 17 +- docs/RFC.md | 101 ++++++ docs/working.md | 32 ++ 26 files changed, 1385 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/com/yage/opencode_client/data/model/ModelShortlist.kt create mode 100644 app/src/main/java/com/yage/opencode_client/ui/ModelShortlist.kt create mode 100644 app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt create mode 100644 app/src/test/java/com/yage/opencode_client/ModelShortlistTest.kt diff --git a/.gitignore b/.gitignore index 7ad03a4b..3cc88349 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ ENV/ # Local Markdown Web Preview fixtures copied from iOS for manual Android rendering tests docs/ios_markdown_preview_fixtures/ + +# TMP working design docs (merged into PRD/RFC/working before PR merge, then deleted) +docs/TMP* diff --git a/app/src/main/java/com/yage/opencode_client/MainActivity.kt b/app/src/main/java/com/yage/opencode_client/MainActivity.kt index f5fca78d..b91270e2 100644 --- a/app/src/main/java/com/yage/opencode_client/MainActivity.kt +++ b/app/src/main/java/com/yage/opencode_client/MainActivity.kt @@ -282,6 +282,9 @@ private fun PhoneLayout(viewModel: MainViewModel) { onNavigateToSettings = { navigateToTopLevel(Screen.Settings.route) }, + onManageModels = { + navigateToTopLevel(Screen.Settings.route) + }, showSettingsButton = false ) } @@ -483,6 +486,7 @@ private fun TabletLayout(viewModel: MainViewModel) { }, useInlineFilePreview = true, onNavigateToSettings = onOpenSettings, + onManageModels = onOpenSettings, showSettingsButton = false, showNewSessionInTopBar = false, showSessionListInTopBar = false diff --git a/app/src/main/java/com/yage/opencode_client/data/api/OpenCodeApi.kt b/app/src/main/java/com/yage/opencode_client/data/api/OpenCodeApi.kt index 0b736dd1..030c8103 100644 --- a/app/src/main/java/com/yage/opencode_client/data/api/OpenCodeApi.kt +++ b/app/src/main/java/com/yage/opencode_client/data/api/OpenCodeApi.kt @@ -79,6 +79,9 @@ interface OpenCodeApi { @GET("config/providers") suspend fun getProviders(): ProvidersResponse + @GET("provider") + suspend fun getProviderRegistry(): ProviderRegistryResponse + @GET("agent") suspend fun getAgents(): List diff --git a/app/src/main/java/com/yage/opencode_client/data/model/Config.kt b/app/src/main/java/com/yage/opencode_client/data/model/Config.kt index fae3feb5..70355dc8 100644 --- a/app/src/main/java/com/yage/opencode_client/data/model/Config.kt +++ b/app/src/main/java/com/yage/opencode_client/data/model/Config.kt @@ -24,6 +24,21 @@ data class ProvidersResponse( } } +/** + * Response of `GET /provider`: every known provider plus the subset that is + * connected (authenticated, or keyless-local providers like a custom Ollama + * endpoint). Used to scope the model catalog to models the user can actually + * run. Mirrors iOS `ProviderRegistryResponse`. + */ +@Serializable +data class ProviderRegistryResponse( + val all: List = emptyList(), + @SerialName("default") val defaultByProvider: Map = emptyMap(), + val connected: List = emptyList() +) { + val connectedProviderIds: Set get() = connected.toSet() +} + @Serializable data class ConfigProvider( val id: String = "", @@ -37,11 +52,29 @@ data class ProviderModel( val name: String? = null, @SerialName("providerID") val providerId: String? = null, @SerialName("providerId") val providerIdAlt: String? = null, - val limit: ProviderModelLimit? = null + val limit: ProviderModelLimit? = null, + val capabilities: ProviderModelCapabilities? = null ) { val resolvedProviderId: String? get() = providerId ?: providerIdAlt } +/** + * Minimal slice of the server `capabilities` object. Chat-capable means the + * model can produce text output; missing info is treated as capable (older + * servers may not report it) so a false negative never hides a working model. + */ +@Serializable +data class ProviderModelCapabilities( + val output: ProviderModelOutput? = null +) { + val isChatCapable: Boolean get() = output?.text ?: true +} + +@Serializable +data class ProviderModelOutput( + val text: Boolean? = null +) + @Serializable data class ProviderModelLimit( val context: Int? = null, diff --git a/app/src/main/java/com/yage/opencode_client/data/model/ModelShortlist.kt b/app/src/main/java/com/yage/opencode_client/data/model/ModelShortlist.kt new file mode 100644 index 00000000..ba882ff9 --- /dev/null +++ b/app/src/main/java/com/yage/opencode_client/data/model/ModelShortlist.kt @@ -0,0 +1,19 @@ +package com.yage.opencode_client.data.model + +import kotlinx.serialization.Serializable + +/** + * A user-curated model entry shown in the chat model picker. Persisted locally + * (JSON array) and managed from Settings. Mirrors iOS `ModelShortlistItem`. + * The stable identity is `providerId/modelId`; `displayName`/`shortName` are + * display-only and may be refreshed from the server catalog. + */ +@Serializable +data class ModelShortlistItem( + val providerId: String, + val modelId: String, + val displayName: String, + val shortName: String +) { + val id: String get() = "$providerId/$modelId" +} \ No newline at end of file diff --git a/app/src/main/java/com/yage/opencode_client/data/repository/OpenCodeRepository.kt b/app/src/main/java/com/yage/opencode_client/data/repository/OpenCodeRepository.kt index 072b8c35..e8272355 100644 --- a/app/src/main/java/com/yage/opencode_client/data/repository/OpenCodeRepository.kt +++ b/app/src/main/java/com/yage/opencode_client/data/repository/OpenCodeRepository.kt @@ -204,6 +204,8 @@ class OpenCodeRepository @Inject constructor() { suspend fun getProviders(): Result = apiCall { api.getProviders() } + suspend fun getProviderRegistry(): Result = apiCall { api.getProviderRegistry() } + suspend fun getAgents(): Result> = apiCall { api.getAgents() } suspend fun getSessionDiff(sessionId: String): Result> = apiCall { diff --git a/app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt b/app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt index 4ba4b109..21c2c5f0 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt @@ -76,6 +76,11 @@ data class AppState( val agents: List = emptyList(), val selectedAgentName: String = "build", val selectedModelIndex: Int = 2, + val selectedModelId: String? = null, + val modelShortlist: List = emptyList(), + val catalogModels: List = emptyList(), + val providerDisplayNames: Map = emptyMap(), + val pendingModelShortlistFocus: Boolean = false, val providers: ProvidersResponse? = null, val pendingPermissions: List = emptyList(), val pendingQuestions: List = emptyList(), @@ -116,21 +121,9 @@ data class AppState( val aiUsageError: String? = null ) { data class NfcPendingAction(val prompt: String, val autoSend: Boolean) - data class ModelOption(val displayName: String, val providerId: String, val modelId: String) { + data class ModelOption(val displayName: String, val providerId: String, val modelId: String, val customShortName: String? = null) { val shortName: String - get() = when { - displayName == "DeepSeek V4 Flash" -> "DS-Flash" - displayName == "DeepSeek Local" -> "DS-L" - displayName == "Ollama GLM 5.2" -> "OGLM-5.2" - displayName == "GPT-5.6 Terra Fast" -> "GPT-TF" - displayName == "GPT-5.6 Luna" -> "GPT-L" - "Haiku" in displayName -> "Haiku" - "Gemini" in displayName -> "Gemini" - "GPT" in displayName -> "GPT" - "Grok" in displayName -> "Grok" - "Qwen" in displayName -> "Qwen" - else -> displayName.split(" ").firstOrNull() ?: displayName - } + get() = customShortName?.trim()?.takeIf { it.isNotEmpty() } ?: suggestedShortName(displayName) } data class ContextUsage( @@ -209,8 +202,12 @@ data class AppState( val themeMode: ThemeMode = ThemeMode.SYSTEM, val languageMode: LanguageMode = LanguageMode.SYSTEM, val selectedModelIndex: Int = 2, + val selectedModelId: String? = null, val selectedAgentName: String = "build", - val availableModels: List = ModelPresets.list, + val availableModels: List = emptyList(), + val modelShortlist: List = emptyList(), + val catalogModels: List = emptyList(), + val providerDisplayNames: Map = emptyMap(), val contextUsage: ContextUsage? = null, val agents: List = emptyList(), val providers: ProvidersResponse? = null, @@ -279,8 +276,12 @@ data class AppState( themeMode = themeMode, languageMode = languageMode, selectedModelIndex = selectedModelIndex, + selectedModelId = selectedModelId, selectedAgentName = selectedAgentName, availableModels = availableModels, + modelShortlist = modelShortlist, + catalogModels = catalogModels, + providerDisplayNames = providerDisplayNames, contextUsage = contextUsage, agents = agents, providers = providers, @@ -305,9 +306,16 @@ data class AppState( val visibleAgents: List get() = agents.filter { it.isVisible } - /** Curated model list (filtered like iOS), not the full API response. */ + /** Curated model list (the user shortlist), not the full API response. */ val availableModels: List - get() = ModelPresets.list + get() = modelShortlist.map { item -> + ModelOption( + displayName = item.displayName, + providerId = item.providerId, + modelId = item.modelId, + customShortName = item.shortName + ) + } val selectedAIUsageQuota: AIUsageQuota? get() { @@ -323,17 +331,7 @@ data class AppState( } private val providerModelsIndex: Map - get() = providers?.providers?.flatMap { provider -> - provider.models.flatMap { (modelKey, model) -> - listOfNotNull( - "${provider.id}/$modelKey" to model, - model.id.takeIf { it.isNotEmpty() }?.let { "${provider.id}/$it" to model }, - model.resolvedProviderId?.let { resolvedProvider -> - model.id.takeIf { it.isNotEmpty() }?.let { modelId -> "$resolvedProvider/$modelId" to model } - } - ) - } - }?.toMap() ?: emptyMap() + get() = buildProviderModelsIndex(providers) val contextUsage: ContextUsage? get() { @@ -1549,7 +1547,7 @@ class MainViewModel @Inject constructor( } private fun loadProviders() { - launchLoadProviders(hostRuntimeScope, repository, _state) { message, error -> + launchLoadProviders(hostRuntimeScope, repository, _state, settingsManager) { message, error -> reportNonFatalIssue(TAG, message, error) } } @@ -1765,10 +1763,70 @@ class MainViewModel @Inject constructor( } fun selectModel(index: Int) { - val clamped = index.coerceIn(0, ModelPresets.list.size - 1) - settingsManager.selectedModelIndex = clamped - _state.update { it.copy(selectedModelIndex = clamped) } - _state.value.currentSessionId?.let { settingsManager.setModelForSession(it, clamped) } + val list = _state.value.modelShortlist + if (list.isEmpty()) return + val clamped = index.coerceIn(0, list.size - 1) + val id = list[clamped].id + settingsManager.selectedModelId = id + _state.update { it.copy(selectedModelIndex = clamped, selectedModelId = id) } + _state.value.currentSessionId?.let { settingsManager.setModelIdForSession(it, id) } + } + + fun moveModelShortlist(from: Int, to: Int) { + val current = _state.value.modelShortlist + val next = moveShortlistItem(current, from, to) + if (next == current) return + settingsManager.modelShortlistJson = encodeShortlist(next) + _state.update { + it.copy(modelShortlist = next, selectedModelIndex = reanchorSelectedModelIndex(next, it.selectedModelId)) + } + } + + fun removeModelShortlistItem(id: String) { + val current = _state.value.modelShortlist + val next = removeShortlistItem(current, id) + if (next == current) return + settingsManager.modelShortlistJson = encodeShortlist(next) + _state.update { + val selectedId = if (it.selectedModelId == id) null else it.selectedModelId + it.copy( + modelShortlist = next, + selectedModelId = selectedId, + selectedModelIndex = reanchorSelectedModelIndex(next, selectedId) + ) + } + } + + fun updateModelShortlistShortName(id: String, shortName: String) { + val current = _state.value.modelShortlist + val next = updateShortlistShortName(current, id, shortName) + if (next == current) return + settingsManager.modelShortlistJson = encodeShortlist(next) + _state.update { it.copy(modelShortlist = next) } + } + + fun addModelsToShortlist(items: List) { + var next = _state.value.modelShortlist + var changed = false + for (item in items) { + val (added, c) = addModelToShortlist(next, item.providerId, item.modelId, item.displayName) + next = added + changed = changed || c + } + if (!changed) return + settingsManager.modelShortlistJson = encodeShortlist(next) + _state.update { + it.copy(modelShortlist = next, selectedModelIndex = reanchorSelectedModelIndex(next, it.selectedModelId)) + } + } + + /** Asks the Settings screen to open the model shortlist when it appears. */ + fun requestModelShortlistFocus() { + _state.update { it.copy(pendingModelShortlistFocus = true) } + } + + fun clearModelShortlistFocus() { + _state.update { it.copy(pendingModelShortlistFocus = false) } } fun setThemeMode(mode: ThemeMode) { diff --git a/app/src/main/java/com/yage/opencode_client/ui/MainViewModelConnectionActions.kt b/app/src/main/java/com/yage/opencode_client/ui/MainViewModelConnectionActions.kt index 893fc0f1..a10d80f9 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/MainViewModelConnectionActions.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/MainViewModelConnectionActions.kt @@ -17,6 +17,7 @@ internal fun applySavedSettings( state: MutableStateFlow ) { settingsManager.migrateRemovedGpt56SolProModelIndices() + migrateModelSelectionToIds(settingsManager) val currentProfile = hostProfileStore.currentProfile() val password = currentProfile.basicAuth?.passwordId?.let { settingsManager.basicAuthPassword(it) } repository.configure( @@ -25,11 +26,9 @@ internal fun applySavedSettings( password = password ) - val savedModelIndex = settingsManager.selectedModelIndex - val clampedModelIndex = savedModelIndex.coerceIn(0, ModelPresets.list.size - 1) - if (clampedModelIndex != savedModelIndex) { - settingsManager.selectedModelIndex = clampedModelIndex - } + val shortlist = decodeShortlist(settingsManager.modelShortlistJson) ?: seedShortlistFromPresets() + val selectedModelId = settingsManager.selectedModelId + val clampedModelIndex = reanchorSelectedModelIndex(shortlist, selectedModelId) state.update { it.copy( @@ -37,6 +36,8 @@ internal fun applySavedSettings( hostProfiles = hostProfileStore.profiles(), currentHostProfileId = currentProfile.id, selectedModelIndex = clampedModelIndex, + selectedModelId = selectedModelId, + modelShortlist = shortlist, selectedAgentName = settingsManager.selectedAgentName ?: "build", themeMode = settingsManager.themeMode, languageMode = settingsManager.languageMode @@ -53,6 +54,25 @@ internal fun applySavedSettings( } } +/** + * One-time migration from the legacy index-based model selection to the + * ID-based model. Seeds the shortlist from the presets when none is persisted, + * maps the legacy selected index and per-session index map onto stable + * "providerId/modelId" strings, and stamps the schema version so it runs once. + */ +internal fun migrateModelSelectionToIds(settingsManager: SettingsManager) { + if (settingsManager.modelShortlistSchemaVersion >= SettingsManager.MODEL_SHORTLIST_SCHEMA_VERSION) return + val migration = migrateToIdBasedModelSelection( + existingShortlist = decodeShortlist(settingsManager.modelShortlistJson), + legacySelectedIndex = settingsManager.selectedModelIndex, + legacySessionModels = settingsManager.getLegacySessionModels() + ) + settingsManager.modelShortlistJson = encodeShortlist(migration.shortlist) + migration.selectedModelId?.let { settingsManager.selectedModelId = it } + settingsManager.setSessionModelIds(migration.sessionModelIds) + settingsManager.modelShortlistSchemaVersion = SettingsManager.MODEL_SHORTLIST_SCHEMA_VERSION +} + internal fun launchConnectionTest( scope: CoroutineScope, repository: OpenCodeRepository, diff --git a/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt b/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt index 87f36a9b..b900d937 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt @@ -4,6 +4,8 @@ import com.yage.opencode_client.data.model.ComposerImageAttachment import com.yage.opencode_client.data.model.Message import com.yage.opencode_client.data.model.MessageWithParts import com.yage.opencode_client.data.model.Part +import com.yage.opencode_client.data.model.ProviderModel +import com.yage.opencode_client.data.model.ProvidersResponse import com.yage.opencode_client.data.repository.OpenCodeRepository import com.yage.opencode_client.util.SettingsManager import kotlinx.coroutines.CoroutineScope @@ -205,14 +207,30 @@ internal fun launchLoadMessages( .onSuccess { messages -> if (sessionId == state.value.currentSessionId) { val lastAssistant = messages.lastOrNull { it.info.isAssistant } - val inferredModelIndex = lastAssistant?.info?.resolvedModel?.let { model -> - ModelPresets.list.indexOfFirst { - it.providerId == model.providerId && it.modelId == model.modelId - }.takeIf { it >= 0 } - } + val inferredModel = lastAssistant?.info?.resolvedModel val inferredAgentName = lastAssistant?.info?.agent - val modelIndex = settingsManager?.getModelForSession(sessionId) ?: inferredModelIndex + val targetModelId = settingsManager?.getModelIdForSession(sessionId) + ?: inferredModel?.let { "${it.providerId}/${it.modelId}" } val agentName = settingsManager?.getAgentForSession(sessionId) ?: inferredAgentName + + // Surface the session's actual model in the shortlist when it's + // missing (provider disconnected, or a dynamic model), mirroring iOS. + var nextShortlist = state.value.modelShortlist + if (inferredModel != null) { + val fullId = "${inferredModel.providerId}/${inferredModel.modelId}" + val displayName = buildProviderModelsIndex(state.value.providers)[fullId]?.name + ?: inferredModel.modelId + val (added, changed) = addModelToShortlist( + nextShortlist, inferredModel.providerId, inferredModel.modelId, displayName + ) + if (changed) { + nextShortlist = added + settingsManager?.modelShortlistJson = encodeShortlist(nextShortlist) + } + } + val effectiveModelId = targetModelId ?: state.value.selectedModelId + val modelIndex = reanchorSelectedModelIndex(nextShortlist, effectiveModelId) + state.update { val (mergedMessages, prunedPending) = mergePendingOptimisticMessages(messages, it) it.copy( @@ -220,7 +238,9 @@ internal fun launchLoadMessages( pendingOptimisticMessageIds = prunedPending, messageLimit = limit, isLoadingMessages = false, - selectedModelIndex = modelIndex ?: it.selectedModelIndex, + modelShortlist = nextShortlist, + selectedModelId = effectiveModelId, + selectedModelIndex = modelIndex, selectedAgentName = agentName ?: it.selectedAgentName ) } @@ -307,17 +327,51 @@ internal fun launchLoadProviders( scope: CoroutineScope, repository: OpenCodeRepository, state: MutableStateFlow, + settingsManager: SettingsManager?, onNonFatalError: (String, Throwable?) -> Unit ) { scope.launch { - repository.getProviders() - .onSuccess { providers -> - state.update { it.copy(providers = providers) } - } - .onFailure { error -> - onNonFatalError("Failed to load providers", error) - } + val providersResult = repository.getProviders() + providersResult + .onSuccess { providers -> state.update { it.copy(providers = providers) } } + .onFailure { error -> onNonFatalError("Failed to load providers", error) } + + // Build the model catalog from /provider (connected-scoped), falling back + // to config/providers (unscoped) when the registry is unavailable (D4). + val (catalogModels, providerDisplayNames) = resolveModelCatalog(repository, providersResult) + + // Refresh shortlist display names from the catalog; short names are kept (D6). + val refreshed = refreshShortlistDisplayNames(state.value.modelShortlist, catalogModels) + if (refreshed != state.value.modelShortlist) { + settingsManager?.modelShortlistJson = encodeShortlist(refreshed) + } + state.update { + it.copy( + catalogModels = catalogModels, + providerDisplayNames = providerDisplayNames, + modelShortlist = refreshed, + selectedModelIndex = reanchorSelectedModelIndex(refreshed, it.selectedModelId) + ) + } + } +} + +private suspend fun resolveModelCatalog( + repository: OpenCodeRepository, + providersResult: Result +): Pair, Map> { + val registryResult = repository.getProviderRegistry() + if (registryResult.isSuccess) { + val registry = registryResult.getOrThrow() + val built = buildCatalog(registry.all, registry.connectedProviderIds) + return built.models to built.providerDisplayNames + } + val providers = providersResult.getOrNull() + if (providers != null) { + val built = buildCatalog(providers.providers, null) + return built.models to built.providerDisplayNames } + return emptyList() to emptyMap() } internal fun launchCreateSession( diff --git a/app/src/main/java/com/yage/opencode_client/ui/ModelShortlist.kt b/app/src/main/java/com/yage/opencode_client/ui/ModelShortlist.kt new file mode 100644 index 00000000..dd2fbe85 --- /dev/null +++ b/app/src/main/java/com/yage/opencode_client/ui/ModelShortlist.kt @@ -0,0 +1,216 @@ +package com.yage.opencode_client.ui + +import com.yage.opencode_client.data.model.ConfigProvider +import com.yage.opencode_client.data.model.ModelShortlistItem +import com.yage.opencode_client.data.model.ProviderModel +import com.yage.opencode_client.data.model.ProvidersResponse +import com.yage.opencode_client.util.migrateLegacyModelIndex +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * A model the server reports as available (from `GET /provider` or the + * `config/providers` fallback). Used to populate the Settings "add model" + * catalog. Mirrors iOS `catalogModelPresets`. + */ +data class CatalogModel( + val providerId: String, + val modelId: String, + val displayName: String, + val shortName: String +) { + val id: String get() = "$providerId/$modelId" +} + +/** Result of building the catalog: the flat model list plus provider display names. */ +data class CatalogBuildResult( + val models: List, + val providerDisplayNames: Map +) + +/** + * One-time migration output: the shortlist to persist (seeded when absent), + * the selected model ID derived from the legacy index, and the per-session + * ID map derived from the legacy per-session index map. + */ +data class ModelShortlistMigration( + val shortlist: List, + val selectedModelId: String?, + val sessionModelIds: Map +) + +private val shortlistJson = Json { ignoreUnknownKeys = true } + +fun encodeShortlist(items: List): String = + shortlistJson.encodeToString(items) + +fun decodeShortlist(json: String?): List? = try { + json?.let { shortlistJson.decodeFromString>(it) } +} catch (_: Exception) { + null +} + +/** + * Display-name → short-name heuristic. Shared by the seeded presets, the + * catalog, and [AppState.ModelOption.shortName] so a model always gets the + * same compact label unless the user overrides it. + */ +fun suggestedShortName(displayName: String): String = when { + displayName == "DeepSeek V4 Flash" -> "DS-Flash" + displayName == "DeepSeek Local" -> "DS-L" + displayName == "Ollama GLM 5.2" -> "OGLM-5.2" + displayName == "GPT-5.6 Terra Fast" -> "GPT-TF" + displayName == "GPT-5.6 Luna" -> "GPT-L" + "Haiku" in displayName -> "Haiku" + "Gemini" in displayName -> "Gemini" + "GPT" in displayName -> "GPT" + "Grok" in displayName -> "Grok" + "Qwen" in displayName -> "Qwen" + else -> displayName.split(" ").firstOrNull() ?: displayName +} + +/** Seeds the shortlist from the hardcoded presets on first launch (D1). */ +fun seedShortlistFromPresets(): List = + ModelPresets.list.map { option -> + ModelShortlistItem( + providerId = option.providerId, + modelId = option.modelId, + displayName = option.displayName, + shortName = suggestedShortName(option.displayName) + ) + } + +/** + * Builds the Settings catalog from a provider list. When [connectedProviderIds] + * is non-null the catalog is scoped to connected providers (the `/provider` + * path); when null every provider is included (the `config/providers` + * fallback, D4). Only chat-capable models are kept. + */ +fun buildCatalog( + providers: List, + connectedProviderIds: Set? +): CatalogBuildResult { + val names = mutableMapOf() + val models = mutableListOf() + for (provider in providers) { + if (connectedProviderIds != null && provider.id !in connectedProviderIds) continue + provider.name?.takeIf { it.isNotEmpty() }?.let { names[provider.id] = it } + for ((modelId, model) in provider.models.toSortedMap()) { + if (!(model.capabilities?.isChatCapable ?: true)) continue + val displayName = model.name ?: modelId + models.add(CatalogModel(provider.id, modelId, displayName, suggestedShortName(displayName))) + } + } + models.sortWith(compareBy({ it.providerId }, { it.displayName })) + return CatalogBuildResult(models, names) +} + +/** + * Flat "providerId/modelId" -> model index across all providers. Used to look + * up a model's display name / context limit from the `config/providers` payload. + */ +fun buildProviderModelsIndex(providers: ProvidersResponse?): Map = + providers?.providers?.flatMap { provider -> + provider.models.flatMap { (modelKey, model) -> + listOfNotNull( + "${provider.id}/$modelKey" to model, + model.id.takeIf { it.isNotEmpty() }?.let { "${provider.id}/$it" to model }, + model.resolvedProviderId?.let { resolvedProvider -> + model.id.takeIf { it.isNotEmpty() }?.let { modelId -> "$resolvedProvider/$modelId" to model } + } + ) + } + }?.toMap() ?: emptyMap() + +/** + * Appends [providerId]/[modelId] to the shortlist if it is not already present. + * Returns the new list and whether anything changed. + */ +fun addModelToShortlist( + shortlist: List, + providerId: String, + modelId: String, + displayName: String +): Pair, Boolean> { + val id = "$providerId/$modelId" + if (shortlist.any { it.id == id }) return shortlist to false + val item = ModelShortlistItem(providerId, modelId, displayName, suggestedShortName(displayName)) + return (shortlist + item) to true +} + +fun removeShortlistItem(shortlist: List, id: String): List = + shortlist.filter { it.id != id } + +/** Moves the item at [from] to [to]; returns the list unchanged when either index is out of range. */ +fun moveShortlistItem(shortlist: List, from: Int, to: Int): List { + if (from !in shortlist.indices || to !in shortlist.indices) return shortlist + val next = shortlist.toMutableList() + val item = next.removeAt(from) + next.add(to, item) + return next +} + +/** Updates the user-editable short name; a blank value falls back to the suggested one. */ +fun updateShortlistShortName( + shortlist: List, + id: String, + shortName: String +): List { + val trimmed = shortName.trim() + return shortlist.map { item -> + if (item.id == id) { + item.copy(shortName = trimmed.ifEmpty { suggestedShortName(item.displayName) }) + } else { + item + } + } +} + +/** Refreshes display names from the catalog; short names (user edits) are preserved (D6). */ +fun refreshShortlistDisplayNames( + shortlist: List, + catalog: List +): List { + if (shortlist.isEmpty()) return shortlist + val names = catalog.associate { it.id to it.displayName } + return shortlist.map { item -> + names[item.id]?.let { name -> + if (name != item.displayName) item.copy(displayName = name) else item + } ?: item + } +} + +/** Index of [selectedModelId] in the shortlist, or 0 when absent/empty. */ +fun reanchorSelectedModelIndex( + shortlist: List, + selectedModelId: String? +): Int { + if (shortlist.isEmpty()) return 0 + return shortlist.indexOfFirst { it.id == selectedModelId }.takeIf { it >= 0 } ?: 0 +} + +/** + * Maps the legacy index-based selection onto the ID-based model. The legacy + * index always pointed into [ModelPresets.list] order, so the index→ID mapping + * uses the canonical seed regardless of any user customization. [existingShortlist] + * is used when present (a shortlist that was persisted before the ID schema + * landed); otherwise the seed is used and becomes the persisted shortlist. + */ +fun migrateToIdBasedModelSelection( + existingShortlist: List?, + legacySelectedIndex: Int, + legacySessionModels: Map +): ModelShortlistMigration { + val seed = seedShortlistFromPresets() + val shortlist = existingShortlist ?: seed + + fun indexToId(index: Int): String? = + seed.getOrNull(migrateLegacyModelIndex(index))?.id + + val selectedModelId = indexToId(legacySelectedIndex) ?: seed.firstOrNull()?.id + val sessionModelIds = legacySessionModels.mapValues { (_, raw) -> + raw.toIntOrNull()?.let { indexToId(it) } ?: raw + } + return ModelShortlistMigration(shortlist, selectedModelId, sessionModelIds) +} \ No newline at end of file diff --git a/app/src/main/java/com/yage/opencode_client/ui/chat/ChatScreen.kt b/app/src/main/java/com/yage/opencode_client/ui/chat/ChatScreen.kt index 25703fd0..3d8fef58 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/chat/ChatScreen.kt @@ -47,6 +47,7 @@ fun ChatScreen( onNavigateToFiles: (String) -> Unit = {}, useInlineFilePreview: Boolean = false, onNavigateToSettings: () -> Unit = {}, + onManageModels: () -> Unit = {}, showSettingsButton: Boolean = true, showNewSessionInTopBar: Boolean = true, showSessionListInTopBar: Boolean = true @@ -176,6 +177,10 @@ fun ChatScreen( onRefreshSessions = viewModel::loadSessions, onToggleSessionExpanded = viewModel::toggleSessionExpanded, onSelectModel = viewModel::selectModel, + onManageModels = { + viewModel.requestModelShortlistFocus() + onManageModels() + }, onOpenAIUsage = viewModel::loadAIUsage, onRefreshAIUsage = viewModel::refreshAIUsage, onNavigateToSettings = onNavigateToSettings, diff --git a/app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt b/app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt index a6b06d41..41193df3 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt @@ -98,6 +98,7 @@ internal data class ChatTopBarActions( val onRefreshSessions: () -> Unit = {}, val onToggleSessionExpanded: (String) -> Unit = {}, val onSelectModel: (Int) -> Unit, + val onManageModels: () -> Unit = {}, val onOpenAIUsage: () -> Unit = {}, val onRefreshAIUsage: () -> Unit = {}, val onNavigateToSettings: () -> Unit = {}, @@ -266,6 +267,15 @@ internal fun ChatTopBar( } ) } + HorizontalDivider() + DropdownMenuItem( + text = { Text(stringResource(R.string.model_shortlist_manage)) }, + onClick = { + actions.onManageModels() + showModelMenu = false + }, + leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null) } + ) } } diff --git a/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt b/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt new file mode 100644 index 00000000..9a62b33d --- /dev/null +++ b/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt @@ -0,0 +1,295 @@ +package com.yage.opencode_client.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.yage.opencode_client.R +import com.yage.opencode_client.data.model.ModelShortlistItem +import com.yage.opencode_client.ui.CatalogModel +import com.yage.opencode_client.ui.MainViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModelShortlistScreen( + viewModel: MainViewModel, + onBack: () -> Unit +) { + val state by viewModel.state.collectAsStateWithLifecycle() + var showAddCatalog by remember { mutableStateOf(false) } + var editingId by remember { mutableStateOf(null) } + var editingShortName by remember { mutableStateOf("") } + + Column(modifier = Modifier.fillMaxSize()) { + TopAppBar( + title = { Text(stringResource(R.string.model_shortlist_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.common_back)) + } + }, + actions = { + IconButton(onClick = { showAddCatalog = true }) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.model_shortlist_add)) + } + } + ) + + if (state.modelShortlist.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + stringResource(R.string.model_shortlist_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .testTag("model.shortlist.list") + ) { + state.modelShortlist.forEachIndexed { index, item -> + ModelShortlistRow( + item = item, + isFirst = index == 0, + isLast = index == state.modelShortlist.size - 1, + onMoveUp = { viewModel.moveModelShortlist(index, index - 1) }, + onMoveDown = { viewModel.moveModelShortlist(index, index + 1) }, + onDelete = { viewModel.removeModelShortlistItem(item.id) }, + onEditShortName = { + editingId = item.id + editingShortName = item.shortName + } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + + editingId?.let { id -> + AlertDialog( + onDismissRequest = { editingId = null }, + title = { Text(stringResource(R.string.model_shortlist_edit_short_name)) }, + text = { + OutlinedTextField( + value = editingShortName, + onValueChange = { editingShortName = it }, + label = { Text(stringResource(R.string.model_shortlist_short_name)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + }, + confirmButton = { + Button(onClick = { + viewModel.updateModelShortlistShortName(id, editingShortName) + editingId = null + }) { Text(stringResource(R.string.settings_save)) } + }, + dismissButton = { + TextButton(onClick = { editingId = null }) { Text(stringResource(R.string.common_cancel)) } + } + ) + } + + if (showAddCatalog) { + AddModelCatalogDialog( + catalog = state.catalogModels, + providerDisplayNames = state.providerDisplayNames, + existingIds = state.modelShortlist.map { it.id }.toSet(), + onConfirm = { selected -> + viewModel.addModelsToShortlist( + selected.map { cm -> + ModelShortlistItem(cm.providerId, cm.modelId, cm.displayName, cm.shortName) + } + ) + showAddCatalog = false + }, + onDismiss = { showAddCatalog = false } + ) + } +} + +@Composable +private fun ModelShortlistRow( + item: ModelShortlistItem, + isFirst: Boolean, + isLast: Boolean, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + onDelete: () -> Unit, + onEditShortName: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag("model.shortlist.row.${item.id}"), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) { + Text(item.displayName, style = MaterialTheme.typography.bodyLarge) + Text( + item.shortName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton(onClick = onEditShortName) { + Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.model_shortlist_edit_short_name)) + } + IconButton(onClick = onMoveUp, enabled = !isFirst) { + Icon(Icons.Default.KeyboardArrowUp, contentDescription = stringResource(R.string.model_shortlist_move_up)) + } + IconButton(onClick = onMoveDown, enabled = !isLast) { + Icon(Icons.Default.KeyboardArrowDown, contentDescription = stringResource(R.string.model_shortlist_move_down)) + } + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.common_delete)) + } + } + } +} + +@Composable +private fun AddModelCatalogDialog( + catalog: List, + providerDisplayNames: Map, + existingIds: Set, + onConfirm: (List) -> Unit, + onDismiss: () -> Unit +) { + var query by remember { mutableStateOf("") } + var selected by remember { mutableStateOf>(emptySet()) } + val q = query.trim().lowercase() + val filtered = catalog.filter { cm -> + q.isEmpty() || + cm.displayName.lowercase().contains(q) || + cm.modelId.lowercase().contains(q) || + cm.providerId.lowercase().contains(q) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.model_shortlist_add_title)) }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + label = { Text(stringResource(R.string.model_shortlist_search)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(12.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 320.dp) + .verticalScroll(rememberScrollState()) + ) { + if (filtered.isEmpty()) { + Text( + stringResource(R.string.model_shortlist_no_models), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + filtered.forEach { cm -> + val inShortlist = cm.id in existingIds + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !inShortlist) { + selected = if (cm.id in selected) selected - cm.id else selected + cm.id + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = cm.id in selected, + enabled = !inShortlist, + onCheckedChange = { + selected = if (cm.id in selected) selected - cm.id else selected + cm.id + } + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(cm.displayName, style = MaterialTheme.typography.bodyMedium) + Text( + "${providerDisplayNames[cm.providerId] ?: cm.providerId} / ${cm.modelId}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (inShortlist) { + Text( + stringResource(R.string.model_shortlist_already_added), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + } + } + } + } + } + }, + confirmButton = { + Button(onClick = { onConfirm(filtered.filter { it.id in selected }) }) { + Text(stringResource(R.string.model_shortlist_add_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/yage/opencode_client/ui/settings/SettingsScreen.kt b/app/src/main/java/com/yage/opencode_client/ui/settings/SettingsScreen.kt index a034390c..100a9f59 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/settings/SettingsScreen.kt @@ -67,6 +67,7 @@ fun SettingsScreen( val context = LocalContext.current var showHostProfiles by remember { mutableStateOf(false) } + var showModelShortlist by remember { mutableStateOf(false) } var isTesting by remember { mutableStateOf(false) } var testResult by remember { mutableStateOf(null) } var aiBuilderBaseURL by remember { mutableStateOf(savedAIBuilder.baseURL) } @@ -120,6 +121,13 @@ fun SettingsScreen( } } + LaunchedEffect(state.pendingModelShortlistFocus) { + if (state.pendingModelShortlistFocus) { + showModelShortlist = true + viewModel.clearModelShortlistFocus() + } + } + if (showHostProfiles) { HostProfilesManagerScreen( viewModel = viewModel, @@ -130,6 +138,14 @@ fun SettingsScreen( return } + if (showModelShortlist) { + ModelShortlistScreen( + viewModel = viewModel, + onBack = { showModelShortlist = false } + ) + return + } + Column(modifier = Modifier.fillMaxSize()) { if (onBack != null) { TopAppBar( @@ -172,6 +188,14 @@ fun SettingsScreen( SettingsSectionDivider() + ModelShortlistEntry( + modelCount = state.modelShortlist.size, + currentModelName = state.availableModels.getOrNull(state.selectedModelIndex)?.displayName, + onManage = { showModelShortlist = true } + ) + + SettingsSectionDivider() + AIUsageDashboardSection( state = state, dashboardUrl = aiUsageDashboardUrl, diff --git a/app/src/main/java/com/yage/opencode_client/ui/settings/SettingsSections.kt b/app/src/main/java/com/yage/opencode_client/ui/settings/SettingsSections.kt index b784e318..1a787c35 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/settings/SettingsSections.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/settings/SettingsSections.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Info @@ -659,6 +660,52 @@ internal fun AboutSection() { ) } +@Composable +internal fun ModelShortlistEntry( + modelCount: Int, + currentModelName: String?, + onManage: () -> Unit +) { + SectionHeader(title = stringResource(R.string.settings_model_shortlist)) + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onManage) + .testTag("settings.model_shortlist.entry"), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.model_shortlist_count, modelCount), style = MaterialTheme.typography.titleMedium) + Text( + stringResource(R.string.settings_model_shortlist_entry), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + currentModelName?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text( + stringResource(R.string.model_shortlist_current, it), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } +} + @Composable internal fun SectionHeader(title: String) { Text( diff --git a/app/src/main/java/com/yage/opencode_client/util/SettingsManager.kt b/app/src/main/java/com/yage/opencode_client/util/SettingsManager.kt index d8e16a2d..00fc6289 100644 --- a/app/src/main/java/com/yage/opencode_client/util/SettingsManager.kt +++ b/app/src/main/java/com/yage/opencode_client/util/SettingsManager.kt @@ -5,6 +5,7 @@ import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import dagger.hilt.android.qualifiers.ApplicationContext +import com.yage.opencode_client.data.model.ModelShortlistItem import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import javax.inject.Inject @@ -77,6 +78,20 @@ class SettingsManager @Inject constructor( get() = encryptedPrefs.getInt(KEY_MODEL_INDEX, 1) set(value) = encryptedPrefs.edit().putInt(KEY_MODEL_INDEX, value).apply() + /** JSON array of [ModelShortlistItem]; the user-curated model list. */ + var modelShortlistJson: String? + get() = encryptedPrefs.getString(KEY_MODEL_SHORTLIST, null) + set(value) = encryptedPrefs.edit().putString(KEY_MODEL_SHORTLIST, value).apply() + + /** Stable "providerId/modelId" of the currently selected model. */ + var selectedModelId: String? + get() = encryptedPrefs.getString(KEY_SELECTED_MODEL_ID, null) + set(value) = encryptedPrefs.edit().putString(KEY_SELECTED_MODEL_ID, value).apply() + + var modelShortlistSchemaVersion: Int + get() = encryptedPrefs.getInt(KEY_MODEL_SHORTLIST_SCHEMA_VERSION, 0) + set(value) = encryptedPrefs.edit().putInt(KEY_MODEL_SHORTLIST_SCHEMA_VERSION, value).apply() + fun migrateRemovedGpt56SolProModelIndices() { if (encryptedPrefs.getInt(KEY_MODEL_PRESET_SCHEMA_VERSION, 0) >= MODEL_PRESET_SCHEMA_VERSION) return @@ -181,24 +196,39 @@ class SettingsManager @Inject constructor( encryptedPrefs.edit().putString(KEY_SESSION_DRAFTS, Json.encodeToString(map)).apply() } - fun getModelForSession(sessionId: String): Int? { - val json = encryptedPrefs.getString(KEY_SESSION_MODELS, null) ?: return null + /** Raw legacy "sessionId -> modelIndex" map, used only for the one-time ID migration. */ + fun getLegacySessionModels(): Map { + val json = encryptedPrefs.getString(KEY_SESSION_MODELS, null) ?: return emptyMap() + return try { + Json.decodeFromString>(json) + } catch (e: Exception) { + emptyMap() + } + } + + fun getModelIdForSession(sessionId: String): String? { + val json = encryptedPrefs.getString(KEY_SESSION_MODEL_IDS, null) ?: return null return try { - Json.decodeFromString>(json)[sessionId]?.toIntOrNull() + Json.decodeFromString>(json)[sessionId] } catch (e: Exception) { null } } - fun setModelForSession(sessionId: String, modelIndex: Int) { - val json = encryptedPrefs.getString(KEY_SESSION_MODELS, null) + fun setModelIdForSession(sessionId: String, modelId: String) { + val json = encryptedPrefs.getString(KEY_SESSION_MODEL_IDS, null) val map: MutableMap = try { json?.let { Json.decodeFromString>(it).toMutableMap() } ?: mutableMapOf() } catch (e: Exception) { mutableMapOf() } - map[sessionId] = modelIndex.toString() - encryptedPrefs.edit().putString(KEY_SESSION_MODELS, Json.encodeToString(map)).apply() + map[sessionId] = modelId + encryptedPrefs.edit().putString(KEY_SESSION_MODEL_IDS, Json.encodeToString(map)).apply() + } + + /** Bulk-replaces the "sessionId -> modelId" map (used by the one-time ID migration). */ + fun setSessionModelIds(ids: Map) { + encryptedPrefs.edit().putString(KEY_SESSION_MODEL_IDS, Json.encodeToString(ids)).apply() } fun getAgentForSession(sessionId: String): String? { @@ -254,12 +284,18 @@ class SettingsManager @Inject constructor( private const val KEY_SESSION_DRAFTS = "session_drafts" private const val KEY_SESSION_MODELS = "session_models" private const val KEY_SESSION_AGENTS = "session_agents" + private const val KEY_MODEL_SHORTLIST = "model_shortlist.v1" + private const val KEY_SELECTED_MODEL_ID = "selected_model_id" + private const val KEY_SESSION_MODEL_IDS = "session_model_ids" + private const val KEY_MODEL_SHORTLIST_SCHEMA_VERSION = "model_shortlist_schema_version" private const val KEY_NFC_ENABLED = "nfc_enabled" private const val KEY_NFC_PROMPT = "nfc_prompt" private const val KEY_NFC_AUTO_SEND = "nfc_auto_send" private const val MODEL_PRESET_SCHEMA_VERSION = 1 + const val MODEL_SHORTLIST_SCHEMA_VERSION = 2 + private fun basicAuthPasswordKey(passwordId: String): String = "basic_auth_password_$passwordId" } } diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index e30dc9d8..ccbd05fe 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -57,6 +57,23 @@ 导入 删除 刷新 + 模型 + 选择聊天选择器中显示的模型 + 模型 + 添加模型 + 列表中没有模型。点击 + 添加一个。 + 编辑简称 + 简称 + 上移 + 下移 + 添加模型 + 搜索模型 + 未找到模型 + 已添加 + 添加 + 当前:%1$s + %1$d 个模型 + 管理模型 正在打开会话… 这个 OpenCode 会话链接无效。 当前主机上找不到这个会话。它可能属于其他主机,或只存在于离线历史中。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 594acf6b..70f615b5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -57,6 +57,23 @@ Import Delete Refresh + Models + Choose which models appear in the chat picker + Models + Add models + No models in your list. Tap + to add one. + Edit short name + Short name + Move up + Move down + Add models + Search models + No models found + Added + Add + Current: %1$s + %1$d models + Manage models Opening session… This OpenCode session link is invalid. This session is not available on the current host. It may belong to another host or exist only in offline history. diff --git a/app/src/test/java/com/yage/opencode_client/AppStateTest.kt b/app/src/test/java/com/yage/opencode_client/AppStateTest.kt index 4892f51c..352cb3fc 100644 --- a/app/src/test/java/com/yage/opencode_client/AppStateTest.kt +++ b/app/src/test/java/com/yage/opencode_client/AppStateTest.kt @@ -1,7 +1,7 @@ package com.yage.opencode_client import com.yage.opencode_client.ui.AppState -import com.yage.opencode_client.ui.ModelPresets +import com.yage.opencode_client.ui.seedShortlistFromPresets import com.yage.opencode_client.data.model.* import com.yage.opencode_client.util.ThemeMode import org.junit.Assert.* @@ -153,18 +153,19 @@ class AppStateTest { } @Test - fun `availableModels returns curated presets (filtered like iOS)`() { - val state = AppState() + fun `availableModels derives from the model shortlist`() { + val shortlist = seedShortlistFromPresets() + val state = AppState(modelShortlist = shortlist) val models = state.availableModels - assertEquals(ModelPresets.list.size, models.size) - assertEquals(ModelPresets.list, models) + assertEquals(shortlist.size, models.size) assertEquals("GLM-5.3", models[0].displayName) assertEquals("zai-coding-plan", models[0].providerId) assertEquals("glm-5.3", models[0].modelId) assertEquals("GPT-5.6 Sol", models[1].displayName) assertEquals("openai", models[1].providerId) assertEquals("gpt-5.6-sol", models[1].modelId) + assertEquals("GPT", models[1].shortName) assertFalse(models.any { it.providerId == "openai" && it.modelId == "gpt-5.6-sol-pro" }) assertFalse(models.any { it.providerId == "openai" && it.modelId == "gpt-5.6-sol-fast" }) assertTrue(models.any { @@ -173,16 +174,23 @@ class AppStateTest { assertTrue(models.any { it.displayName == "GPT-5.6 Luna" && it.providerId == "openai" && it.modelId == "gpt-5.6-luna" }) - assertFalse(models.any { it.modelId == "deepseek-v4-flash:0731" }) - assertFalse(models.any { it.providerId == "deepseek" && it.modelId == "deepseek-v4-pro" }) + } + + @Test + fun `availableModels is empty when the shortlist is empty`() { + val state = AppState() + assertTrue(state.availableModels.isEmpty()) } @Test fun `availableModels independent of providers`() { - val stateWithProviders = AppState(providers = makeProviders(Triple("openai", "gpt-4", "GPT-4"))) - val stateWithoutProviders = AppState(providers = null) + val shortlist = seedShortlistFromPresets() + val stateWithProviders = AppState( + providers = makeProviders(Triple("openai", "gpt-4", "GPT-4")), + modelShortlist = shortlist + ) + val stateWithoutProviders = AppState(providers = null, modelShortlist = shortlist) assertEquals(stateWithProviders.availableModels, stateWithoutProviders.availableModels) - assertEquals(ModelPresets.list, stateWithProviders.availableModels) } private fun makeContextUsageState( diff --git a/app/src/test/java/com/yage/opencode_client/ForkSessionTest.kt b/app/src/test/java/com/yage/opencode_client/ForkSessionTest.kt index 83a01e61..00413504 100644 --- a/app/src/test/java/com/yage/opencode_client/ForkSessionTest.kt +++ b/app/src/test/java/com/yage/opencode_client/ForkSessionTest.kt @@ -3,6 +3,8 @@ package com.yage.opencode_client import android.util.Log import com.yage.opencode_client.data.model.Session import com.yage.opencode_client.data.model.HostProfile +import com.yage.opencode_client.data.model.ProviderRegistryResponse +import com.yage.opencode_client.data.model.ProvidersResponse import com.yage.opencode_client.data.repository.HostProfileStore import com.yage.opencode_client.data.repository.OpenCodeRepository import com.yage.opencode_client.ssh.SSHKeyManager @@ -99,8 +101,8 @@ class ForkSessionTest { every { settingsManager.getDraftText(any()) } returns "" every { settingsManager.setDraftText(any(), any()) } just runs - every { settingsManager.getModelForSession(any()) } returns null - every { settingsManager.setModelForSession(any(), any()) } just runs + every { settingsManager.getModelIdForSession(any()) } returns null + every { settingsManager.getLegacySessionModels() } returns emptyMap() every { settingsManager.getAgentForSession(any()) } returns null every { settingsManager.setAgentForSession(any(), any()) } just runs @@ -108,6 +110,8 @@ class ForkSessionTest { coEvery { repository.getSessionStatus() } returns Result.success(emptyMap()) coEvery { repository.getMessages(any(), any()) } returns Result.success(emptyList()) coEvery { repository.getPendingPermissions() } returns Result.success(emptyList()) + coEvery { repository.getProviders() } returns Result.success(ProvidersResponse()) + coEvery { repository.getProviderRegistry() } returns Result.success(ProviderRegistryResponse()) } @After diff --git a/app/src/test/java/com/yage/opencode_client/MainViewModelTest.kt b/app/src/test/java/com/yage/opencode_client/MainViewModelTest.kt index 6e91ccba..c50b2d12 100644 --- a/app/src/test/java/com/yage/opencode_client/MainViewModelTest.kt +++ b/app/src/test/java/com/yage/opencode_client/MainViewModelTest.kt @@ -14,6 +14,9 @@ import com.yage.opencode_client.data.model.SSEPayload import com.yage.opencode_client.data.model.HealthResponse import com.yage.opencode_client.data.model.HostProfile import com.yage.opencode_client.data.model.HostTransport +import com.yage.opencode_client.data.model.ModelShortlistItem +import com.yage.opencode_client.data.model.ProviderRegistryResponse +import com.yage.opencode_client.data.model.ProvidersResponse import com.yage.opencode_client.data.repository.HostProfileStore import com.yage.opencode_client.data.repository.OpenCodeRepository import com.yage.opencode_client.ssh.SSHKeyManager @@ -22,6 +25,8 @@ import com.yage.opencode_client.ui.AppState import com.yage.opencode_client.ui.DeepLinkError import com.yage.opencode_client.ui.MainViewModel import com.yage.opencode_client.ui.ModelPresets +import com.yage.opencode_client.ui.encodeShortlist +import com.yage.opencode_client.ui.seedShortlistFromPresets import com.yage.opencode_client.ui.session.buildSessionTree import com.yage.opencode_client.util.SettingsManager import com.yage.opencode_client.util.ThemeMode @@ -136,8 +141,8 @@ class MainViewModelTest { every { settingsManager.getDraftText(any()) } returns "" every { settingsManager.setDraftText(any(), any()) } just runs - every { settingsManager.getModelForSession(any()) } returns null - every { settingsManager.setModelForSession(any(), any()) } just runs + every { settingsManager.getModelIdForSession(any()) } returns null + every { settingsManager.getLegacySessionModels() } returns emptyMap() every { settingsManager.getAgentForSession(any()) } returns null every { settingsManager.setAgentForSession(any(), any()) } just runs @@ -146,6 +151,8 @@ class MainViewModelTest { coEvery { repository.getSessionStatus() } returns Result.success(emptyMap()) coEvery { repository.getMessages(any(), any()) } returns Result.success(emptyList()) coEvery { repository.getPendingPermissions() } returns Result.success(emptyList()) + coEvery { repository.getProviders() } returns Result.success(ProvidersResponse()) + coEvery { repository.getProviderRegistry() } returns Result.success(ProviderRegistryResponse()) } private fun createViewModel(): MainViewModel { @@ -352,13 +359,13 @@ class MainViewModelTest { } @Test - fun `init clamps saved model index and configures repository`() = runTest { + fun `init seeds model shortlist and configures repository`() = runTest { every { settingsManager.selectedModelIndex } returns 999 val viewModel = createViewModel() - assertEquals(ModelPresets.list.lastIndex, viewModel.state.value.selectedModelIndex) - verify { settingsManager.selectedModelIndex = ModelPresets.list.lastIndex } + assertEquals(seedShortlistFromPresets().size, viewModel.state.value.modelShortlist.size) + assertTrue(viewModel.state.value.selectedModelIndex in viewModel.state.value.modelShortlist.indices) verify { repository.configure("http://server.test", null, null) } } @@ -2033,13 +2040,68 @@ class MainViewModelTest { } @Test - fun `selectModel with active session saves model index per session`() = runTest { + fun `selectModel with active session saves model id per session`() = runTest { val viewModel = createViewModel() updateState(viewModel) { it.copy(currentSessionId = "s1") } viewModel.selectModel(2) - verify { settingsManager.setModelForSession("s1", 2) } + val expectedId = seedShortlistFromPresets()[2].id + verify { settingsManager.setModelIdForSession("s1", expectedId) } + } + + @Test + fun `moveModelShortlist reorders and persists`() = runTest { + val viewModel = createViewModel() + val before = viewModel.state.value.modelShortlist + assertTrue(before.size >= 2) + + viewModel.moveModelShortlist(0, 1) + + val after = viewModel.state.value.modelShortlist + assertEquals(before[1], after[0]) + assertEquals(before[0], after[1]) + verify { settingsManager.modelShortlistJson = any() } + } + + @Test + fun `removeModelShortlistItem removes and reanchors selection`() = runTest { + val viewModel = createViewModel() + val before = viewModel.state.value.modelShortlist + val targetId = before[0].id + + viewModel.removeModelShortlistItem(targetId) + + val after = viewModel.state.value.modelShortlist + assertEquals(before.size - 1, after.size) + assertFalse(after.any { it.id == targetId }) + assertTrue(viewModel.state.value.selectedModelIndex in after.indices) + } + + @Test + fun `updateModelShortlistShortName edits the short name`() = runTest { + val viewModel = createViewModel() + val targetId = viewModel.state.value.modelShortlist[0].id + + viewModel.updateModelShortlistShortName(targetId, "Custom") + + val updated = viewModel.state.value.modelShortlist.first { it.id == targetId } + assertEquals("Custom", updated.shortName) + } + + @Test + fun `addModelsToShortlist appends catalog models and dedups`() = runTest { + val viewModel = createViewModel() + val beforeSize = viewModel.state.value.modelShortlist.size + val existing = viewModel.state.value.modelShortlist[0] + + viewModel.addModelsToShortlist( + listOf(ModelShortlistItem("anthropic", "claude-x", "Claude X", "Claude"), existing) + ) + + val after = viewModel.state.value.modelShortlist + assertEquals(beforeSize + 1, after.size) + assertTrue(after.any { it.id == "anthropic/claude-x" }) } @Test @@ -2068,7 +2130,7 @@ class MainViewModelTest { } @Test - fun `loadMessages uses per-session saved model index over message inference`() = runTest { + fun `loadMessages uses per-session saved model id over message inference`() = runTest { val inferredPreset = ModelPresets.list[2] val messages = listOf( MessageWithParts( @@ -2080,7 +2142,8 @@ class MainViewModelTest { ) ) coEvery { repository.getMessages("session-1", 30) } returns Result.success(messages) - every { settingsManager.getModelForSession("session-1") } returns 3 + val savedId = seedShortlistFromPresets()[3].id + every { settingsManager.getModelIdForSession("session-1") } returns savedId val viewModel = createViewModel() updateState(viewModel) { it.copy(currentSessionId = "session-1") } diff --git a/app/src/test/java/com/yage/opencode_client/ModelShortlistTest.kt b/app/src/test/java/com/yage/opencode_client/ModelShortlistTest.kt new file mode 100644 index 00000000..e0671f42 --- /dev/null +++ b/app/src/test/java/com/yage/opencode_client/ModelShortlistTest.kt @@ -0,0 +1,213 @@ +package com.yage.opencode_client + +import com.yage.opencode_client.data.model.ConfigProvider +import com.yage.opencode_client.data.model.ModelShortlistItem +import com.yage.opencode_client.data.model.ProviderModel +import com.yage.opencode_client.data.model.ProviderModelCapabilities +import com.yage.opencode_client.data.model.ProviderModelOutput +import com.yage.opencode_client.ui.CatalogModel +import com.yage.opencode_client.ui.addModelToShortlist +import com.yage.opencode_client.ui.buildCatalog +import com.yage.opencode_client.ui.decodeShortlist +import com.yage.opencode_client.ui.encodeShortlist +import com.yage.opencode_client.ui.moveShortlistItem +import com.yage.opencode_client.ui.migrateToIdBasedModelSelection +import com.yage.opencode_client.ui.refreshShortlistDisplayNames +import com.yage.opencode_client.ui.reanchorSelectedModelIndex +import com.yage.opencode_client.ui.removeShortlistItem +import com.yage.opencode_client.ui.seedShortlistFromPresets +import com.yage.opencode_client.ui.suggestedShortName +import com.yage.opencode_client.ui.updateShortlistShortName +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ModelShortlistTest { + + private fun item( + providerId: String = "openai", + modelId: String = "gpt-5.6-sol", + displayName: String = "GPT-5.6 Sol", + shortName: String = "GPT" + ) = ModelShortlistItem(providerId, modelId, displayName, shortName) + + @Test + fun `suggestedShortName applies known mappings`() { + assertEquals("DS-L", suggestedShortName("DeepSeek Local")) + assertEquals("GPT", suggestedShortName("GPT-5.6 Sol")) + assertEquals("Gemini", suggestedShortName("Gemini 3.7 Flash")) + assertEquals("Grok", suggestedShortName("Grok 4.6")) + assertEquals("Qwen", suggestedShortName("Qwen 3.8 27B")) + } + + @Test + fun `suggestedShortName falls back to first word for unknown names`() { + assertEquals("My", suggestedShortName("My Custom Model")) + assertEquals("GLM-5.3", suggestedShortName("GLM-5.3")) + } + + @Test + fun `seedShortlistFromPresets has nine entries with stable ids`() { + val seed = seedShortlistFromPresets() + assertEquals(9, seed.size) + assertEquals("zai-coding-plan/glm-5.3", seed[0].id) + assertEquals("openai/gpt-5.6-sol", seed[1].id) + assertEquals("google/gemini-3.7-flash", seed[2].id) + assertTrue(seed.all { it.shortName.isNotEmpty() }) + } + + @Test + fun `buildCatalog scopes to connected providers and chat-capable models`() { + val providers = listOf( + ConfigProvider( + id = "openai", + name = "OpenAI", + models = mapOf( + "gpt-5.6-sol" to ProviderModel(id = "gpt-5.6-sol", name = "GPT-5.6 Sol"), + "embed-1" to ProviderModel( + id = "embed-1", + name = "Embed", + capabilities = ProviderModelCapabilities(output = ProviderModelOutput(text = false)) + ) + ) + ), + ConfigProvider( + id = "disconnected", + name = "Disconnected", + models = mapOf("m1" to ProviderModel(id = "m1", name = "M1")) + ) + ) + val result = buildCatalog(providers, connectedProviderIds = setOf("openai")) + + assertEquals(listOf("openai/gpt-5.6-sol"), result.models.map { it.id }) + assertEquals(mapOf("openai" to "OpenAI"), result.providerDisplayNames) + } + + @Test + fun `buildCatalog includes all providers when connected set is null`() { + val providers = listOf( + ConfigProvider(id = "a", models = mapOf("m" to ProviderModel(id = "m", name = "M"))), + ConfigProvider(id = "b", models = mapOf("m" to ProviderModel(id = "m", name = "M"))) + ) + val result = buildCatalog(providers, connectedProviderIds = null) + assertEquals(2, result.models.size) + } + + @Test + fun `buildCatalog sorts by provider then display name`() { + val providers = listOf( + ConfigProvider( + id = "openai", + models = mapOf( + "zeta" to ProviderModel(id = "zeta", name = "Zeta"), + "alpha" to ProviderModel(id = "alpha", name = "Alpha") + ) + ) + ) + val result = buildCatalog(providers, connectedProviderIds = setOf("openai")) + assertEquals(listOf("openai/alpha", "openai/zeta"), result.models.map { it.id }) + } + + @Test + fun `addModelToShortlist appends new model and skips duplicates`() { + val shortlist = listOf(item()) + val (added, changed) = addModelToShortlist(shortlist, "google", "gemini-3.7-flash", "Gemini 3.7 Flash") + assertTrue(changed) + assertEquals(2, added.size) + assertEquals("google/gemini-3.7-flash", added[1].id) + + val (unchanged, changedAgain) = addModelToShortlist(added, "google", "gemini-3.7-flash", "Gemini 3.7 Flash") + assertFalse(changedAgain) + assertEquals(added, unchanged) + } + + @Test + fun `removeShortlistItem removes by id`() { + val shortlist = listOf(item(), item("google", "gemini-3.7-flash", "Gemini 3.7 Flash", "Gemini")) + val next = removeShortlistItem(shortlist, "google/gemini-3.7-flash") + assertEquals(1, next.size) + assertEquals("openai/gpt-5.6-sol", next[0].id) + } + + @Test + fun `moveShortlistItem reorders and ignores out-of-range indices`() { + val shortlist = listOf( + item("openai", "a", "A", "A"), + item("openai", "b", "B", "B"), + item("openai", "c", "C", "C") + ) + val moved = moveShortlistItem(shortlist, 0, 2) + assertEquals(listOf("openai/b", "openai/c", "openai/a"), moved.map { it.id }) + assertEquals(shortlist, moveShortlistItem(shortlist, 0, -1)) + assertEquals(shortlist, moveShortlistItem(shortlist, 0, 5)) + } + + @Test + fun `updateShortlistShortName edits and falls back to suggested when blank`() { + val shortlist = listOf(item()) + val edited = updateShortlistShortName(shortlist, "openai/gpt-5.6-sol", " Sol ") + assertEquals("Sol", edited[0].shortName) + + val blank = updateShortlistShortName(shortlist, "openai/gpt-5.6-sol", " ") + assertEquals("GPT", blank[0].shortName) + } + + @Test + fun `refreshShortlistDisplayNames updates display name but keeps short name`() { + val shortlist = listOf(item(displayName = "Old Name", shortName = "MyCustom")) + val catalog = listOf(CatalogModel("openai", "gpt-5.6-sol", "New Name", "GPT")) + val refreshed = refreshShortlistDisplayNames(shortlist, catalog) + assertEquals("New Name", refreshed[0].displayName) + assertEquals("MyCustom", refreshed[0].shortName) + } + + @Test + fun `reanchorSelectedModelIndex finds id or defaults to zero`() { + val shortlist = listOf( + item("openai", "a", "A", "A"), + item("openai", "b", "B", "B") + ) + assertEquals(1, reanchorSelectedModelIndex(shortlist, "openai/b")) + assertEquals(0, reanchorSelectedModelIndex(shortlist, "openai/missing")) + assertEquals(0, reanchorSelectedModelIndex(shortlist, null)) + assertEquals(0, reanchorSelectedModelIndex(emptyList(), "openai/a")) + } + + @Test + fun `migrate seeds shortlist and maps legacy index to id`() { + val migration = migrateToIdBasedModelSelection( + existingShortlist = null, + legacySelectedIndex = 2, + legacySessionModels = mapOf("s1" to "1", "s2" to "not-a-number") + ) + assertEquals(9, migration.shortlist.size) + assertEquals("google/gemini-3.7-flash", migration.selectedModelId) + assertEquals(mapOf("s1" to "openai/gpt-5.6-sol", "s2" to "not-a-number"), migration.sessionModelIds) + } + + @Test + fun `migrate keeps existing shortlist and falls back to first preset for bad index`() { + val existing = listOf(item("zai-coding-plan", "glm-5.3", "GLM-5.3", "GLM-5.3")) + val migration = migrateToIdBasedModelSelection(existing, 999, emptyMap()) + assertEquals(existing, migration.shortlist) + assertEquals("zai-coding-plan/glm-5.3", migration.selectedModelId) + } + + @Test + fun `migrate applies legacy index remapping`() { + // Legacy index 6 used to be GPT-5.6 Sol Pro; it now maps to index 1 (GPT-5.6 Sol). + val migration = migrateToIdBasedModelSelection(null, 6, emptyMap()) + assertEquals("openai/gpt-5.6-sol", migration.selectedModelId) + } + + @Test + fun `encode and decode shortlist round trips`() { + val shortlist = listOf(item(), item("google", "gemini-3.7-flash", "Gemini 3.7 Flash", "Gemini")) + val json = encodeShortlist(shortlist) + assertEquals(shortlist, decodeShortlist(json)) + assertNull(decodeShortlist(null)) + assertNull(decodeShortlist("not-json")) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/yage/opencode_client/NfcQuickPromptTest.kt b/app/src/test/java/com/yage/opencode_client/NfcQuickPromptTest.kt index 80eb7c32..519b2176 100644 --- a/app/src/test/java/com/yage/opencode_client/NfcQuickPromptTest.kt +++ b/app/src/test/java/com/yage/opencode_client/NfcQuickPromptTest.kt @@ -3,6 +3,7 @@ package com.yage.opencode_client import android.util.Log import com.yage.opencode_client.data.model.Session import com.yage.opencode_client.data.model.HostProfile +import com.yage.opencode_client.data.model.ProviderRegistryResponse import com.yage.opencode_client.data.model.ProvidersResponse import com.yage.opencode_client.data.repository.HostProfileStore import com.yage.opencode_client.data.repository.OpenCodeRepository @@ -97,8 +98,8 @@ class NfcQuickPromptTest { every { settingsManager.getDraftText(any()) } returns "" every { settingsManager.setDraftText(any(), any()) } just runs - every { settingsManager.getModelForSession(any()) } returns null - every { settingsManager.setModelForSession(any(), any()) } just runs + every { settingsManager.getModelIdForSession(any()) } returns null + every { settingsManager.getLegacySessionModels() } returns emptyMap() every { settingsManager.getAgentForSession(any()) } returns null every { settingsManager.setAgentForSession(any(), any()) } just runs @@ -107,6 +108,7 @@ class NfcQuickPromptTest { coEvery { repository.getSessions(any()) } returns Result.success(emptyList()) coEvery { repository.getAgents() } returns Result.success(emptyList()) coEvery { repository.getProviders() } returns Result.success(ProvidersResponse()) + coEvery { repository.getProviderRegistry() } returns Result.success(ProviderRegistryResponse()) coEvery { repository.sendMessage(any(), any(), any(), any(), any(), any()) } returns Result.success(Unit) } diff --git a/docs/PRD.md b/docs/PRD.md index 7a1ec140..5f84cd49 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -128,7 +128,7 @@ Android Client 提供以下核心能力: - Todo:在 tool 卡片内展示任务列表 ✅ - **流式显示**:SSE delta 增量累积,text/reasoning 均支持打字机效果 ✅ - **自动跟随**:当用户停留在底部时,新的消息 / tool call / 流式更新自动跟随;离开底部时保持当前位置 ✅ -- **模型选择**:从 `/provider` API 动态获取,TopBar 下拉菜单 ✅ +- **模型选择**:TopBar 下拉菜单显示用户维护的模型短名单(可增删/排序/改短名),候选目录从 `/provider` 注册表动态生成,见"模型列表管理" ✅ - **Agent 选择**:从 `/agent` API 动态获取 ✅ - **Context Usage**:环形进度显示上下文占用(绿/橙/红三色),AI 回答中也始终可见 ✅ - **权限审批**:手动批准/拒绝 permission 请求 ✅ @@ -165,6 +165,18 @@ Android Client 提供以下核心能力: 模型和 Agent 的选择按 sessionID 存储。切换 session 时,优先从持久化存储恢复该 session 上次的选择;若无记录则从最后一条 assistant message 推断(当前已有此逻辑);推断不到时保持全局默认值。 +#### 模型列表管理(Model Shortlist,对齐 iOS) + +聊天模型下拉框只显示用户维护的"模型短名单",不再硬编码。设置页新增"模型列表"入口(带数量角标),进入短名单管理页可增删、上移/下移排序、编辑短名;"添加模型"从服务器 `/provider` 注册表动态生成的候选目录里搜索并多选加入。 + +- **短名单**(持久化):聊天 picker 只读这个列表。首启播种当前 9 个默认模型(存量迁移,非空)。 +- **候选目录**(运行时):每次连接后从 `GET /provider` 重建,只取 connected 的 provider 与 chat-capable 的 model;`/provider` 不可用时降级到 `config/providers`(不做 connected 过滤)。 +- **选择态按 model ID 持久化**(`providerId/modelId`),不再按列表下标——短名单可变(顺序/成员)后 index 会错位。一次性幂等迁移 + schema version 保护,现有用户无感。 +- **自动添加**:切换 session 恢复的模型不在短名单但在目录里 → 自动加入;加载消息历史时最后一条 assistant 消息用的模型不在 picker 里 → 作为 ad-hoc 条目自动加入(保证 toolbar 显示 session 实际在跑的模型)。 +- **displayName 跟随目录刷新**(server 改名后自动同步),用户自定义短名不动。 +- **重排用"上移/下移"**(不做拖拽);删除无"至少保留一个"约束,空短名单是合法状态。 +- **聊天 picker 底部**有"管理模型"跳转行,可深链到设置页短名单管理并高亮。 + #### 消息历史分页(Phase 5b — Bug 修复) 当前滚动到消息列表顶部(最早的消息)时无法加载更多历史消息。`loadMoreMessages()` 后端已实现(增大 limit 参数重新拉取),但 UI 滚动检测逻辑方向反转:`reverseLayout = true` 下,列表视觉顶部对应高索引,而当前检测逻辑在低索引处触发,实际效果是在最新消息处触发加载而非最旧消息处。需修复检测方向并在列表顶部增加 loading 指示器。 @@ -335,6 +347,7 @@ Session 搜索继续由 Agent 和 semantic-search 负责,客户端不建设搜 | Chat Toolbar 布局 | 左 Session 操作 / 右 Model+Agent+Context | ✅ Phase 5 完成 | 两行布局已对齐 | | 草稿持久化 | 按 Session 存储 | ✅ Phase 5 完成 | JSON Map in EncryptedSharedPreferences | | Model/Agent 按 Session 记忆 | 按 Session 存储 | ✅ Phase 5 完成 | per-session > 推断 > 全局 | +| 模型列表管理(Shortlist) | 短名单 + /provider 动态 catalog + 拖拽重排 | ✅ 已对齐(上移/下移重排,无拖拽) | 短名单/ID 持久化/自动添加/displayName 跟随对齐;重排用上下移(iOS 拖拽 bug 独立 follow-up) | | Session Rename UI | Toolbar pencil 按钮 | ✅ Phase 5 完成 | AlertDialog 已实现 | | Model/Agent 文本显示 | Capsule 按钮含模型名 | ✅ Phase 5b 完成 | 模型名 + Agent 名文本化显示 | | 消息历史分页 | pull-to-refresh | Phase 5b 修复 | 当前 Android 滚动检测方向反转 | @@ -355,6 +368,7 @@ Session 搜索继续由 Agent 和 semantic-search 负责,客户端不建设搜 | 3 | Bug 修复、Markdown 渲染、模型选择、Context Usage、主题、平板布局 | ✅ 完成 (2026-03-02) | | 5 | UX 对齐 iOS:Chat toolbar 重排、Session Rename UI、草稿持久化、Model/Agent per-session 记忆 | ✅ 完成 (2026-03-14) | | 5b | 消息历史分页修复、Model/Agent 文本化 Capsule、平板 toolbar 适配、消息模型标注 | 🔲 进行中 | +| 5c | 模型列表管理(Model Shortlist):短名单 + 动态 catalog + ID 持久化迁移 + 管理 UI | ✅ 完成 (2026-09-04) | | 6 | 语音输入 realtime recovery:立即 PCM capture、本地 cache、session attach/replay、断线恢复 | ✅ 完成 (2026-05-25) | | 7 | Markdown Web Preview、Native/Web/Source 三态、平板 Sessions pane 折叠 | 🔲 规划中 (2026-06-14) | | 8 | Host Profiles、SSH Tunnel、iOS import/export parity、分阶段连接诊断 | 🔲 规划中 (2026-06-21) | @@ -385,6 +399,7 @@ Session 搜索继续由 Agent 和 semantic-search 负责,客户端不建设搜 5. **大型 Session**:暂不考虑性能优化,不预期 session 超过百条消息。 6. **后台 SSE 连接**:不保持。App 进入后台时断开 SSE,回到前台时通过 REST 全量同步 + 重建 SSE 恢复。 7. **SSH Tunnel**:Android 端进入 Phase 8 实现,目标是与 iOS 完成 Host Profiles + SSH Tunnel feature parity。底层采用 JSch app 内 local forward,不使用系统 VPN,不依赖 Termux/OpenSSH,不承诺后台永久保活。 +8. **模型列表管理**:聊天 picker 只显示用户维护的短名单,候选目录从 `/provider` 动态生成。按 model ID 持久化选择态(非 index)。首启播种 9 个默认模型(存量迁移,非 iOS 的空短名单)。重排用"上移/下移"不做拖拽(iOS 拖拽 bug 是独立 follow-up)。退役模型直接从短名单消失,不做静默 ID 替换。 --- diff --git a/docs/RFC.md b/docs/RFC.md index 1cfa3edc..8534ce16 100644 --- a/docs/RFC.md +++ b/docs/RFC.md @@ -564,6 +564,97 @@ fun setAgentForSession(sessionId: String, agentName: String) { /* 同上模式 * **全局默认值保留**:SettingsManager 中原有的全局 `selectedModelIndex` 和 `selectedAgentName` 继续保留,作为新 session 或无 per-session 记录时的 fallback。 +### 4.5 Model Shortlist(模型短名单,对齐 iOS) + +**背景**:此前模型列表硬编码在 `ui/ModelPresets.kt`(9 个模型),`availableModels` 直接返回该列表,增删模型需改代码重编译;服务器 `config/providers` 全量列表只用于 context/AI usage,不喂给模型下拉框。本节把 iOS 的"模型短名单"机制移植到 Android:聊天下拉框只显示用户维护的短名单,设置里可增删/排序/改短名,候选目录从服务器 `/provider` 注册表动态生成。同时把 §4.4 的**模型**选择持久化从 index 升级为 model ID(Agent 记忆不变)。 + +**两个列表的分工(核心概念)**: + +| 列表 | 来源 | 用途 | +|---|---|---| +| `modelShortlist`(持久化) | 用户维护 + 自动添加 | 聊天模型下拉框**只读这个**(`availableModels = modelShortlist.map { it.toModelOption() }`) | +| `catalogModels`(运行时) | `GET /provider` → 只取 `connected` 的 provider、只取 chat-capable 的 model,按 provider+name 排序 | 设置里"添加模型"的候选目录 | + +**数据模型**(`data/model/`): + +```kotlin +// 持久化单元(对应 iOS ModelShortlistItem) +@Serializable +data class ModelShortlistItem( + val providerId: String, + val modelId: String, + val displayName: String, + val shortName: String +) { + val id: String get() = "$providerId/$modelId" + fun toModelOption() = AppState.ModelOption(shortName, providerId, modelId, customShortName = shortName) +} + +// /provider 响应(对应 iOS ProviderRegistryResponse) +@Serializable +data class ProviderRegistryResponse( + val all: List = emptyList(), + @SerialName("default") val defaultByProvider: Map = emptyMap(), + val connected: List = emptyList() +) + +// ProviderModel 新增可选 capabilities(chat-capable 过滤) +@Serializable +data class ProviderModelCapabilities(val output: ProviderModelOutput? = null) +@Serializable +data class ProviderModelOutput(val text: Boolean? = null) +// ProviderModel 加字段:val capabilities: ProviderModelCapabilities? = null +``` + +**持久化**(`SettingsManager`,全走 `EncryptedSharedPreferences`): + +| Key | 类型 | 说明 | +|---|---|---| +| `model_shortlist.v1` | JSON `List` | 短名单本体 | +| `selected_model_id` | String? | 全局当前选择(model ID,替代 `model_index` 语义) | +| `session_model_ids` | JSON `Map` | 替代 `session_models`(index 版) | +| `model_shortlist_schema_version` | Int | bump 到 `2` | + +**一次性迁移**(`migrateModelSelectionToIds`,`applySavedSettings` 调用,schema version 保护、幂等): +1. 读旧 `model_index` → 用**当前硬编码** `ModelPresets.list[index]` 解析出 model ID → 写 `selected_model_id`。 +2. 读旧 `session_models`(index 版)→ 逐条同样解析 → 写 `session_model_ids`。 +3. **短名单播种**:`model_shortlist.v1` 不存在时,用当前 9 个 `ModelPresets` 初始化(决策 D1)。 +4. bump `model_shortlist_schema_version = 2`。 + +> 与 iOS 差异:iOS 首启短名单为空(从零建);Android 是存量迁移,播种 9 个默认项保证现有用户无感(已存 index 选择正好解析到播种进短名单的同一批 ID)。 + +**API**(`OpenCodeApi` / `OpenCodeRepository`): + +```kotlin +@GET("provider") +suspend fun getProviderRegistry(): ProviderRegistryResponse +``` + +- Repository `getProviderRegistry(): Result`,走 REST client。 +- **降级**:`/provider` 失败(老服务器无该路由)时,用现有 `getProviders()`(`config/providers`)构建 catalog,且**不做 connected 过滤**(视为全部 connected)。catalog 构建失败则保持旧 catalog 不动(对齐 iOS"注册表不可用则 catalog 不变")。 + +**Catalog 构建**(纯函数 `buildCatalog`,`ui/ModelShortlist.kt`):只取 `connected` 集合内的 provider;跳过 `capabilities.output.text == false` 的 model(null/缺省视为 chat-capable);`displayName = model.name ?: modelId`;`shortName` 复用 `suggestedShortName` 推断;按 providerId、再按 displayName 排序。同时产出 `providerDisplayNames`(providerId → 人类可读名)。 + +**AppState / ViewModel 行为**: +- `AppState` 新增 `modelShortlist`、`catalogModels`、`providerDisplayNames`、`selectedModelId`、`pendingModelShortlistFocus`;`ModelOption` 新增 `customShortName`(`shortName` getter 委托 `suggestedShortName`)。 +- `availableModels` getter 改为 `modelShortlist.map { it.toModelOption() }`(不再读 `ModelPresets.list`;`ModelPresets` 降级为播种 + 迁移解析的唯一用途,保留文件)。 +- `reanchorSelectedModelIndex()`:任何短名单变动(增/删/移/改)后调用——在 `availableModels` 里找当前选中 model ID 的下标,找不到回落 0。 +- `selectModel(index)`:持久化 **model ID**(`selected_model_id` + `session_model_ids[currentSession]`),不再存 index。 +- `selectSession`:按 ID 恢复;saved ID 不在短名单但在 catalog → 自动加入短名单再选中。 +- `launchLoadMessages` 推断:按 `providerId/modelId` 在短名单里 `firstIndex`;找不到且 session 有 assistant 消息 → ad-hoc 加入短名单(displayName 从 `providerModelsIndex` 取)。 +- catalog 刷新时机:`loadProviders()`(连接成功 / host 切换后)拉 `getProviderRegistry()` → `buildCatalog` → 更新 `catalogModels`/`providerDisplayNames` → `refreshShortlistDisplayNames`(短名单 displayName 跟随 catalog,shortName 不动)→ `reanchorSelectedModelIndex`。 +- 短名单操作(ViewModel 方法,全部 = 改 state + 写 prefs + reanchor):`addModelsToShortlist`(去重 by id)、`removeModelShortlistItem`、`moveModelShortlist`、`updateModelShortlistShortName`(空值回落 `suggestedShortName`)。 + +**决策(已确认)**: +- **D1**:首启播种当前 9 个 `ModelPresets`(存量迁移,非 iOS 的空短名单)。 +- **D2**:按 model ID 持久化选择态(短名单可变后 index 必然错位;一次性幂等迁移 + schema version 保护)。 +- **D3**:重排用"上移/下移",不做拖拽(iOS 拖拽 bug 是独立低优先级 follow-up)。 +- **D4**:`/provider` 端点 + `config/providers` 降级(不做 connected 过滤)。 +- **D5**:不做 ongoing canonical ID 老化映射;退役模型直接从短名单消失。 +- **D6**:displayName 跟随 catalog 刷新;用户自定义 shortName 不动。 + +**不受影响**:`selectedAIUsageQuota` 的 provider 映射(走 `availableModels.getOrNull(selectedModelIndex)?.providerId`,reanchor 正确则自动正确);乐观发送 / SSE / 网络层 / NFC / deep link / 语音。 + --- ## 5. UI 设计 @@ -1041,6 +1132,15 @@ Chat Markdown 在 `WorkspaceMarkdownLinkResolver` 之前拦截 `opencode` scheme 安全边界与 iOS 一致:当前 Host only,不轮询其他 Host,不恢复离线 archive DB,不接受 server/凭证/prompt/tool action,不自动执行 Markdown link。测试覆盖 parser contract、repository by-ID path、断连 pending、成功 hydration、失败保留上下文和 session-window preservation;系统 cold/warm Intent 的 emulator E2E 作为后续可选 Tier 3,不在物理设备执行。 +### 5.14 Model Shortlist 管理 UI(对齐 iOS) + +- **设置页入口**(`SettingsScreen.kt`):`AppearanceSection` 之后新增一行 `ModelShortlistEntry`(带 `modelShortlist.size` 数量角标 + `ChevronRight`),复用 `HostProfilesManagerScreen` 的子页面模式(`showModelShortlist` state 切换,渲染 `ModelShortlistScreen(viewModel, onBack)`)。 +- **短名单页**(新 `ui/settings/ModelShortlistScreen.kt`):TopAppBar 标题 "模型列表",actions 里 "+" → catalog picker。行对齐 `HostProfileRow` 视觉语言(卡片 + 右侧 MoreVert 菜单):主行 displayName,副行 `providerDisplayNames[providerId] ?: providerId / modelId`。重排用 MoreVert 菜单里的"上移/下移"(决策 D3,不做拖拽);MoreVert 还有"编辑短名 / 删除"(删除直接执行,空短名单是合法状态)。点行主体 → 编辑短名 dialog(`AlertDialog` + `OutlinedTextField`)。空态:提示文案 + 醒目"添加模型"按钮。 +- **Catalog picker**(同文件 `AddModelCatalogDialog`):搜索框(displayName/modelId/providerId 三字段过滤)+ 多选 checkbox(排除已在短名单的)+ 底部"添加所选 (n)"(0 选禁用)。catalog 为空时显示"未获取到模型目录"。 +- **聊天 picker**(`ChatTopBar.kt`):DropdownMenu 底部加 "Manage models" 跳转行(`onManageModels` → `requestModelShortlistFocus()` + 跳设置);`availableModels.isEmpty()` 时显示"去设置添加模型"跳转行。 +- **深链**:`pendingModelShortlistFocus` state + `SettingsScreen` 的 `LaunchedEffect` 自动打开短名单页并高亮,聊天 picker 的跳转行能直接落到短名单页。 +- **文案**:`values/strings.xml` + `values-zh/strings.xml` 各新增 17 条(`settings_model_shortlist`、`model_shortlist_*` 等)。 + --- ## 6. 安全设计 @@ -1170,6 +1270,7 @@ app/ | 3 | 文件树、Markdown / 图片预览、Diff、平板布局 | 已完成 | | 5 | UX 对齐 iOS:Chat toolbar 重排(§5.4)、Session Rename UI、草稿持久化(§4.3)、Model/Agent per-session(§4.4) | ✅ 完成 | | 5b | 消息历史分页修复(§5.5)、Model/Agent Capsule 文本化(§5.6)、平板 toolbar 适配(§5.7)、消息模型标注(§5.8) | 1-2 天 | +| 5c | Model Shortlist(§4.5、§5.14):模型短名单 + 动态 catalog + ID 持久化迁移 + 管理 UI | ✅ 完成 | | 7 | Markdown Web Preview(§5.10)、Tablet Sessions pane 折叠(§5.11) | 2-4 天 | | 4 | SSH Tunnel(可选) | 1 周 | diff --git a/docs/working.md b/docs/working.md index 655ab9e5..f634b49a 100644 --- a/docs/working.md +++ b/docs/working.md @@ -650,3 +650,35 @@ iOS/Android feature parity 调研完成,确认以下体验层差异需要对 - 验证:`testDebugUnitTest` 308 tests 全部通过;新增 session switch、stale typewriter、Grok cancel-and-join、active retry discard join、PCM saturation tests。VoiceFlowKit JitPack 已 pin 到 feature commit `54141fbb46ae495c6787a5de9800a30cae085f3d`,Kit 发布后、合并前替换为 exact `0.4.0`。 - `compileDebugKotlin`、`compileDebugUnitTestKotlin`、`assembleDebug` 通过。`compileDebugAndroidTestKotlin` 仍被既有 `ReadToolCardIntegrationTest.kt:151` 阻塞:调用缺少 `completedTurnActivities` 和 `onMarkdownLinkClick`;该错误与 speech 改动无关,本轮未扩大 scope 修复。 - 本地 composite sibling `:voiceflowkit:testDebugUnitTest` 全部通过,确认 host mutex memoization 与 Kit 当前 atomic `AudioDisposition` termination contract 兼容。 + +## 2026-09-04: Model Shortlist(模型短名单,对齐 iOS) + +把 iOS 已落地的"模型短名单"机制移植到 Android:聊天模型下拉框只显示用户维护的短名单,设置里可增删/排序/改短名,候选目录从服务器 `/provider` 注册表动态生成。同时把模型选择持久化从 index 升级为 model ID。设计见 RFC §4.5 / §5.14,功能见 PRD"模型列表管理"。 + +**数据 + API** +- `data/model/ModelShortlist.kt`(新):`ModelShortlistItem(providerId, modelId, displayName, shortName)`,`id = "providerId/modelId"`。 +- `data/model/Config.kt`:新增 `ProviderRegistryResponse(all, defaultByProvider, connected)`、`ProviderModelCapabilities(output)`、`ProviderModelOutput(text)`,`ProviderModel` 加 `capabilities` 字段。 +- `OpenCodeApi.getProviderRegistry()`(`@GET("provider")`)+ `OpenCodeRepository.getProviderRegistry(): Result<...>`。 + +**持久化 + 迁移**(`SettingsManager`) +- 新 key:`model_shortlist.v1`(JSON List)、`selected_model_id`、`session_model_ids`、`model_shortlist_schema_version`(bump 到 2)。 +- 一次性 `migrateModelSelectionToIds`(`applySavedSettings` 调用,schema version 保护、幂等):旧 `model_index`/`session_models`(index 版)用当前 `ModelPresets` 解析成 model ID 写入新 key;`model_shortlist.v1` 不存在时播种当前 9 个 `ModelPresets`(决策 D1)。删除死代码 `getModelForSession`/`setModelForSession`。 + +**纯函数**(`ui/ModelShortlist.kt`,新) +- `buildCatalog`(connected 过滤 + chat-capable 过滤 + 排序 + providerDisplayNames)、`buildProviderModelsIndex`、`seedShortlistFromPresets`、`addModelToShortlist`/`removeShortlistItem`/`moveShortlistItem`/`updateShortlistShortName`/`refreshShortlistDisplayNames`、`reanchorSelectedModelIndex`、`migrateToIdBasedModelSelection`、`encodeShortlist`/`decodeShortlist`、`suggestedShortName`(抽出的共享推断)。 + +**ViewModel 接线** +- `AppState`:`ModelOption.customShortName`;新增 `modelShortlist`/`catalogModels`/`providerDisplayNames`/`selectedModelId`/`pendingModelShortlistFocus`;`availableModels` getter 改源为短名单。 +- `selectModel` 改按 ID 持久化;`selectSession`/`launchLoadMessages` 按 ID 恢复 + ad-hoc 自动加入;`loadProviders` 拉注册表 → 建 catalog(含 `config/providers` 降级)→ 刷 displayName → reanchor。 +- 新增短名单操作方法 + `requestModelShortlistFocus`/`clearModelShortlistFocus`。 + +**UI** +- `ui/settings/ModelShortlistScreen.kt`(新):管理页(上移/下移、编辑短名 dialog、删除、`AddModelCatalogDialog` 搜索 + 多选)。 +- `SettingsSections.ModelShortlistEntry` + `SettingsScreen` 入口行 + `LaunchedEffect` 深链自动打开。 +- `ChatTopBar` 加 `onManageModels` + "Manage models" 跳转行;`ChatScreen`/`MainActivity` 接线(两处 ChatScreen 调用)。 +- 文案:`values/strings.xml` + `values-zh/strings.xml` 各 17 条。 + +**验证** +- `compileDebugKotlin`、`compileDebugUnitTestKotlin` 通过。 +- `testDebugUnitTest` 348 tests 全部通过(新增 `ModelShortlistTest` 16 个纯函数测试;更新 `AppStateTest`/`MainViewModelTest`/`ForkSessionTest`/`NfcQuickPromptTest` 的 stub 与模型选择测试为 ID 版)。 +- `lintDebug`:本 feature 0 新增 error(16 个 `MissingTranslation` 已补中文);剩余 3 个 error 为 master 既有(`MainViewModelSupport.kt` 的 SuspiciousIndentation / LocalContextGetResourceValueCall / ByteOrderMark),不在本分支 diff 内。 From 0e16f4ad883cc740d789d05aab5dff1159ab6c11 Mon Sep 17 00:00:00 2001 From: Yan Wang Date: Thu, 3 Sep 2026 20:10:46 -0700 Subject: [PATCH 2/6] fix(models): address subagent review findings on model shortlist A reasoning_gpt code review of the shortlist PR surfaced 2 P0 and 6 P1 bugs; all are fixed here. - Migration no longer double-applies the legacy index remap: the stored index is already normalized by migrateRemovedGpt56SolProModelIndices() before the ID migration runs, so map it straight onto the seed. - Seed the shortlist only when the key is genuinely absent; a present-but undecodable list is left on disk untouched instead of being clobbered by the defaults and sealed at schema 2. - launchLoadMessages now auto-adds the session's saved model (not just the history-inferred one) when it's missing from the shortlist, so the selected ID and index can't split and send a different model. - removeModelShortlistItem persists the fallback selection to both the global and per-session stores; the selectedModelId setter now clears the key on null (putString(null) was a no-op) and a removeModelIdForSession helper is added. - resolveModelCatalog returns null when both /provider and config/providers fail, so a previously loaded catalog is preserved instead of wiped. - The migration drops malformed/out-of-range legacy per-session values (mapNotNull) instead of writing them as permanent invalid IDs. - Catalog picker confirm uses the full catalog (cross-search selections no longer lost), shows the count, and is disabled when empty. - Tablet "Manage models" now also expands a collapsed Sessions pane so the deep-link is actually consumed. - Shortlist rows show the stable provider/modelId; buildCatalog falls back to the model id for blank names. Tests: 3 new regression tests (remove-current-selection persistence, missing saved-ID auto-add, blank model name). 351 unit tests pass; lint adds no new errors. --- .../com/yage/opencode_client/MainActivity.kt | 8 +- .../yage/opencode_client/ui/MainViewModel.kt | 14 +++- .../ui/MainViewModelConnectionActions.kt | 13 ++- .../ui/MainViewModelSessionActions.kt | 82 +++++++++++-------- .../yage/opencode_client/ui/ModelShortlist.kt | 22 +++-- .../ui/settings/ModelShortlistScreen.kt | 11 ++- .../opencode_client/util/SettingsManager.kt | 17 +++- app/src/main/res/values-zh/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../yage/opencode_client/MainViewModelTest.kt | 41 ++++++++++ .../opencode_client/ModelShortlistTest.kt | 36 ++++++-- docs/PRD.md | 2 +- docs/RFC.md | 2 +- docs/working.md | 18 +++- 14 files changed, 209 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/com/yage/opencode_client/MainActivity.kt b/app/src/main/java/com/yage/opencode_client/MainActivity.kt index b91270e2..35995211 100644 --- a/app/src/main/java/com/yage/opencode_client/MainActivity.kt +++ b/app/src/main/java/com/yage/opencode_client/MainActivity.kt @@ -373,7 +373,13 @@ private fun BoxScope.DeepLinkFeedback( private fun TabletLayout(viewModel: MainViewModel) { var selectedTab by remember { mutableIntStateOf(0) } var sessionsPaneCollapsed by rememberSaveable { mutableStateOf(false) } - val onOpenSettings: () -> Unit = { selectedTab = 1 } + // Opening Settings (e.g. from the chat "Manage models" jump) must also expand + // the left pane, otherwise the Settings screen isn't composed when the Sessions + // pane is collapsed and the pending model-shortlist focus is never consumed. + val onOpenSettings: () -> Unit = { + sessionsPaneCollapsed = false + selectedTab = 1 + } val state by viewModel.state.collectAsStateWithLifecycle() val filesWeight = if (sessionsPaneCollapsed) 0.5f else 0.375f val chatWeight = if (sessionsPaneCollapsed) 0.5f else 0.375f diff --git a/app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt b/app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt index 21c2c5f0..99db8bef 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt @@ -1787,8 +1787,20 @@ class MainViewModel @Inject constructor( val next = removeShortlistItem(current, id) if (next == current) return settingsManager.modelShortlistJson = encodeShortlist(next) + // If the removed model was the current selection, fall back to the first + // remaining model (or none) and persist that to both the global selection + // and the current session, so a restart doesn't re-read the deleted ID. + val wasSelected = _state.value.selectedModelId == id + val selectedId = if (wasSelected) next.firstOrNull()?.id else _state.value.selectedModelId + if (wasSelected) { + settingsManager.selectedModelId = next.firstOrNull()?.id + _state.value.currentSessionId?.let { sessionId -> + val fallbackId = next.firstOrNull()?.id + if (fallbackId != null) settingsManager.setModelIdForSession(sessionId, fallbackId) + else settingsManager.removeModelIdForSession(sessionId) + } + } _state.update { - val selectedId = if (it.selectedModelId == id) null else it.selectedModelId it.copy( modelShortlist = next, selectedModelId = selectedId, diff --git a/app/src/main/java/com/yage/opencode_client/ui/MainViewModelConnectionActions.kt b/app/src/main/java/com/yage/opencode_client/ui/MainViewModelConnectionActions.kt index a10d80f9..e8c1bc98 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/MainViewModelConnectionActions.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/MainViewModelConnectionActions.kt @@ -62,12 +62,21 @@ internal fun applySavedSettings( */ internal fun migrateModelSelectionToIds(settingsManager: SettingsManager) { if (settingsManager.modelShortlistSchemaVersion >= SettingsManager.MODEL_SHORTLIST_SCHEMA_VERSION) return + val rawShortlistJson = settingsManager.modelShortlistJson + // Seed only when the shortlist key is genuinely absent. When the key is + // present but undecodable, keep the on-disk value untouched (don't clobber a + // user's list with the defaults) and fall back to the seed in-memory. + val existingShortlist = rawShortlistJson?.let { decodeShortlist(it) } val migration = migrateToIdBasedModelSelection( - existingShortlist = decodeShortlist(settingsManager.modelShortlistJson), + existingShortlist = existingShortlist, legacySelectedIndex = settingsManager.selectedModelIndex, legacySessionModels = settingsManager.getLegacySessionModels() ) - settingsManager.modelShortlistJson = encodeShortlist(migration.shortlist) + // Persist the resolved shortlist only when we have a real one (absent -> seed, + // present -> kept). A present-but-malformed list is left on disk as-is. + if (rawShortlistJson == null || existingShortlist != null) { + settingsManager.modelShortlistJson = encodeShortlist(migration.shortlist) + } migration.selectedModelId?.let { settingsManager.selectedModelId = it } settingsManager.setSessionModelIds(migration.sessionModelIds) settingsManager.modelShortlistSchemaVersion = SettingsManager.MODEL_SHORTLIST_SCHEMA_VERSION diff --git a/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt b/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt index b900d937..6764ef7c 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt @@ -209,26 +209,37 @@ internal fun launchLoadMessages( val lastAssistant = messages.lastOrNull { it.info.isAssistant } val inferredModel = lastAssistant?.info?.resolvedModel val inferredAgentName = lastAssistant?.info?.agent - val targetModelId = settingsManager?.getModelIdForSession(sessionId) - ?: inferredModel?.let { "${it.providerId}/${it.modelId}" } + val savedModelId = settingsManager?.getModelIdForSession(sessionId) + val inferredModelId = inferredModel?.let { "${it.providerId}/${it.modelId}" } + val sessionModelId = savedModelId ?: inferredModelId val agentName = settingsManager?.getAgentForSession(sessionId) ?: inferredAgentName - // Surface the session's actual model in the shortlist when it's - // missing (provider disconnected, or a dynamic model), mirroring iOS. + // Ensure the session's effective model (saved, or inferred from + // history) is present in the shortlist. The saved ID may be + // missing (the user removed it, or it predates the shortlist); + // the inferred model may be a dynamic/ad-hoc one. Without this, + // a saved ID absent from the shortlist would reanchor to index 0 + // while selectedModelId still points elsewhere, so the send path + // would use a different model than the state claims. Mirrors iOS + // applySavedModelForCurrentSession + syncModelFromMessageHistory. var nextShortlist = state.value.modelShortlist - if (inferredModel != null) { - val fullId = "${inferredModel.providerId}/${inferredModel.modelId}" - val displayName = buildProviderModelsIndex(state.value.providers)[fullId]?.name - ?: inferredModel.modelId - val (added, changed) = addModelToShortlist( - nextShortlist, inferredModel.providerId, inferredModel.modelId, displayName - ) - if (changed) { - nextShortlist = added - settingsManager?.modelShortlistJson = encodeShortlist(nextShortlist) + if (sessionModelId != null) { + val slash = sessionModelId.indexOf('/') + if (slash > 0) { + val providerId = sessionModelId.substring(0, slash) + val modelId = sessionModelId.substring(slash + 1) + val displayName = buildProviderModelsIndex(state.value.providers)[sessionModelId]?.name + ?: modelId + val (added, changed) = addModelToShortlist( + nextShortlist, providerId, modelId, displayName + ) + if (changed) { + nextShortlist = added + settingsManager?.modelShortlistJson = encodeShortlist(nextShortlist) + } } } - val effectiveModelId = targetModelId ?: state.value.selectedModelId + val effectiveModelId = sessionModelId ?: state.value.selectedModelId val modelIndex = reanchorSelectedModelIndex(nextShortlist, effectiveModelId) state.update { @@ -338,20 +349,22 @@ internal fun launchLoadProviders( // Build the model catalog from /provider (connected-scoped), falling back // to config/providers (unscoped) when the registry is unavailable (D4). - val (catalogModels, providerDisplayNames) = resolveModelCatalog(repository, providersResult) - - // Refresh shortlist display names from the catalog; short names are kept (D6). - val refreshed = refreshShortlistDisplayNames(state.value.modelShortlist, catalogModels) - if (refreshed != state.value.modelShortlist) { - settingsManager?.modelShortlistJson = encodeShortlist(refreshed) - } - state.update { - it.copy( - catalogModels = catalogModels, - providerDisplayNames = providerDisplayNames, - modelShortlist = refreshed, - selectedModelIndex = reanchorSelectedModelIndex(refreshed, it.selectedModelId) - ) + // When both endpoints fail, keep the previously loaded catalog (D4). + val resolvedCatalog = resolveModelCatalog(repository, providersResult) + if (resolvedCatalog != null) { + // Refresh shortlist display names from the catalog; short names are kept (D6). + val refreshed = refreshShortlistDisplayNames(state.value.modelShortlist, resolvedCatalog.models) + if (refreshed != state.value.modelShortlist) { + settingsManager?.modelShortlistJson = encodeShortlist(refreshed) + } + state.update { + it.copy( + catalogModels = resolvedCatalog.models, + providerDisplayNames = resolvedCatalog.providerDisplayNames, + modelShortlist = refreshed, + selectedModelIndex = reanchorSelectedModelIndex(refreshed, it.selectedModelId) + ) + } } } } @@ -359,19 +372,18 @@ internal fun launchLoadProviders( private suspend fun resolveModelCatalog( repository: OpenCodeRepository, providersResult: Result -): Pair, Map> { +): CatalogBuildResult? { val registryResult = repository.getProviderRegistry() if (registryResult.isSuccess) { val registry = registryResult.getOrThrow() - val built = buildCatalog(registry.all, registry.connectedProviderIds) - return built.models to built.providerDisplayNames + return buildCatalog(registry.all, registry.connectedProviderIds) } val providers = providersResult.getOrNull() if (providers != null) { - val built = buildCatalog(providers.providers, null) - return built.models to built.providerDisplayNames + return buildCatalog(providers.providers, null) } - return emptyList() to emptyMap() + // Both endpoints failed: return null so the caller keeps the existing catalog. + return null } internal fun launchCreateSession( diff --git a/app/src/main/java/com/yage/opencode_client/ui/ModelShortlist.kt b/app/src/main/java/com/yage/opencode_client/ui/ModelShortlist.kt index dd2fbe85..10154aab 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/ModelShortlist.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/ModelShortlist.kt @@ -4,7 +4,6 @@ import com.yage.opencode_client.data.model.ConfigProvider import com.yage.opencode_client.data.model.ModelShortlistItem import com.yage.opencode_client.data.model.ProviderModel import com.yage.opencode_client.data.model.ProvidersResponse -import com.yage.opencode_client.util.migrateLegacyModelIndex import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -98,7 +97,7 @@ fun buildCatalog( provider.name?.takeIf { it.isNotEmpty() }?.let { names[provider.id] = it } for ((modelId, model) in provider.models.toSortedMap()) { if (!(model.capabilities?.isChatCapable ?: true)) continue - val displayName = model.name ?: modelId + val displayName = model.name?.takeIf { it.isNotBlank() } ?: modelId models.add(CatalogModel(provider.id, modelId, displayName, suggestedShortName(displayName))) } } @@ -205,12 +204,21 @@ fun migrateToIdBasedModelSelection( val seed = seedShortlistFromPresets() val shortlist = existingShortlist ?: seed - fun indexToId(index: Int): String? = - seed.getOrNull(migrateLegacyModelIndex(index))?.id + // The legacy index is already normalized to the current ModelPresets.list + // order by migrateRemovedGpt56SolProModelIndices() before this runs, so map + // it straight onto the seed. Re-applying the legacy remap here would shift an + // already-migrated index one slot further (e.g. 7 -> 6 -> 1). + fun indexToId(index: Int): String? = seed.getOrNull(index)?.id val selectedModelId = indexToId(legacySelectedIndex) ?: seed.firstOrNull()?.id - val sessionModelIds = legacySessionModels.mapValues { (_, raw) -> - raw.toIntOrNull()?.let { indexToId(it) } ?: raw - } + // Only migrate per-session entries that parse to an in-range index. A + // malformed or out-of-range legacy value is dropped so it can't become a + // permanent invalid selection; message history / the global selection take + // over for that session instead. + val sessionModelIds = legacySessionModels.entries + .mapNotNull { (sessionId, raw) -> + raw.toIntOrNull()?.let { indexToId(it) }?.let { id -> sessionId to id } + } + .toMap() return ModelShortlistMigration(shortlist, selectedModelId, sessionModelIds) } \ No newline at end of file diff --git a/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt b/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt index 9a62b33d..1655335d 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt @@ -95,6 +95,7 @@ fun ModelShortlistScreen( state.modelShortlist.forEachIndexed { index, item -> ModelShortlistRow( item = item, + providerSubtitle = "${state.providerDisplayNames[item.providerId] ?: item.providerId} / ${item.modelId}", isFirst = index == 0, isLast = index == state.modelShortlist.size - 1, onMoveUp = { viewModel.moveModelShortlist(index, index - 1) }, @@ -157,6 +158,7 @@ fun ModelShortlistScreen( @Composable private fun ModelShortlistRow( item: ModelShortlistItem, + providerSubtitle: String, isFirst: Boolean, isLast: Boolean, onMoveUp: () -> Unit, @@ -179,7 +181,7 @@ private fun ModelShortlistRow( Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) { Text(item.displayName, style = MaterialTheme.typography.bodyLarge) Text( - item.shortName, + providerSubtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -284,8 +286,11 @@ private fun AddModelCatalogDialog( } }, confirmButton = { - Button(onClick = { onConfirm(filtered.filter { it.id in selected }) }) { - Text(stringResource(R.string.model_shortlist_add_confirm)) + Button( + onClick = { onConfirm(catalog.filter { it.id in selected }) }, + enabled = selected.isNotEmpty() + ) { + Text(stringResource(R.string.model_shortlist_add_confirm, selected.size)) } }, dismissButton = { diff --git a/app/src/main/java/com/yage/opencode_client/util/SettingsManager.kt b/app/src/main/java/com/yage/opencode_client/util/SettingsManager.kt index 00fc6289..d9cbd5e2 100644 --- a/app/src/main/java/com/yage/opencode_client/util/SettingsManager.kt +++ b/app/src/main/java/com/yage/opencode_client/util/SettingsManager.kt @@ -86,7 +86,9 @@ class SettingsManager @Inject constructor( /** Stable "providerId/modelId" of the currently selected model. */ var selectedModelId: String? get() = encryptedPrefs.getString(KEY_SELECTED_MODEL_ID, null) - set(value) = encryptedPrefs.edit().putString(KEY_SELECTED_MODEL_ID, value).apply() + set(value) = encryptedPrefs.edit().apply { + if (value == null) remove(KEY_SELECTED_MODEL_ID) else putString(KEY_SELECTED_MODEL_ID, value) + }.apply() var modelShortlistSchemaVersion: Int get() = encryptedPrefs.getInt(KEY_MODEL_SHORTLIST_SCHEMA_VERSION, 0) @@ -226,6 +228,19 @@ class SettingsManager @Inject constructor( encryptedPrefs.edit().putString(KEY_SESSION_MODEL_IDS, Json.encodeToString(map)).apply() } + /** Removes a single session's saved model ID (e.g. when its model is deleted). */ + fun removeModelIdForSession(sessionId: String) { + val json = encryptedPrefs.getString(KEY_SESSION_MODEL_IDS, null) ?: return + val map: MutableMap = try { + Json.decodeFromString>(json).toMutableMap() + } catch (e: Exception) { + return + } + if (map.remove(sessionId) != null) { + encryptedPrefs.edit().putString(KEY_SESSION_MODEL_IDS, Json.encodeToString(map)).apply() + } + } + /** Bulk-replaces the "sessionId -> modelId" map (used by the one-time ID migration). */ fun setSessionModelIds(ids: Map) { encryptedPrefs.edit().putString(KEY_SESSION_MODEL_IDS, Json.encodeToString(ids)).apply() diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index ccbd05fe..33dcd3ab 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -70,7 +70,7 @@ 搜索模型 未找到模型 已添加 - 添加 + 添加 (%1$d) 当前:%1$s %1$d 个模型 管理模型 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 70f615b5..c0811eb4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -70,7 +70,7 @@ Search models No models found Added - Add + Add (%1$d) Current: %1$s %1$d models Manage models diff --git a/app/src/test/java/com/yage/opencode_client/MainViewModelTest.kt b/app/src/test/java/com/yage/opencode_client/MainViewModelTest.kt index c50b2d12..8f583d44 100644 --- a/app/src/test/java/com/yage/opencode_client/MainViewModelTest.kt +++ b/app/src/test/java/com/yage/opencode_client/MainViewModelTest.kt @@ -2078,6 +2078,27 @@ class MainViewModelTest { assertTrue(viewModel.state.value.selectedModelIndex in after.indices) } + @Test + fun `removeModelShortlistItem of current selection falls back and persists`() = runTest { + val viewModel = createViewModel() + val before = viewModel.state.value.modelShortlist + val targetId = before[0].id + // Make the first model the active selection on a live session. + updateState(viewModel) { it.copy(currentSessionId = "s1", selectedModelId = targetId, selectedModelIndex = 0) } + + viewModel.removeModelShortlistItem(targetId) + + val after = viewModel.state.value.modelShortlist + assertEquals(before.size - 1, after.size) + // Selection falls back to the new first item and is persisted globally and + // per-session, so a restart doesn't re-read the deleted id. + val fallbackId = after.first().id + assertEquals(fallbackId, viewModel.state.value.selectedModelId) + assertEquals(0, viewModel.state.value.selectedModelIndex) + verify { settingsManager.selectedModelId = fallbackId } + verify { settingsManager.setModelIdForSession("s1", fallbackId) } + } + @Test fun `updateModelShortlistShortName edits the short name`() = runTest { val viewModel = createViewModel() @@ -2154,6 +2175,26 @@ class MainViewModelTest { assertEquals(3, viewModel.state.value.selectedModelIndex) } + @Test + fun `loadMessages auto-adds a saved model missing from the shortlist`() = runTest { + // A saved model that is NOT in the seeded shortlist (e.g. the user removed + // it, or it predates the shortlist). Loading the session must re-add it so + // the selected id and index stay consistent (no ID/index split). + val savedId = "anthropic/claude-x" + every { settingsManager.getModelIdForSession("session-1") } returns savedId + + val viewModel = createViewModel() + updateState(viewModel) { it.copy(currentSessionId = "session-1") } + + viewModel.loadMessages("session-1") + advanceUntilIdle() + + assertTrue(viewModel.state.value.modelShortlist.any { it.id == savedId }) + assertEquals(savedId, viewModel.state.value.selectedModelId) + val idx = viewModel.state.value.modelShortlist.indexOfFirst { it.id == savedId } + assertEquals(idx, viewModel.state.value.selectedModelIndex) + } + @Test fun `abortSession calls repository for current session`() = runTest { coEvery { repository.abortSession("session-1") } returns Result.success(Unit) diff --git a/app/src/test/java/com/yage/opencode_client/ModelShortlistTest.kt b/app/src/test/java/com/yage/opencode_client/ModelShortlistTest.kt index e0671f42..6659fcdd 100644 --- a/app/src/test/java/com/yage/opencode_client/ModelShortlistTest.kt +++ b/app/src/test/java/com/yage/opencode_client/ModelShortlistTest.kt @@ -110,6 +110,24 @@ class ModelShortlistTest { assertEquals(listOf("openai/alpha", "openai/zeta"), result.models.map { it.id }) } + @Test + fun `buildCatalog falls back to model id when the name is blank or null`() { + val providers = listOf( + ConfigProvider( + id = "openai", + models = mapOf( + "blank-name" to ProviderModel(id = "blank-name", name = " "), + "null-name" to ProviderModel(id = "null-name", name = null) + ) + ) + ) + val result = buildCatalog(providers, connectedProviderIds = setOf("openai")) + val byId = result.models.associateBy { it.id } + assertEquals("blank-name", byId["openai/blank-name"]?.displayName) + assertEquals("null-name", byId["openai/null-name"]?.displayName) + assertTrue(result.models.all { it.shortName.isNotEmpty() }) + } + @Test fun `addModelToShortlist appends new model and skips duplicates`() { val shortlist = listOf(item()) @@ -180,11 +198,13 @@ class ModelShortlistTest { val migration = migrateToIdBasedModelSelection( existingShortlist = null, legacySelectedIndex = 2, - legacySessionModels = mapOf("s1" to "1", "s2" to "not-a-number") + legacySessionModels = mapOf("s1" to "1", "s2" to "not-a-number", "s3" to "999") ) assertEquals(9, migration.shortlist.size) assertEquals("google/gemini-3.7-flash", migration.selectedModelId) - assertEquals(mapOf("s1" to "openai/gpt-5.6-sol", "s2" to "not-a-number"), migration.sessionModelIds) + // Only in-range numeric legacy indices migrate; malformed / out-of-range + // values are dropped so they can't become permanent invalid selections. + assertEquals(mapOf("s1" to "openai/gpt-5.6-sol"), migration.sessionModelIds) } @Test @@ -196,10 +216,14 @@ class ModelShortlistTest { } @Test - fun `migrate applies legacy index remapping`() { - // Legacy index 6 used to be GPT-5.6 Sol Pro; it now maps to index 1 (GPT-5.6 Sol). - val migration = migrateToIdBasedModelSelection(null, 6, emptyMap()) - assertEquals("openai/gpt-5.6-sol", migration.selectedModelId) + fun `migrate maps an already-normalized index straight to the seed`() { + // By the time this runs, migrateRemovedGpt56SolProModelIndices() has already + // normalized the stored index to the current ModelPresets.list order, so the + // index maps straight onto the seed (a second remap would shift it further). + val luna = migrateToIdBasedModelSelection(null, 6, emptyMap()) + assertEquals("openai/gpt-5.6-luna", luna.selectedModelId) + val grok = migrateToIdBasedModelSelection(null, 7, emptyMap()) + assertEquals("xai/grok-4.6", grok.selectedModelId) } @Test diff --git a/docs/PRD.md b/docs/PRD.md index 5f84cd49..12f8b625 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -175,7 +175,7 @@ Android Client 提供以下核心能力: - **自动添加**:切换 session 恢复的模型不在短名单但在目录里 → 自动加入;加载消息历史时最后一条 assistant 消息用的模型不在 picker 里 → 作为 ad-hoc 条目自动加入(保证 toolbar 显示 session 实际在跑的模型)。 - **displayName 跟随目录刷新**(server 改名后自动同步),用户自定义短名不动。 - **重排用"上移/下移"**(不做拖拽);删除无"至少保留一个"约束,空短名单是合法状态。 -- **聊天 picker 底部**有"管理模型"跳转行,可深链到设置页短名单管理并高亮。 +- **聊天 picker 底部**有"管理模型"跳转行,深链直接落到设置页短名单管理子页。 #### 消息历史分页(Phase 5b — Bug 修复) diff --git a/docs/RFC.md b/docs/RFC.md index 8534ce16..faed22b4 100644 --- a/docs/RFC.md +++ b/docs/RFC.md @@ -1138,7 +1138,7 @@ Chat Markdown 在 `WorkspaceMarkdownLinkResolver` 之前拦截 `opencode` scheme - **短名单页**(新 `ui/settings/ModelShortlistScreen.kt`):TopAppBar 标题 "模型列表",actions 里 "+" → catalog picker。行对齐 `HostProfileRow` 视觉语言(卡片 + 右侧 MoreVert 菜单):主行 displayName,副行 `providerDisplayNames[providerId] ?: providerId / modelId`。重排用 MoreVert 菜单里的"上移/下移"(决策 D3,不做拖拽);MoreVert 还有"编辑短名 / 删除"(删除直接执行,空短名单是合法状态)。点行主体 → 编辑短名 dialog(`AlertDialog` + `OutlinedTextField`)。空态:提示文案 + 醒目"添加模型"按钮。 - **Catalog picker**(同文件 `AddModelCatalogDialog`):搜索框(displayName/modelId/providerId 三字段过滤)+ 多选 checkbox(排除已在短名单的)+ 底部"添加所选 (n)"(0 选禁用)。catalog 为空时显示"未获取到模型目录"。 - **聊天 picker**(`ChatTopBar.kt`):DropdownMenu 底部加 "Manage models" 跳转行(`onManageModels` → `requestModelShortlistFocus()` + 跳设置);`availableModels.isEmpty()` 时显示"去设置添加模型"跳转行。 -- **深链**:`pendingModelShortlistFocus` state + `SettingsScreen` 的 `LaunchedEffect` 自动打开短名单页并高亮,聊天 picker 的跳转行能直接落到短名单页。 +- **深链**:`pendingModelShortlistFocus` state + `SettingsScreen` 的 `LaunchedEffect` 一次性打开短名单子页(跳转即直接落到目标页,无需额外高亮;消费后清除,不重复触发)。tablet 折叠 Sessions 左栏时,设置跳转同时展开左栏,否则 `SettingsScreen` 不会 composition、pending 无法消费。 - **文案**:`values/strings.xml` + `values-zh/strings.xml` 各新增 17 条(`settings_model_shortlist`、`model_shortlist_*` 等)。 --- diff --git a/docs/working.md b/docs/working.md index f634b49a..26344679 100644 --- a/docs/working.md +++ b/docs/working.md @@ -681,4 +681,20 @@ iOS/Android feature parity 调研完成,确认以下体验层差异需要对 **验证** - `compileDebugKotlin`、`compileDebugUnitTestKotlin` 通过。 - `testDebugUnitTest` 348 tests 全部通过(新增 `ModelShortlistTest` 16 个纯函数测试;更新 `AppStateTest`/`MainViewModelTest`/`ForkSessionTest`/`NfcQuickPromptTest` 的 stub 与模型选择测试为 ID 版)。 -- `lintDebug`:本 feature 0 新增 error(16 个 `MissingTranslation` 已补中文);剩余 3 个 error 为 master 既有(`MainViewModelSupport.kt` 的 SuspiciousIndentation / LocalContextGetResourceValueCall / ByteOrderMark),不在本分支 diff 内。 +- `lintDebug`:本 feature 0 新增 error(16 个 `MissingTranslation` 已补中文);剩余 3 个 error 为 master 既有(`SuspiciousIndentation`@MainActivity、`LocalContextGetResourceValueCall`@ChatScreen、`ByteOrderMark`@MainViewModelSupport),不在本分支 diff 内。 + +**Subagent review(reasoning_gpt)+ 修复(同日)** + +开 PR #107 后跑了一个 `reasoning_gpt` 全量代码审查(diff `master..feat/model-shortlist`),逐条核对后确认 2 个 P0、6 个 P1 为真实 bug,全部修复: + +- **P0 迁移重复 remap**:`applySavedSettings` 先跑 `migrateRemovedGpt56SolProModelIndices()`(把 index 归一化到当前 preset 顺序并落盘),`migrateToIdBasedModelSelection` 又对已归一化的 index 再套一次 `migrateLegacyModelIndex`,导致 index 6/7 迁到错误 ID(如 Grok→Luna)。修复:`indexToId` 直接 `seed.getOrNull(index)`,不再二次 remap。 +- **P0 播种覆盖坏数据**:`decodeShortlist` 对"key 不存在"和"JSON 解码失败"都返回 null,迁移把两者都当"无短名单"用默认 9 项覆盖并封存 schema 2。修复:只在 key 真不存在时播种;存在但解码失败则保留磁盘原值、内存回落到 seed、不覆盖。 +- **P1 saved ID 不在短名单不自动加入**:`launchLoadMessages` 只自动加入消息历史推断的模型,saved ID 缺失时 reanchor 回落到 0 而 `selectedModelId` 仍指向缺失 ID,发送路径按 index 取到另一个模型(ID/index 分裂)。修复:统一按 session 的有效模型(saved 优先,否则推断)补加入短名单再 reanchor。 +- **P1 删除当前选中不落盘**:`removeModelShortlistItem` 只改内存,未更新全局 `selected_model_id` 与当前 session map,重启会重新读到已删 ID。修复:删除当前选中时回落到新首项并同步写全局 + 当前 session;`selectedModelId` setter 改为 null 时 `remove` key(原 `putString(null)` 是 no-op),新增 `removeModelIdForSession`。 +- **P1 双端点失败清空 catalog**:`resolveModelCatalog` 在 `/provider` 与 `config/providers` 都失败时返回空并覆盖旧 catalog。修复:改为返回 nullable,双失败返回 null、调用方保留旧 catalog。 +- **P1 迁移写入非法 session 值**:旧 map 的非数字/越界值用 `?: raw` 原样写进 ID map。修复:改 `mapNotNull`,只迁移能解析且在 seed 范围内的值,非法项丢弃。 +- **P1 catalog picker 跨搜索多选丢失**:确认按钮只提交当前过滤结果。修复:用完整 `catalog` 过滤、按钮显示选中数、空选禁用。 +- **P1 tablet 折叠时深链失效**:折叠 Sessions 左栏时"管理模型"只置 `selectedTab=1`,左栏不 composition、pending 不消费。修复:`onOpenSettings` 同时展开左栏。 +- **P2**:短名单行副行改为稳定的 `provider / modelId`(原只显示 shortName,同名模型无法区分);`buildCatalog` 对空白 model name 回落 modelId;RFC/PRD 去掉"高亮"措辞(深链直接落到子页,无需高亮)。 + +**复验**:`testDebugUnitTest` 351 tests 全部通过(新增 3 个回归测试:删除当前选中回落落盘、saved ID 缺失自动加入、blank model name 回落);`lintDebug` 仍 0 新增 error。 From 221f9d7f66da9b6ca757b0ab3204c70f069b929c Mon Sep 17 00:00:00 2001 From: Yan Wang Date: Thu, 3 Sep 2026 23:48:29 -0700 Subject: [PATCH 3/6] fix(models): surface the edited short name in the model lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user-editable short name (简称) was only rendered in the toolbar capsule and the edit dialog. After editing it, the change was invisible in the two lists users actually look at: the Settings shortlist row and the chat model dropdown both showed only the full display name. Show the short name in both places so an edit is visible where it's made and where models are picked: - Settings shortlist row now shows display name / short name / provider-model id (three lines). - Chat model dropdown shows the short name as a subtitle under each model's full name. Display-only change; no state or persistence changes. 351 unit tests pass. --- .../opencode_client/ui/chat/ChatTopBar.kt | 21 ++++++++++++------- .../ui/settings/ModelShortlistScreen.kt | 7 ++++++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt b/app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt index 41193df3..ff6bf3dc 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt @@ -253,13 +253,20 @@ internal fun ChatTopBar( state.availableModels.forEachIndexed { index, model -> DropdownMenuItem( text = { - Text( - model.displayName, - color = if (index == state.selectedModelIndex) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.onSurface - ) + Column { + Text( + model.displayName, + color = if (index == state.selectedModelIndex) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurface + ) + Text( + model.shortName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } }, onClick = { actions.onSelectModel(index) diff --git a/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt b/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt index 1655335d..502426cb 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt @@ -181,8 +181,13 @@ private fun ModelShortlistRow( Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) { Text(item.displayName, style = MaterialTheme.typography.bodyLarge) Text( - providerSubtitle, + item.shortName, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + providerSubtitle, + style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } From eaf52e96fd98386e584d822a8d44097060b31d79 Mon Sep 17 00:00:00 2001 From: Yan Wang Date: Thu, 3 Sep 2026 23:56:47 -0700 Subject: [PATCH 4/6] fix(models): redesign shortlist row, restore chat dropdown User walkthrough feedback on the previous fix: - The chat model dropdown didn't need changes - restore the original single-line display name per item. - The Settings shortlist row was cluttered: three text lines crammed left of four inline icon buttons (~190dp of 48dp touch targets). Redesign the row around the HostProfileRow pattern the RFC already specified: the whole card is tappable and opens the short-name editor; the display name and provider/model id subtitle get the full width with ellipsis; the short name renders as a compact primary-tinted badge next to the name (echoing the chat capsule label, capped at 120dp); all management actions (edit / move up / move down / delete) collapse into one 32dp MoreVert overflow menu with leading icons, move actions disabled at the list edges, and delete tinted as destructive. --- .../opencode_client/ui/chat/ChatTopBar.kt | 21 ++-- .../ui/settings/ModelShortlistScreen.kt | 111 ++++++++++++++---- docs/RFC.md | 2 +- docs/working.md | 7 ++ 4 files changed, 105 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt b/app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt index ff6bf3dc..41193df3 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt @@ -253,20 +253,13 @@ internal fun ChatTopBar( state.availableModels.forEachIndexed { index, model -> DropdownMenuItem( text = { - Column { - Text( - model.displayName, - color = if (index == state.selectedModelIndex) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.onSurface - ) - Text( - model.shortName, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Text( + model.displayName, + color = if (index == state.selectedModelIndex) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurface + ) }, onClick = { actions.onSelectModel(index) diff --git a/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt b/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt index 502426cb..79d77b20 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt @@ -11,8 +11,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -21,16 +24,20 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -43,6 +50,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.yage.opencode_client.R @@ -155,6 +163,7 @@ fun ModelShortlistScreen( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ModelShortlistRow( item: ModelShortlistItem, @@ -166,7 +175,12 @@ private fun ModelShortlistRow( onDelete: () -> Unit, onEditShortName: () -> Unit ) { + var menuExpanded by remember { mutableStateOf(false) } + // Aligns with HostProfileRow: whole row opens the short-name editor, all + // management actions live behind a single compact overflow menu so the + // text gets the card's full width. Card( + onClick = onEditShortName, modifier = Modifier .fillMaxWidth() .testTag("model.shortlist.row.${item.id}"), @@ -175,33 +189,88 @@ private fun ModelShortlistRow( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(start = 16.dp, end = 4.dp, top = 10.dp, bottom = 10.dp), verticalAlignment = Alignment.CenterVertically ) { - Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) { - Text(item.displayName, style = MaterialTheme.typography.bodyLarge) - Text( - item.shortName, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface - ) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + item.displayName, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + Spacer(modifier = Modifier.width(8.dp)) + // The label the chat capsule shows, echoed as a compact badge. + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.10f), + modifier = Modifier.widthIn(max = 120.dp) + ) { + Text( + item.shortName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) + ) + } + } + Spacer(modifier = Modifier.height(2.dp)) Text( providerSubtitle, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } - IconButton(onClick = onEditShortName) { - Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.model_shortlist_edit_short_name)) - } - IconButton(onClick = onMoveUp, enabled = !isFirst) { - Icon(Icons.Default.KeyboardArrowUp, contentDescription = stringResource(R.string.model_shortlist_move_up)) - } - IconButton(onClick = onMoveDown, enabled = !isLast) { - Icon(Icons.Default.KeyboardArrowDown, contentDescription = stringResource(R.string.model_shortlist_move_down)) - } - IconButton(onClick = onDelete) { - Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.common_delete)) + Box { + IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.MoreVert, + contentDescription = stringResource(R.string.chat_more_options), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.model_shortlist_edit_short_name)) }, + onClick = { menuExpanded = false; onEditShortName() }, + leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.model_shortlist_move_up)) }, + enabled = !isFirst, + onClick = { menuExpanded = false; onMoveUp() }, + leadingIcon = { Icon(Icons.Default.KeyboardArrowUp, contentDescription = null) } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.model_shortlist_move_down)) }, + enabled = !isLast, + onClick = { menuExpanded = false; onMoveDown() }, + leadingIcon = { Icon(Icons.Default.KeyboardArrowDown, contentDescription = null) } + ) + DropdownMenuItem( + text = { + Text( + stringResource(R.string.common_delete), + color = MaterialTheme.colorScheme.error + ) + }, + onClick = { menuExpanded = false; onDelete() }, + leadingIcon = { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + } + ) + } } } } diff --git a/docs/RFC.md b/docs/RFC.md index faed22b4..50ae9d94 100644 --- a/docs/RFC.md +++ b/docs/RFC.md @@ -1135,7 +1135,7 @@ Chat Markdown 在 `WorkspaceMarkdownLinkResolver` 之前拦截 `opencode` scheme ### 5.14 Model Shortlist 管理 UI(对齐 iOS) - **设置页入口**(`SettingsScreen.kt`):`AppearanceSection` 之后新增一行 `ModelShortlistEntry`(带 `modelShortlist.size` 数量角标 + `ChevronRight`),复用 `HostProfilesManagerScreen` 的子页面模式(`showModelShortlist` state 切换,渲染 `ModelShortlistScreen(viewModel, onBack)`)。 -- **短名单页**(新 `ui/settings/ModelShortlistScreen.kt`):TopAppBar 标题 "模型列表",actions 里 "+" → catalog picker。行对齐 `HostProfileRow` 视觉语言(卡片 + 右侧 MoreVert 菜单):主行 displayName,副行 `providerDisplayNames[providerId] ?: providerId / modelId`。重排用 MoreVert 菜单里的"上移/下移"(决策 D3,不做拖拽);MoreVert 还有"编辑短名 / 删除"(删除直接执行,空短名单是合法状态)。点行主体 → 编辑短名 dialog(`AlertDialog` + `OutlinedTextField`)。空态:提示文案 + 醒目"添加模型"按钮。 +- **短名单页**(新 `ui/settings/ModelShortlistScreen.kt`):TopAppBar 标题 "模型列表",actions 里 "+" → catalog picker。行对齐 `HostProfileRow` 视觉语言(卡片 + 右侧单个 MoreVert 溢出菜单):主行 displayName + 简称 badge(电蓝小 chip,呼应聊天胶囊里显示的标签),副行 `providerDisplayNames[providerId] ?: providerId / modelId`。所有操作收进 MoreVert 菜单:编辑短名 / 上移 / 下移(决策 D3,不做拖拽)/ 删除(error 色,直接执行,空短名单是合法状态);上移/下移在首/尾位禁用。点行主体 → 编辑短名 dialog(`AlertDialog` + `OutlinedTextField`)。空态:提示文案 + 醒目"添加模型"按钮。 - **Catalog picker**(同文件 `AddModelCatalogDialog`):搜索框(displayName/modelId/providerId 三字段过滤)+ 多选 checkbox(排除已在短名单的)+ 底部"添加所选 (n)"(0 选禁用)。catalog 为空时显示"未获取到模型目录"。 - **聊天 picker**(`ChatTopBar.kt`):DropdownMenu 底部加 "Manage models" 跳转行(`onManageModels` → `requestModelShortlistFocus()` + 跳设置);`availableModels.isEmpty()` 时显示"去设置添加模型"跳转行。 - **深链**:`pendingModelShortlistFocus` state + `SettingsScreen` 的 `LaunchedEffect` 一次性打开短名单子页(跳转即直接落到目标页,无需额外高亮;消费后清除,不重复触发)。tablet 折叠 Sessions 左栏时,设置跳转同时展开左栏,否则 `SettingsScreen` 不会 composition、pending 无法消费。 diff --git a/docs/working.md b/docs/working.md index 26344679..b45440db 100644 --- a/docs/working.md +++ b/docs/working.md @@ -698,3 +698,10 @@ iOS/Android feature parity 调研完成,确认以下体验层差异需要对 - **P2**:短名单行副行改为稳定的 `provider / modelId`(原只显示 shortName,同名模型无法区分);`buildCatalog` 对空白 model name 回落 modelId;RFC/PRD 去掉"高亮"措辞(深链直接落到子页,无需高亮)。 **复验**:`testDebugUnitTest` 351 tests 全部通过(新增 3 个回归测试:删除当前选中回落落盘、saved ID 缺失自动加入、blank model name 回落);`lintDebug` 仍 0 新增 error。 + +**用户走查后的 UI 迭代(同日)** + +- 第一版修复把简称加进了聊天下拉副标题 + 管理行第三行,用户反馈:聊天下拉不需要动(恢复单行 displayName);管理行三行文字 + 4 个内联 IconButton(编辑/上移/下移/删除,各 48dp 触摸目标约 190dp)把文字挤到左侧一小块,太乱。 +- 管理行重设计(对齐 RFC 本来就指定的 `HostProfileRow` 模式,`SettingsScreen.kt` 已有先例):卡片整行可点 → 编辑简称 dialog;主行 displayName(省略号截断)+ 简称 badge(电蓝 10% 底色小 chip,呼应聊天胶囊标签,`widthIn(max=120dp)` 防超长简称撑爆);副行 `provider / modelId`(单行省略);右侧只留一个 32dp MoreVert,菜单含 编辑短名 / 上移(首位禁用)/ 下移(尾位禁用)/ 删除(error 色 + error 色图标),均带 leadingIcon,contentDescription 复用 `chat_more_options`。 +- 聊天下拉恢复原样:单行 displayName,选中项 primary 色;胶囊按钮继续显示简称(唯一展示简称的聊天表面)。 +- 验证:`compileDebugKotlin`/`compileDebugUnitTestKotlin` 通过,`testDebugUnitTest` 351 全过(无 androidTest 覆盖该 UI,无测试需更新)。 From e80deec001fe83e015c847237c59117acd909a16 Mon Sep 17 00:00:00 2001 From: Yan Wang Date: Fri, 4 Sep 2026 00:04:23 -0700 Subject: [PATCH 5/6] feat(chat): show model name + ID in model picker dropdown The chat model dropdown now displays two lines per entry: - displayName (bodyLarge) on top - modelId (bodySmall, muted) below This gives users a stable identifier to cross-reference with server logs and API calls, without cluttering the capsule badge. --- .../opencode_client/ui/chat/ChatTopBar.kt | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt b/app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt index 41193df3..b4979a66 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt @@ -253,13 +253,21 @@ internal fun ChatTopBar( state.availableModels.forEachIndexed { index, model -> DropdownMenuItem( text = { - Text( - model.displayName, - color = if (index == state.selectedModelIndex) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.onSurface - ) + Column { + Text( + model.displayName, + style = MaterialTheme.typography.bodyLarge, + color = if (index == state.selectedModelIndex) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurface + ) + Text( + model.modelId, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } }, onClick = { actions.onSelectModel(index) From bff1a6442f54e603501bd1353f43031f419d472c Mon Sep 17 00:00:00 2001 From: Yan Wang Date: Fri, 4 Sep 2026 00:20:23 -0700 Subject: [PATCH 6/6] fix(models): address second-round review findings P1 fixes: - addModelsToShortlist now maintains the selectedModelId invariant: when the shortlist transitions from empty to non-empty, anchor to the first item and persist globally + per-session. - resolveModelCatalog falls back to ModelPresets when both /provider and config/providers fail, so the Add Model catalog is never empty. - launchLoadMessages only auto-adds a saved/inferred model when its provider is present in the loaded providers list, preventing stale or retired models from being resurrected. - AddModelCatalogDialog uses LazyColumn with stable keys instead of eager Column.verticalScroll for large catalogs. - ModelShortlist sub-screen uses rememberSaveable + BackHandler so system Back returns to Settings root instead of popping Settings. P2 fixes: - MoreVert IconButton uses default 48dp touch target (was 32dp). - model_shortlist_count uses for correct English singular. Tests: 352 passing (new unknown-provider regression test). --- .../yage/opencode_client/ui/MainViewModel.kt | 22 ++++++++- .../ui/MainViewModelSessionActions.kt | 45 ++++++++++++------- .../ui/settings/ModelShortlistScreen.kt | 21 +++++---- .../ui/settings/SettingsScreen.kt | 5 ++- .../ui/settings/SettingsSections.kt | 3 +- app/src/main/res/values-zh/strings.xml | 4 +- app/src/main/res/values/strings.xml | 5 ++- .../yage/opencode_client/MainViewModelTest.kt | 41 +++++++++++++++-- docs/working.md | 11 +++++ 9 files changed, 122 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt b/app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt index 99db8bef..49d80ba6 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt @@ -1827,8 +1827,28 @@ class MainViewModel @Inject constructor( } if (!changed) return settingsManager.modelShortlistJson = encodeShortlist(next) + // Ensure the ID invariant: a non-empty shortlist always has a valid + // selectedModelId. When the shortlist was empty (selectedModelId null) + // and we just added items, anchor to the first item and persist. + val currentId = _state.value.selectedModelId + val resolvedId = if (currentId != null && next.any { it.id == currentId }) { + currentId + } else { + next.firstOrNull()?.id + } + if (resolvedId != currentId) { + settingsManager.selectedModelId = resolvedId + _state.value.currentSessionId?.let { sessionId -> + if (resolvedId != null) settingsManager.setModelIdForSession(sessionId, resolvedId) + else settingsManager.removeModelIdForSession(sessionId) + } + } _state.update { - it.copy(modelShortlist = next, selectedModelIndex = reanchorSelectedModelIndex(next, it.selectedModelId)) + it.copy( + modelShortlist = next, + selectedModelId = resolvedId, + selectedModelIndex = reanchorSelectedModelIndex(next, resolvedId) + ) } } diff --git a/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt b/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt index 6764ef7c..54114e18 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt @@ -215,27 +215,28 @@ internal fun launchLoadMessages( val agentName = settingsManager?.getAgentForSession(sessionId) ?: inferredAgentName // Ensure the session's effective model (saved, or inferred from - // history) is present in the shortlist. The saved ID may be - // missing (the user removed it, or it predates the shortlist); - // the inferred model may be a dynamic/ad-hoc one. Without this, - // a saved ID absent from the shortlist would reanchor to index 0 - // while selectedModelId still points elsewhere, so the send path - // would use a different model than the state claims. Mirrors iOS - // applySavedModelForCurrentSession + syncModelFromMessageHistory. + // history) is present in the shortlist. Only auto-add when + // the provider is known (present in the loaded providers + // list) to avoid resurrecting stale/retired models. var nextShortlist = state.value.modelShortlist if (sessionModelId != null) { val slash = sessionModelId.indexOf('/') if (slash > 0) { val providerId = sessionModelId.substring(0, slash) val modelId = sessionModelId.substring(slash + 1) - val displayName = buildProviderModelsIndex(state.value.providers)[sessionModelId]?.name - ?: modelId - val (added, changed) = addModelToShortlist( - nextShortlist, providerId, modelId, displayName - ) - if (changed) { - nextShortlist = added - settingsManager?.modelShortlistJson = encodeShortlist(nextShortlist) + val alreadyInShortlist = nextShortlist.any { it.id == sessionModelId } + val providerKnown = state.value.providers?.providers + ?.any { it.id == providerId } == true + if (!alreadyInShortlist && providerKnown) { + val displayName = buildProviderModelsIndex(state.value.providers)[sessionModelId]?.name + ?: modelId + val (added, changed) = addModelToShortlist( + nextShortlist, providerId, modelId, displayName + ) + if (changed) { + nextShortlist = added + settingsManager?.modelShortlistJson = encodeShortlist(nextShortlist) + } } } } @@ -382,8 +383,18 @@ private suspend fun resolveModelCatalog( if (providers != null) { return buildCatalog(providers.providers, null) } - // Both endpoints failed: return null so the caller keeps the existing catalog. - return null + // Both endpoints failed: fall back to the hardcoded presets so the + // "Add Model" catalog is never empty (users can still add known models + // while offline or against an incompatible server). + val presetCatalog = ModelPresets.list.map { preset -> + CatalogModel( + providerId = preset.providerId, + modelId = preset.modelId, + displayName = preset.displayName, + shortName = preset.customShortName ?: preset.displayName + ) + } + return CatalogBuildResult(models = presetCatalog, providerDisplayNames = emptyMap()) } internal fun launchCreateSession( diff --git a/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt b/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt index 79d77b20..7ecd3b24 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/settings/ModelShortlistScreen.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -228,7 +230,7 @@ private fun ModelShortlistRow( ) } Box { - IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(32.dp)) { + IconButton(onClick = { menuExpanded = true }) { Icon( Icons.Default.MoreVert, contentDescription = stringResource(R.string.chat_more_options), @@ -307,20 +309,21 @@ private fun AddModelCatalogDialog( singleLine = true ) Spacer(modifier = Modifier.height(12.dp)) - Column( + LazyColumn( modifier = Modifier .fillMaxWidth() .heightIn(max = 320.dp) - .verticalScroll(rememberScrollState()) ) { if (filtered.isEmpty()) { - Text( - stringResource(R.string.model_shortlist_no_models), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + item { + Text( + stringResource(R.string.model_shortlist_no_models), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } - filtered.forEach { cm -> + items(filtered, key = { it.id }) { cm -> val inShortlist = cm.id in existingIds Row( modifier = Modifier diff --git a/app/src/main/java/com/yage/opencode_client/ui/settings/SettingsScreen.kt b/app/src/main/java/com/yage/opencode_client/ui/settings/SettingsScreen.kt index 100a9f59..fad46398 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/settings/SettingsScreen.kt @@ -35,7 +35,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.activity.compose.BackHandler import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.testTag @@ -67,7 +69,7 @@ fun SettingsScreen( val context = LocalContext.current var showHostProfiles by remember { mutableStateOf(false) } - var showModelShortlist by remember { mutableStateOf(false) } + var showModelShortlist by rememberSaveable { mutableStateOf(false) } var isTesting by remember { mutableStateOf(false) } var testResult by remember { mutableStateOf(null) } var aiBuilderBaseURL by remember { mutableStateOf(savedAIBuilder.baseURL) } @@ -139,6 +141,7 @@ fun SettingsScreen( } if (showModelShortlist) { + BackHandler(enabled = true) { showModelShortlist = false } ModelShortlistScreen( viewModel = viewModel, onBack = { showModelShortlist = false } diff --git a/app/src/main/java/com/yage/opencode_client/ui/settings/SettingsSections.kt b/app/src/main/java/com/yage/opencode_client/ui/settings/SettingsSections.kt index 1a787c35..df39164c 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/settings/SettingsSections.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/settings/SettingsSections.kt @@ -48,6 +48,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation @@ -682,7 +683,7 @@ internal fun ModelShortlistEntry( verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.model_shortlist_count, modelCount), style = MaterialTheme.typography.titleMedium) + Text(pluralStringResource(R.plurals.model_shortlist_count, modelCount, modelCount), style = MaterialTheme.typography.titleMedium) Text( stringResource(R.string.settings_model_shortlist_entry), style = MaterialTheme.typography.bodySmall, diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 33dcd3ab..04f33d27 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -72,7 +72,9 @@ 已添加 添加 (%1$d) 当前:%1$s - %1$d 个模型 + + %1$d 个模型 + 管理模型 正在打开会话… 这个 OpenCode 会话链接无效。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c0811eb4..9a96c90c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -72,7 +72,10 @@ Added Add (%1$d) Current: %1$s - %1$d models + + %1$d model + %1$d models + Manage models Opening session… This OpenCode session link is invalid. diff --git a/app/src/test/java/com/yage/opencode_client/MainViewModelTest.kt b/app/src/test/java/com/yage/opencode_client/MainViewModelTest.kt index 8f583d44..63186d64 100644 --- a/app/src/test/java/com/yage/opencode_client/MainViewModelTest.kt +++ b/app/src/test/java/com/yage/opencode_client/MainViewModelTest.kt @@ -14,6 +14,7 @@ import com.yage.opencode_client.data.model.SSEPayload import com.yage.opencode_client.data.model.HealthResponse import com.yage.opencode_client.data.model.HostProfile import com.yage.opencode_client.data.model.HostTransport +import com.yage.opencode_client.data.model.ConfigProvider import com.yage.opencode_client.data.model.ModelShortlistItem import com.yage.opencode_client.data.model.ProviderRegistryResponse import com.yage.opencode_client.data.model.ProvidersResponse @@ -2177,14 +2178,22 @@ class MainViewModelTest { @Test fun `loadMessages auto-adds a saved model missing from the shortlist`() = runTest { - // A saved model that is NOT in the seeded shortlist (e.g. the user removed - // it, or it predates the shortlist). Loading the session must re-add it so - // the selected id and index stay consistent (no ID/index split). + // A saved model that is NOT in the seeded shortlist but whose provider + // is known (present in the loaded providers list). Loading the session + // must re-add it so the selected id and index stay consistent. val savedId = "anthropic/claude-x" every { settingsManager.getModelIdForSession("session-1") } returns savedId + coEvery { repository.getMessages("session-1", 30) } returns Result.success(emptyList()) val viewModel = createViewModel() - updateState(viewModel) { it.copy(currentSessionId = "session-1") } + updateState(viewModel) { + it.copy( + currentSessionId = "session-1", + providers = ProvidersResponse( + providers = listOf(ConfigProvider(id = "anthropic", name = "Anthropic")) + ) + ) + } viewModel.loadMessages("session-1") advanceUntilIdle() @@ -2195,6 +2204,30 @@ class MainViewModelTest { assertEquals(idx, viewModel.state.value.selectedModelIndex) } + @Test + fun `loadMessages does not auto-add a saved model with unknown provider`() = runTest { + // A saved model whose provider is NOT in the loaded providers list + // (stale/retired provider). Loading the session must NOT re-add it. + val savedId = "ghost/phantom-model" + every { settingsManager.getModelIdForSession("session-1") } returns savedId + coEvery { repository.getMessages("session-1", 30) } returns Result.success(emptyList()) + + val viewModel = createViewModel() + updateState(viewModel) { + it.copy( + currentSessionId = "session-1", + providers = ProvidersResponse( + providers = listOf(ConfigProvider(id = "openai", name = "OpenAI")) + ) + ) + } + + viewModel.loadMessages("session-1") + advanceUntilIdle() + + assertFalse(viewModel.state.value.modelShortlist.any { it.id == savedId }) + } + @Test fun `abortSession calls repository for current session`() = runTest { coEvery { repository.abortSession("session-1") } returns Result.success(Unit) diff --git a/docs/working.md b/docs/working.md index b45440db..01cf5eca 100644 --- a/docs/working.md +++ b/docs/working.md @@ -705,3 +705,14 @@ iOS/Android feature parity 调研完成,确认以下体验层差异需要对 - 管理行重设计(对齐 RFC 本来就指定的 `HostProfileRow` 模式,`SettingsScreen.kt` 已有先例):卡片整行可点 → 编辑简称 dialog;主行 displayName(省略号截断)+ 简称 badge(电蓝 10% 底色小 chip,呼应聊天胶囊标签,`widthIn(max=120dp)` 防超长简称撑爆);副行 `provider / modelId`(单行省略);右侧只留一个 32dp MoreVert,菜单含 编辑短名 / 上移(首位禁用)/ 下移(尾位禁用)/ 删除(error 色 + error 色图标),均带 leadingIcon,contentDescription 复用 `chat_more_options`。 - 聊天下拉恢复原样:单行 displayName,选中项 primary 色;胶囊按钮继续显示简称(唯一展示简称的聊天表面)。 - 验证:`compileDebugKotlin`/`compileDebugUnitTestKotlin` 通过,`testDebugUnitTest` 351 全过(无 androidTest 覆盖该 UI,无测试需更新)。 + +**第二轮 subagent review 修复(merge 前)** + +- **P1 selectedModelId 不变量**:`addModelsToShortlist` 在短名单从空变非空时未设置 `selectedModelId`,导致 ID/index 分裂。修复:添加后若当前 ID 无效则锚定到首项并同步落盘全局 + 当前 session。 +- **P1 catalog 兜底**:`/provider` 与 `config/providers` 双失败时返回 null(保留旧 catalog),但全新安装时 catalog 为空、"添加模型"无可选。修复:双失败时回落到 `ModelPresets` 硬编码列表构建 catalog。 +- **P1 过期 session 模型**:`launchLoadMessages` 对任何语法合法的 `providerId/modelId` 都自动加入短名单,即使 provider 已断开/退役。修复:仅当 provider 在已加载的 providers 列表中时才自动加入;未知 provider 的 saved ID 不再复活。 +- **P1 catalog picker 性能**:`AddModelCatalogDialog` 用 `Column.verticalScroll()` 一次性 compose 所有行(OpenRouter 可达数千模型)。修复:改为 `LazyColumn` + `items(key = { it.id })`。 +- **P1 子页 Back 键**:`showModelShortlist` 是普通 `remember`,系统 Back 直接弹出 Settings 而非返回 Settings 根。修复:改 `rememberSaveable` + `BackHandler`。 +- **P2 触摸目标**:MoreVert `IconButton` 限制 32dp 低于 48dp 无障碍最低。修复:移除 size 约束,用默认 48dp。 +- **P2 复数**:`model_shortlist_count` 英文 "1 models"。修复:改 ``(one/other),中文用 `other`。 +- 验证:352 tests 全过(新增 1 个 unknown-provider 回归测试)。