diff --git a/plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml b/plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml
index 58824d79..0a437809 100644
--- a/plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml
+++ b/plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml
@@ -40,7 +40,8 @@
android:value="App Dev for All" />
GeminiPromptConfig?,
) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend, ToolCallingBackend,
- EmbeddingBackend, WebSearchBackend, ActiveModelReportingBackend {
+ EmbeddingModelSelectable, WebSearchBackend, ActiveModelReportingBackend {
private val scope = CoroutineScope(Dispatchers.IO)
@@ -162,6 +162,48 @@ class GeminiBackend(
?.trim()?.takeIf { it.isNotEmpty() }
?: DEFAULT_EMBEDDING_MODEL
+ /**
+ * The models the saved key can embed with: those that advertise `embedContent`. A saved model
+ * the list omits is replaced by its first entry.
+ *
+ * Fails rather than answering empty without a key, so the screen offering the choice can say
+ * why there is no list instead of drawing an empty one.
+ *
+ * @return the embedding models in catalog order, or a future completed exceptionally with the
+ * sentence a refused chat turn shows, or, with no usable key, why none could be read
+ */
+ override fun listEmbeddingModels(): CompletableFuture> {
+ val keyStamp = storedKeyStamp()
+ return launchFuture(
+ describeFailure = { e ->
+ context.logger.warn("GeminiBackend: listing embedding models failed: ${e.message}")
+ IOException(formatErrorMessage(e, keyStamp), e)
+ },
+ ) {
+ val apiKey = readGeminiApiKey()
+ if (apiKey.isNullOrBlank()) throw ReportedFailure(missingKeyMessage())
+ val models = describeCatalog(apiKey).embedding
+ // Off a retired saved model, as OpenAI does: the live catalog omitting it means a 404.
+ models.firstOrNull()?.takeIf { getEmbeddingModelId() !in models }
+ ?.let(::setEmbeddingModelId)
+ models
+ }
+ }
+
+ /**
+ * Stores [modelId] where [getEmbeddingModelId] reads it.
+ *
+ * The plugin watches this key, so writing it is what tells AI Core's listeners; setting the
+ * current model again writes nothing and so tells nobody.
+ */
+ override fun setEmbeddingModelId(modelId: String) {
+ val model = modelId.trim()
+ require(model.isNotEmpty()) { "The embedding model id must not be empty" }
+ if (model == getEmbeddingModelId()) return
+ agentPrefs()?.edit()?.putString(GeminiPreferences.KEY_EMBEDDING_MODEL, model)?.apply()
+ context.logger.info("GeminiBackend: embedding model set to $model")
+ }
+
/**
* Vector length the API last produced for the configured embedding model.
*
@@ -183,42 +225,20 @@ class GeminiBackend(
* store a partial space
*/
override fun embed(texts: List): CompletableFuture> {
- val future = CompletableFuture>()
- if (texts.isEmpty()) {
- future.complete(emptyList())
- return future
- }
- // close() cancels the scope, making launch a silent no-op; fail loudly instead.
- if (!scope.isActive) {
- future.completeExceptionally(IllegalStateException("Gemini backend is closed"))
- return future
- }
-
- val job = scope.launch {
- val keyStamp = storedKeyStamp()
- try {
- val apiKey = readGeminiApiKey()
- if (apiKey.isNullOrBlank()) {
- // The same refusal a chat turn reports, through the same formatter.
- future.completeExceptionally(
- IOException(userMessage(GeminiFailure.KeyInvalid))
- )
- return@launch
- }
- future.complete(embedBatches(texts, apiKey))
- } catch (e: CancellationException) {
- future.cancel(true)
- throw e
- } catch (e: Exception) {
+ if (texts.isEmpty()) return CompletableFuture.completedFuture(emptyList())
+ val keyStamp = storedKeyStamp()
+ return launchFuture(
+ describeFailure = { e ->
context.logger.error("GeminiBackend: embedding ${texts.size} texts failed", e)
// The formatted sentence, not the raw body: this message reaches the user through
// whichever consumer asked, exactly as a refused chat turn's does.
- future.completeExceptionally(IOException(formatErrorMessage(e, keyStamp), e))
- }
+ IOException(formatErrorMessage(e, keyStamp), e)
+ },
+ ) {
+ val apiKey = readGeminiApiKey()
+ if (apiKey.isNullOrBlank()) throw ReportedFailure(missingKeyMessage())
+ embedBatches(texts, apiKey)
}
- future.cancelJobOnCancel(job)
-
- return future
}
/**
@@ -670,36 +690,22 @@ class GeminiBackend(
* network/API failure, so the caller can fall back to a current-models-only list and
* never advertise a dead model.
*/
- internal fun listCatalog(): CompletableFuture {
- val future = CompletableFuture()
- // close() cancels the scope, making launch a silent no-op; fail loudly instead, or the
- // gateway's blocking get() would sit at "Loading" for its full 60-second timeout.
- if (!scope.isActive) {
- future.completeExceptionally(IllegalStateException("Gemini backend is closed"))
- return future
- }
-
- val job = scope.launch {
- try {
- val key = readGeminiApiKey()
- if (key.isNullOrBlank()) {
- context.logger.warn("GeminiBackend: no API key configured; cannot list live models")
- future.complete(ModelCatalog.EMPTY)
- return@launch
- }
- future.complete(describeCatalog(key))
- } catch (e: CancellationException) {
- future.cancel(true)
- throw e
- } catch (e: Exception) {
+ internal fun listCatalog(): CompletableFuture =
+ // A closed scope fails at once, not after the gateway's blocking 60-second get().
+ launchFuture(
+ describeFailure = { e ->
context.logger.error("GeminiBackend: Error in listCatalog", e)
- future.completeExceptionally(e)
+ e
+ },
+ ) {
+ val key = readGeminiApiKey()
+ if (key.isNullOrBlank()) {
+ context.logger.warn("GeminiBackend: no API key configured; cannot list live models")
+ ModelCatalog.EMPTY
+ } else {
+ describeCatalog(key)
}
}
- future.cancelJobOnCancel(job)
-
- return future
- }
/**
* List the models a caller-supplied [apiKey] can use, instead of the one saved on disk.
@@ -713,32 +719,19 @@ class GeminiBackend(
* status code out of that message to tell a refused key from an unreachable network
*/
internal fun listCatalog(apiKey: String): CompletableFuture {
- val future = CompletableFuture()
val key = apiKey.trim()
if (key.isEmpty()) {
- future.completeExceptionally(IllegalArgumentException("Gemini API key is blank"))
- return future
- }
- // close() cancels the scope, making launch a silent no-op; fail loudly instead.
- if (!scope.isActive) {
- future.completeExceptionally(IllegalStateException("Gemini backend is closed"))
- return future
- }
-
- val job = scope.launch {
- try {
- future.complete(describeCatalog(key))
- } catch (e: CancellationException) {
- future.cancel(true)
- throw e
- } catch (e: Exception) {
- context.logger.warn("GeminiBackend: candidate key check failed: ${e.message}")
- future.completeExceptionally(e)
+ // Not CompletableFuture.failedFuture: that is API 31, and the host still runs on 28.
+ return CompletableFuture().apply {
+ completeExceptionally(IllegalArgumentException("Gemini API key is blank"))
}
}
- future.cancelJobOnCancel(job)
-
- return future
+ return launchFuture(
+ describeFailure = { e ->
+ context.logger.warn("GeminiBackend: candidate key check failed: ${e.message}")
+ e
+ },
+ ) { describeCatalog(key) }
}
/**
@@ -1098,6 +1091,65 @@ class GeminiBackend(
}
}
+ /**
+ * Runs [block] on this backend's scope and completes the returned future with its value.
+ *
+ * Cancelling the future cancels [block], and a closed backend fails the future at once: after
+ * close() cancels the scope, launch is a silent no-op that would leave it pending forever.
+ *
+ * @param describeFailure turns what [block] threw into what the future fails with; a
+ * [ReportedFailure] skips it, being already in the user's words
+ */
+ private fun launchFuture(
+ describeFailure: (Exception) -> Throwable,
+ block: suspend () -> T,
+ ): CompletableFuture {
+ val future = CompletableFuture()
+ if (!scope.isActive) {
+ future.completeExceptionally(IllegalStateException("Gemini backend is closed"))
+ return future
+ }
+
+ val job = scope.launch {
+ try {
+ future.complete(block())
+ } catch (e: CancellationException) {
+ future.cancel(true)
+ throw e
+ } catch (e: ReportedFailure) {
+ future.completeExceptionally(e)
+ } catch (e: Exception) {
+ future.completeExceptionally(describeFailure(e))
+ }
+ }
+ future.cancelJobOnCancel(job)
+ return future
+ }
+
+ /**
+ * Why no key could be used, in words that do not blame a key that was never sent: none saved,
+ * one this device can no longer decrypt, or a Keystore that did not answer this time.
+ */
+ private fun missingKeyMessage(): String {
+ val stored = runCatching {
+ secureApiKeyStore.readAndMigrate(agentPrefs(), GeminiPreferences.KEY_API_KEY)
+ }.getOrNull()
+ val res = when (stored) {
+ KeystoreSecretStore.Stored.Absent -> R.string.gemini_error_key_missing
+ is KeystoreSecretStore.Stored.Value ->
+ // Readable now but not a moment ago, or saved blank; only blank lasts.
+ if (stored.plain.isBlank()) R.string.gemini_error_key_missing
+ else R.string.msg_api_key_unavailable
+ KeystoreSecretStore.Stored.Unreadable -> R.string.msg_api_key_unreadable
+ KeystoreSecretStore.Stored.Unavailable, null -> R.string.msg_api_key_unavailable
+ }
+ return try {
+ context.androidContext.getString(res)
+ } catch (e: Exception) {
+ "No usable Gemini API key"
+ }
+ }
+
/**
* Turn a failure into one user-facing sentence.
*
@@ -1181,6 +1233,12 @@ class GeminiBackend(
}
}
+/**
+ * A failure already worded for the user, which [GeminiBackend]'s future helper passes on as it is
+ * instead of classifying it again as a transport error.
+ */
+private class ReportedFailure(message: String) : IOException(message)
+
/**
* Cancel [job] when this future is cancelled by its caller.
*
diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/ModelCatalog.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/ModelCatalog.kt
index ad3f70ed..8e9ce704 100644
--- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/ModelCatalog.kt
+++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/ModelCatalog.kt
@@ -1,11 +1,8 @@
package com.itsaky.androidide.plugins.aiagentgemini.backend
/**
- * One key's catalog, split into the models each picker may offer.
- *
- * Carried as one value because both halves come from one paginated `ListModels` walk, filtered on
- * the capability each model declares: fetching them separately would pay for the walk twice and
- * let the two pickers describe different snapshots.
+ * One key's catalog, split by declared capability: chat models for this plugin's settings pane,
+ * embedding models for Vector Search's. One value because both come from one `ListModels` walk.
*
* @param chat models that advertise `generateContent`
* @param embedding models that advertise `embedContent`
diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt
index fc1249fb..f838285a 100644
--- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt
+++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt
@@ -49,11 +49,17 @@ class GeminiPlugin : IPlugin, DocumentationExtension {
// Tags for the controls on this backend's settings pane (see GeminiSettingsFragment).
const val TOOLTIP_TAG_SETTINGS_GEMINI_KEY = "ai_gemini_key"
const val TOOLTIP_TAG_SETTINGS_GEMINI_MODEL = "ai_gemini_model"
- const val TOOLTIP_TAG_SETTINGS_GEMINI_EMBEDDING_MODEL = "ai_gemini_embedding_model"
const val TOOLTIP_TAG_SETTINGS_GET_KEY = "ai_gemini_get_free_key"
- /** The settings that change what [GeminiBackend.isAvailable] or its model name answers. */
- private val WATCHED_KEYS = setOf(GeminiPreferences.KEY_API_KEY, GeminiPreferences.KEY_MODEL)
+ /**
+ * The settings that change what [GeminiBackend.isAvailable], its model name or its
+ * embedding model answers; the last is set from Vector Search, which re-reads on notice.
+ */
+ private val WATCHED_KEYS = setOf(
+ GeminiPreferences.KEY_API_KEY,
+ GeminiPreferences.KEY_MODEL,
+ GeminiPreferences.KEY_EMBEDDING_MODEL,
+ )
@Volatile
private var pluginContext: PluginContext? = null
@@ -220,22 +226,6 @@ class GeminiPlugin : IPlugin, DocumentationExtension {
current models is shown instead.
""".trimIndent(),
),
- PluginTooltipEntry(
- tag = TOOLTIP_TAG_SETTINGS_GEMINI_EMBEDDING_MODEL,
- summary = "Which model turns your code into vectors for semantic search. Never used for chat.",
- detail = """
-
Semantic search compares meaning rather than words, which it
- does by embedding every chunk of the project with this model. It
- is a separate setting because no Gemini model does both: this list
- holds the models that advertise embedContent, and the
- Model list above holds those that advertise
- generateContent.
-
Changing it changes the vector space, so the project is indexed
- again from scratch. Vectors from two different models are not
- comparable, and mixing them would quietly return worse results
- rather than fail.
- """.trimIndent(),
- ),
PluginTooltipEntry(
tag = TOOLTIP_TAG_SETTINGS_GET_KEY,
summary = "Opens Google AI Studio in your browser, where API keys are free to create.",
diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/CatalogResult.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/CatalogResult.kt
index e0eeb829..fcd741c1 100644
--- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/CatalogResult.kt
+++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/CatalogResult.kt
@@ -9,19 +9,11 @@ package com.itsaky.androidide.plugins.aiagentgemini.settings
sealed interface CatalogResult {
/**
- * The backend answered. Either list may be empty, which for [models] is itself suspicious for
- * a valid key.
- *
- * Both halves come from one paginated walk, split by the method each model declares, so they
- * describe the same snapshot of the same key.
+ * The backend answered. The list may be empty, which is itself suspicious for a valid key.
*
* @param models the chat-capable models the key can reach
- * @param embeddingModels the embedding-capable models the key can reach
*/
- data class Success(
- val models: List,
- val embeddingModels: List,
- ) : CatalogResult
+ data class Success(val models: List) : CatalogResult
/** No "gemini" backend was resolvable — ai-core or ai-agent-gemini is missing, disabled,
* or not yet active. */
diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiCatalogGateway.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiCatalogGateway.kt
index a57c006e..d6f71e22 100644
--- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiCatalogGateway.kt
+++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiCatalogGateway.kt
@@ -96,7 +96,7 @@ class BackendGeminiCatalogGateway(
return try {
val catalog = future.get(LIST_MODELS_TIMEOUT_SECONDS, TimeUnit.SECONDS)
?: ModelCatalog.EMPTY
- CatalogResult.Success(catalog.chat, catalog.embedding)
+ CatalogResult.Success(catalog.chat)
} catch (e: ExecutionException) {
// The API failure the backend reported; its message carries the HTTP status.
CatalogResult.Failed(e.cause ?: e)
diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt
index 2a3aed6f..667442fd 100644
--- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt
+++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt
@@ -20,10 +20,7 @@ import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.DrawableRes
-import androidx.annotation.IdRes
-import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
-import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -147,8 +144,7 @@ class GeminiSettingsFragment : Fragment() {
view.applyPaneStyling(PANE_STYLE, OUTLINED_BUTTON_IDS)
setupApiKeyUi(view)
- setupModelPicker(view, chatModelPicker())
- setupModelPicker(view, embeddingModelPicker())
+ setupModelPicker(view)
setupModelRefresh(view)
}
@@ -642,59 +638,7 @@ class GeminiSettingsFragment : Fragment() {
box.isEndIconCheckable = false
}
- // --- Model pickers -------------------------------------------------------------------------
-
- /**
- * Everything one model dropdown needs, so the chat and embedding pickers are one
- * implementation rather than two that drift.
- *
- * @param tooltipTag long-press help shared by the picker's label, field, hint and chevron
- * @param helpHint hint shown while no catalog is offered
- * @param liveHint hint shown once there is a list to tap
- * @param read the currently saved model
- * @param write persists a model the user picked
- * @param options the catalog to offer
- */
- private class ModelPicker(
- @IdRes val boxId: Int,
- @IdRes val inputId: Int,
- @IdRes val labelId: Int,
- @IdRes val hintId: Int,
- val tooltipTag: String,
- @StringRes val helpHint: Int,
- @StringRes val liveHint: Int,
- val read: () -> String,
- val write: (String) -> Unit,
- val options: LiveData,
- )
-
- /** The chat model: what a turn is generated with. */
- private fun chatModelPicker() = ModelPicker(
- boxId = R.id.gemini_model_box,
- inputId = R.id.gemini_model_input,
- labelId = R.id.gemini_model_label,
- hintId = R.id.gemini_model_hint_text,
- tooltipTag = GeminiPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_MODEL,
- helpHint = R.string.hint_gemini_model_help,
- liveHint = R.string.hint_gemini_model_live,
- read = viewModel::getGeminiModel,
- write = viewModel::saveGeminiModel,
- options = viewModel.geminiModels,
- )
-
- /** The embedding model: what semantic search indexes and queries with. */
- private fun embeddingModelPicker() = ModelPicker(
- boxId = R.id.gemini_embedding_model_box,
- inputId = R.id.gemini_embedding_model_input,
- labelId = R.id.gemini_embedding_model_label,
- hintId = R.id.gemini_embedding_model_hint_text,
- tooltipTag = GeminiPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_EMBEDDING_MODEL,
- helpHint = R.string.hint_gemini_embedding_model_help,
- liveHint = R.string.hint_gemini_embedding_model_live,
- read = viewModel::getGeminiEmbeddingModel,
- write = viewModel::saveGeminiEmbeddingModel,
- options = viewModel.geminiEmbeddingModels,
- )
+ // --- Model picker --------------------------------------------------------------------------
/**
* One model dropdown, built as the same control the OpenAI pane carries rather than a
@@ -704,16 +648,16 @@ class GeminiSettingsFragment : Fragment() {
* unlike the OpenAI pane there is no free-text model to type. The field itself is what shows
* the model in use, which is why there is no separate "current model" line any more.
*/
- private fun setupModelPicker(view: View, picker: ModelPicker) {
- val modelBox = view.findViewById(picker.boxId)
- val modelInput = view.findViewById(picker.inputId)
- val modelLabel = view.findViewById(picker.labelId)
- val modelHint = view.findViewById(picker.hintId)
+ private fun setupModelPicker(view: View) {
+ val modelBox = view.findViewById(R.id.gemini_model_box)
+ val modelInput = view.findViewById(R.id.gemini_model_input)
+ val modelLabel = view.findViewById(R.id.gemini_model_label)
+ val modelHint = view.findViewById(R.id.gemini_model_hint_text)
setupDropdownEndIcon(modelBox)
listOf(modelLabel, modelInput, modelHint)
- .forEach { wireTooltip(it, picker.tooltipTag) }
+ .forEach { wireTooltip(it, GeminiPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_MODEL) }
// A picker, not a text field: the list is the only way to change it.
modelInput.keyListener = null
@@ -722,20 +666,20 @@ class GeminiSettingsFragment : Fragment() {
// the list holding only the selected entry after a day/night switch.
modelInput.isSaveEnabled = false
// The suppressing overload throughout: a filtering write would narrow the list.
- modelInput.setText(picker.read(), false)
+ modelInput.setText(viewModel.getGeminiModel(), false)
// Tapping anywhere in the field opens the list; the end icon is only a second way in.
modelInput.setOnClickListener { modelInput.showDropDown() }
modelBox.setEndIconOnClickListener { modelInput.showDropDown() }
- wireEndIconTooltip(modelBox, picker.tooltipTag)
+ wireEndIconTooltip(modelBox, GeminiPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_MODEL)
// Only a real pick reaches here, so unlike the Spinner this replaced there is no
// programmatic selection to tell apart from a user's.
modelInput.setOnItemClickListener { parent, _, position, _ ->
val selected = parent.getItemAtPosition(position) as? String
- if (selected == null || selected == picker.read()) {
+ if (selected == null || selected == viewModel.getGeminiModel()) {
return@setOnItemClickListener
}
- picker.write(selected)
+ viewModel.saveGeminiModel(selected)
Toast.makeText(
requireContext(),
getString(R.string.model_changed, selected),
@@ -743,7 +687,7 @@ class GeminiSettingsFragment : Fragment() {
).show()
}
- picker.options.observe(viewLifecycleOwner) { options ->
+ viewModel.geminiModels.observe(viewLifecycleOwner) { options ->
// Cleared, not left stale: an empty list means there is no catalog to offer, and a
// remembered one would suggest models this key may no longer reach.
modelInput.setAdapter(
@@ -753,25 +697,23 @@ class GeminiSettingsFragment : Fragment() {
DropdownAdapter(modelInput.context, options.models)
}
)
- modelHint.setText(if (options.models.isEmpty()) picker.helpHint else picker.liveHint)
+ modelHint.setText(
+ if (options.models.isEmpty()) R.string.hint_gemini_model_help
+ else R.string.hint_gemini_model_live
+ )
// Migrate off a retired saved model only for a live catalog, never for the fallback:
// the field has to show what will actually be requested.
- if (!options.isLive || options.models.contains(picker.read())) {
+ if (!options.isLive || options.models.contains(viewModel.getGeminiModel())) {
return@observe
}
val migrated = options.models.firstOrNull() ?: return@observe
- picker.write(migrated)
+ viewModel.saveGeminiModel(migrated)
modelInput.setText(migrated, false)
}
}
- /**
- * The one Refresh button, shared by both pickers since one catalog walk answers them both.
- *
- * The initial fetch is guarded on the chat picker alone: the two are published together, so a
- * non-empty chat list means the fetch has already happened.
- */
+ /** The Refresh button; a non-empty model list means the initial fetch has already happened. */
private fun setupModelRefresh(view: View) {
val refreshButton = view.findViewById