From 21b8a7539765387412cc4f2c4d1ef4cb465c0d2e Mon Sep 17 00:00:00 2001 From: lcy Date: Sat, 17 Oct 2026 17:52:32 +0800 Subject: [PATCH 1/7] fix(chat): retry agents that fail to load instead of a dead mode chip A local OpenCode with plugins (oh-my-opencode-slim, opencode-mem, ...) installs them on its first GET /agent after a start: on a fresh cache that call took ~25s here, and it can fail outright. The catalogue took that one answer as final, so the agent list stayed empty until something else refreshed it, and the mode chip kept showing "build" over a menu with nothing in it - tapping it did nothing visible. Reproduced on an emulator against a proxy that fails the first /agent: the chip stayed dead for the whole session, across 60s and a background/foreground round trip. - RuntimeCatalogRepository retries GET /agent (4 x 3s) while the answer is a failure or an empty list - refreshAgents() re-reads only the agents, keeping the rest of the catalogue - the mode chip asks for a refresh when tapped with no agents, instead of opening an empty menu --- .../repository/RuntimeCatalogRepository.kt | 36 +++++++++++- .../andcode/feature/chat/ChatHomeScreen.kt | 17 +++++- .../yugahashimoto/andcode/ui/AndCodeApp.kt | 1 + .../RuntimeCatalogRepositoryTest.kt | 56 ++++++++++++++++++- 4 files changed, 107 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt index 1e13080be..a048ba84f 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt @@ -200,7 +200,7 @@ class RuntimeCatalogRepository( supervisorScope { val sessions = async { runCatching { target.listSessions() } } val providers = async { runCatching { target.listProviders() } } - val agents = async { runCatching { target.listAgents() } } + val agents = async { loadAgents(target) } val workspaces = async { runCatching { target.listWorkspaces() } } LoadedCatalog( sessions = sessions.await(), @@ -249,6 +249,38 @@ class RuntimeCatalogRepository( } } + /** + * The runtime's agents, retried a few times when the first answer is a failure or an empty list. + * + * A local OpenCode with plugins (oh-my-opencode-slim, opencode-mem, ...) installs them on its + * first `GET /agent` after a start, which can take tens of seconds or fail outright. That one + * answer used to be final until something else refreshed the catalogue, so the agent chip kept + * showing "build" over an empty menu that nothing would ever refill. + */ + private suspend fun loadAgents(target: RuntimeTarget): Result> { + var result = runCatching { target.listAgents() } + repeat(AGENT_RETRIES) { + if (result.getOrNull()?.isNotEmpty() == true || registry.selected.value?.id != target.id) return result + delay(AGENT_RETRY_DELAY_MILLIS) + result = runCatching { target.listAgents() } + } + return result + } + + /** + * Re-reads only the agent list, for a picker the user opened while it was empty. Leaves the rest + * of the catalogue alone and keeps any agents already on screen if this read fails too. + */ + fun refreshAgents() { + val target = registry.selected.value ?: return + scope.launch { + val agents = loadAgents(target) + if (registry.selected.value?.id != target.id) return@launch + val list = agents.getOrNull()?.takeIf { it.isNotEmpty() } ?: return@launch + mutableState.update { if (it.runtime?.id == target.id) it.copy(agents = list) else it } + } + } + private data class LoadedCatalog( val sessions: Result>, val providers: Result, @@ -267,6 +299,8 @@ class RuntimeCatalogRepository( private companion object { const val CONNECT_RETRIES = 6 const val CONNECT_RETRY_DELAY_MILLIS = 2_500L + const val AGENT_RETRIES = 4 + const val AGENT_RETRY_DELAY_MILLIS = 3_000L } } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt index 1d30bfc12..e281300bc 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt @@ -176,6 +176,8 @@ fun ChatHomeScreen( onSelectRuntime: (String) -> Unit, onSelectModel: (String, String) -> Unit, onSelectAgent: (String) -> Unit, + /** Asks the runtime for its agents again; the mode chip calls it when its list is empty. */ + onRefreshAgents: () -> Unit = {}, selectedVariant: String? = null, onSelectVariant: (String?) -> Unit = {}, attachments: List = emptyList(), @@ -644,6 +646,7 @@ fun ChatHomeScreen( agents = agents, selectedAgentId = selectedAgentId, onSelectAgent = onSelectAgent, + onRefreshAgents = onRefreshAgents, thinkingOptions = providers .firstOrNull { it.id == selectedProviderId } @@ -1238,6 +1241,7 @@ private fun ChatComposer( agents: List, selectedAgentId: String?, onSelectAgent: (String) -> Unit, + onRefreshAgents: () -> Unit, thinkingOptions: List, selectedVariant: String?, onSelectVariant: (String?) -> Unit, @@ -1601,6 +1605,7 @@ private fun ChatComposer( agents = agents, selectedAgentId = selectedAgentId, onSelect = onSelectAgent, + onRefresh = onRefreshAgents, ) // Auto-accept answers permission prompts, so it only means something on a runtime // that asks for permission. Antigravity decides permissions per invocation from its @@ -1869,6 +1874,7 @@ private fun ModeChip( agents: List, selectedAgentId: String?, onSelect: (String) -> Unit, + onRefresh: () -> Unit = {}, ) { var expanded by remember { mutableStateOf(false) } // Every primary agent the server reports is offered, not just build/plan: plugins such as @@ -1889,7 +1895,16 @@ private fun ModeChip( modifier = Modifier .widthIn(max = 92.dp) - .clickable(onClick = { expanded = true }), + .clickable( + onClick = { + // An empty list is a runtime that has not answered `GET /agent` yet (a + // local server still installing its plugins) or whose answer failed. + // Opening the menu then showed nothing, so the tap read as a dead + // button; ask again instead, and the menu has items next time. + if (selectableAgents.isEmpty()) onRefresh() else expanded = true + }, + ) + .testTag("chat-mode-chip"), shape = RoundedCornerShape(100.dp), color = MaterialTheme.colorScheme.surfaceVariant, contentColor = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt index ba6e60f34..4e3314c0e 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt @@ -1129,6 +1129,7 @@ fun AndCodeApp( }, onSelectModel = settingsViewModel::selectModel, onSelectAgent = settingsViewModel::selectAgent, + onRefreshAgents = app.catalogRepository::refreshAgents, selectedVariant = chatState.selectedVariant, onSelectVariant = chatViewModel::selectVariant, onAttach = { attachmentLauncher.launch("*/*") }, diff --git a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt index d157a8895..ce96cfacb 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt @@ -271,6 +271,53 @@ class RuntimeCatalogRepositoryTest { connected = emptyList(), ) + @Test + fun `agents that fail on the first read are retried until the runtime answers`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac") + // A local OpenCode installing its plugins: the first GET /agent fails, the next is empty. + target.agentAnswers.add { error("plugin load failed") } + target.agentAnswers.add { emptyList() } + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + + advanceUntilIdle() + + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + assertEquals(3, target.agentCalls) + assertNull(repository.state.value.error) + } + + @Test + fun `refreshing agents fills an empty picker without touching the rest of the catalogue`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac") + repeat(5) { target.agentAnswers.add { error("plugin load failed") } } + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + advanceUntilIdle() + assertTrue(repository.state.value.agents.isEmpty()) + val sessionsBefore = repository.state.value.sessions + + repository.refreshAgents() + advanceUntilIdle() + + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + assertEquals(sessionsBefore, repository.state.value.sessions) + } + private class FakeStore( profiles: List = listOf(profileStatic("mac")), override var selectedRuntimeId: String? = null, @@ -375,7 +422,14 @@ class RuntimeCatalogRepositoryTest { ) } - override suspend fun listAgents(): List = listOf(OpenCodeAgent("build", mode = "primary")) + /** Answers for successive [listAgents] calls; once used up, the default single `build`. */ + val agentAnswers = ArrayDeque<() -> List>() + var agentCalls = 0 + + override suspend fun listAgents(): List { + agentCalls++ + return agentAnswers.removeFirstOrNull()?.invoke() ?: listOf(OpenCodeAgent("build", mode = "primary")) + } override suspend fun listWorkspaces(): List = listOf( From f2f262ee4b98396211cb63b2d11b0cf9d4d4e943 Mon Sep 17 00:00:00 2001 From: lcy Date: Sat, 17 Oct 2026 18:06:33 +0800 Subject: [PATCH 2/7] fix(chat): retry agents outside the catalogue lock, keep a shown list Per review: the agent retry ran inside load()'s refresh lock, holding the whole catalogue (and every other refresh) behind up to several slow GET /agent calls. load() now reads agents once and publishes, then a single background retry (agentRetryJob) runs outside the lock. - a failed or empty agent read no longer blanks agents this runtime already showed (same rule as providers, #373) - repeated taps on an empty chip share one retry instead of stacking polls - a runtime that never connected gets a full refresh() from the chip - success means at least one selectable primary agent, matching the chip --- .../repository/RuntimeCatalogRepository.kt | 55 ++++++++++++++---- .../RuntimeCatalogRepositoryTest.kt | 56 ++++++++++++++++++- 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt index a048ba84f..7e5eb4b42 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt @@ -4,11 +4,13 @@ import com.yugahashimoto.andcode.core.api.OpenCodeAgent import com.yugahashimoto.andcode.core.api.OpenCodeHealth import com.yugahashimoto.andcode.core.api.OpenCodeSession import com.yugahashimoto.andcode.core.api.ProviderCatalog +import com.yugahashimoto.andcode.core.api.selectablePrimaryAgents import com.yugahashimoto.andcode.runtime.LocalAgent import com.yugahashimoto.andcode.runtime.RuntimeRegistry import com.yugahashimoto.andcode.runtime.RuntimeTarget import com.yugahashimoto.andcode.runtime.WorkspaceRef import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -59,6 +61,9 @@ class RuntimeCatalogRepository( val state: StateFlow = mutableState.asStateFlow() private val refreshMutex = Mutex() + /** The one agent retry in flight, so repeated taps on an empty mode chip do not stack polls. */ + @Volatile private var agentRetryJob: Job? = null + private val mutableAllSessions = MutableStateFlow>(emptyList()) /** @@ -200,7 +205,7 @@ class RuntimeCatalogRepository( supervisorScope { val sessions = async { runCatching { target.listSessions() } } val providers = async { runCatching { target.listProviders() } } - val agents = async { loadAgents(target) } + val agents = async { runCatching { target.listAgents() } } val workspaces = async { runCatching { target.listWorkspaces() } } LoadedCatalog( sessions = sessions.await(), @@ -235,22 +240,34 @@ class RuntimeCatalogRepository( } } val errors = catalog.failures(messages) + // Like the providers above: a read that failed or offered no agent to pick must not blank + // a list this runtime already showed, or a flaky `GET /agent` empties a working picker. + val loadedAgents = catalog.agents.getOrNull() + val agentsToUse = + if (loadedAgents.hasSelectableAgent()) { + loadedAgents.orEmpty() + } else { + mutableState.value.agentsFor(target).ifEmpty { loadedAgents.orEmpty() } + } mutableState.value = RuntimeCatalogState( runtime = target, health = connection.getOrNull(), sessions = catalog.sessions.getOrDefault(emptyList()), providers = providersToUse, - agents = catalog.agents.getOrDefault(emptyList()), + agents = agentsToUse, workspaces = catalog.workspaces.getOrDefault(emptyList()), isRefreshing = false, error = errors.takeIf { it.isNotEmpty() }?.joinToString("\n"), ) } + // Outside the lock: a local OpenCode installing its plugins can take tens of seconds per + // `GET /agent`, and the rest of the catalogue - and every other refresh - must not wait on it. + if (!mutableState.value.agentsFor(target).hasSelectableAgent()) refreshAgents() } /** - * The runtime's agents, retried a few times when the first answer is a failure or an empty list. + * The runtime's agents, retried a few times while the answer fails or offers nothing to pick. * * A local OpenCode with plugins (oh-my-opencode-slim, opencode-mem, ...) installs them on its * first `GET /agent` after a start, which can take tens of seconds or fail outright. That one @@ -260,7 +277,7 @@ class RuntimeCatalogRepository( private suspend fun loadAgents(target: RuntimeTarget): Result> { var result = runCatching { target.listAgents() } repeat(AGENT_RETRIES) { - if (result.getOrNull()?.isNotEmpty() == true || registry.selected.value?.id != target.id) return result + if (result.getOrNull().hasSelectableAgent() || registry.selected.value?.id != target.id) return result delay(AGENT_RETRY_DELAY_MILLIS) result = runCatching { target.listAgents() } } @@ -268,17 +285,26 @@ class RuntimeCatalogRepository( } /** - * Re-reads only the agent list, for a picker the user opened while it was empty. Leaves the rest - * of the catalogue alone and keeps any agents already on screen if this read fails too. + * Re-reads the agent list in the background until the runtime offers one to pick, for a load + * whose `GET /agent` failed or for a mode chip tapped while empty. + * + * Only one retry runs at a time. A runtime that never connected gets a full [refresh] instead, + * so the chip does not fill in agents over an otherwise empty, errored catalogue. The rest of the + * catalogue is left alone, and a failed retry keeps whatever agents are already on screen. */ fun refreshAgents() { val target = registry.selected.value ?: return - scope.launch { - val agents = loadAgents(target) - if (registry.selected.value?.id != target.id) return@launch - val list = agents.getOrNull()?.takeIf { it.isNotEmpty() } ?: return@launch - mutableState.update { if (it.runtime?.id == target.id) it.copy(agents = list) else it } + if (mutableState.value.runtime?.id == target.id && mutableState.value.health == null && !mutableState.value.isRefreshing) { + refresh() + return } + if (agentRetryJob?.isActive == true) return + agentRetryJob = + scope.launch { + val list = loadAgents(target).getOrNull()?.takeIf { it.hasSelectableAgent() } ?: return@launch + if (registry.selected.value?.id != target.id) return@launch + mutableState.update { if (it.runtime?.id == target.id) it.copy(agents = list) else it } + } } private data class LoadedCatalog( @@ -333,3 +359,10 @@ internal fun shouldReplaceProviderCatalog( current: ProviderCatalog, fresh: ProviderCatalog, ): Boolean = fresh.connected.isNotEmpty() || current.connected.isEmpty() + +/** The agents on screen, but only while the state still belongs to [target]; see [providersFor]. */ +private fun RuntimeCatalogState.agentsFor(target: RuntimeTarget): List = + if (runtime?.id == target.id) agents else emptyList() + +/** Whether this answer offers at least one agent the mode chip can show. */ +private fun List?.hasSelectableAgent(): Boolean = this?.selectablePrimaryAgents()?.isNotEmpty() == true diff --git a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt index ce96cfacb..9251f7d30 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt @@ -289,9 +289,10 @@ class RuntimeCatalogRepositoryTest { advanceUntilIdle() + // The load publishes after one read; the retry runs after it, outside the refresh lock. assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) assertEquals(3, target.agentCalls) - assertNull(repository.state.value.error) + assertFalse(repository.state.value.isRefreshing) } @Test @@ -299,7 +300,8 @@ class RuntimeCatalogRepositoryTest { runTest { val dispatcher = StandardTestDispatcher(testScheduler) val target = FakeTarget("mac") - repeat(5) { target.agentAnswers.add { error("plugin load failed") } } + // The load's own read plus its background retry (1 + 5 reads) all fail. + repeat(6) { target.agentAnswers.add { error("plugin load failed") } } val registry = RuntimeRegistry( store = FakeStore(selectedRuntimeId = "mac"), @@ -309,6 +311,7 @@ class RuntimeCatalogRepositoryTest { val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) advanceUntilIdle() assertTrue(repository.state.value.agents.isEmpty()) + assertEquals(6, target.agentCalls) val sessionsBefore = repository.state.value.sessions repository.refreshAgents() @@ -318,6 +321,55 @@ class RuntimeCatalogRepositoryTest { assertEquals(sessionsBefore, repository.state.value.sessions) } + @Test + fun `a refresh whose agent read fails keeps the agents already on screen`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac") + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + advanceUntilIdle() + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + + // Every read of the next refresh fails, retries included. + repeat(10) { target.agentAnswers.add { error("plugin load failed") } } + repository.refresh() + advanceUntilIdle() + + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + } + + @Test + fun `repeated agent refreshes run a single retry at a time`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac") + // The load and its background retry give up empty-handed (1 + 5 failed reads). + repeat(6) { target.agentAnswers.add { error("plugin load failed") } } + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + advanceUntilIdle() + val callsBefore = target.agentCalls + + assertTrue(repository.state.value.agents.isEmpty()) + + repeat(5) { repository.refreshAgents() } + advanceUntilIdle() + + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + assertEquals(callsBefore + 1, target.agentCalls) + } + private class FakeStore( profiles: List = listOf(profileStatic("mac")), override var selectedRuntimeId: String? = null, From 4cc8a3e5e5b6cb7e3a24cefde5188895ffab19f6 Mon Sep 17 00:00:00 2001 From: lcy Date: Sat, 17 Oct 2026 18:12:53 +0800 Subject: [PATCH 3/7] fix(chat): clear the agents error once the retry succeeds Per review: a first GET /agent failure put "Agents: ..." into the catalogue error, and the background retry only replaced the list, so the workspaces screen kept showing the failure after the agents had arrived. The agents line is kept separately and dropped when the retry fills the list. Also: a retry left over from a runtime the user switched away from is cancelled rather than blocking the new runtime's, and starting a retry is synchronized since taps (main) and load (IO) both reach it. --- .../repository/RuntimeCatalogRepository.kt | 44 +++++++++++++++---- .../RuntimeCatalogRepositoryTest.kt | 26 +++++++++++ 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt index 7e5eb4b42..4ac51e0a8 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt @@ -39,6 +39,8 @@ data class RuntimeCatalogState( val workspaces: List = emptyList(), val isRefreshing: Boolean = false, val error: String? = null, + /** The line of [error] that is about agents, so a later successful agent read can drop it. */ + val agentsError: String? = null, ) class RuntimeCatalogRepository( @@ -61,8 +63,12 @@ class RuntimeCatalogRepository( val state: StateFlow = mutableState.asStateFlow() private val refreshMutex = Mutex() - /** The one agent retry in flight, so repeated taps on an empty mode chip do not stack polls. */ - @Volatile private var agentRetryJob: Job? = null + /** + * The one agent retry in flight and the runtime it is for, so repeated taps on an empty mode + * chip do not stack polls. Guarded by [agentRetryLock]: taps (main) and load (IO) both start it. + */ + private var agentRetry: Pair? = null + private val agentRetryLock = Any() private val mutableAllSessions = MutableStateFlow>(emptyList()) @@ -259,6 +265,7 @@ class RuntimeCatalogRepository( workspaces = catalog.workspaces.getOrDefault(emptyList()), isRefreshing = false, error = errors.takeIf { it.isNotEmpty() }?.joinToString("\n"), + agentsError = catalog.agentsFailure(messages), ) } // Outside the lock: a local OpenCode installing its plugins can take tens of seconds per @@ -298,13 +305,22 @@ class RuntimeCatalogRepository( refresh() return } - if (agentRetryJob?.isActive == true) return - agentRetryJob = - scope.launch { - val list = loadAgents(target).getOrNull()?.takeIf { it.hasSelectableAgent() } ?: return@launch - if (registry.selected.value?.id != target.id) return@launch - mutableState.update { if (it.runtime?.id == target.id) it.copy(agents = list) else it } + synchronized(agentRetryLock) { + val running = agentRetry + if (running != null && running.second.isActive) { + // Still polling this runtime: let it finish. A retry left over from the runtime the + // user switched away from would only give up, so it must not block this one's. + if (running.first == target.id) return + running.second.cancel() } + agentRetry = + target.id to + scope.launch { + val list = loadAgents(target).getOrNull()?.takeIf { it.hasSelectableAgent() } ?: return@launch + if (registry.selected.value?.id != target.id) return@launch + mutableState.update { if (it.runtime?.id == target.id) it.withAgents(list) else it } + } + } } private data class LoadedCatalog( @@ -313,11 +329,14 @@ class RuntimeCatalogRepository( val agents: Result>, val workspaces: Result>, ) { + fun agentsFailure(messages: RuntimeCatalogMessages): String? = + agents.exceptionOrNull()?.let { messages.agents(it.safeMessage(messages.connectionFailed)) } + fun failures(messages: RuntimeCatalogMessages): List = listOfNotNull( sessions.exceptionOrNull()?.let { messages.sessions(it.safeMessage(messages.connectionFailed)) }, providers.exceptionOrNull()?.let { messages.providers(it.safeMessage(messages.connectionFailed)) }, - agents.exceptionOrNull()?.let { messages.agents(it.safeMessage(messages.connectionFailed)) }, + agentsFailure(messages), workspaces.exceptionOrNull()?.let { messages.workspaces(it.safeMessage(messages.connectionFailed)) }, ) } @@ -366,3 +385,10 @@ private fun RuntimeCatalogState.agentsFor(target: RuntimeTarget): List?.hasSelectableAgent(): Boolean = this?.selectablePrimaryAgents()?.isNotEmpty() == true + +/** This state with [agents] in place, and the earlier agents failure removed from [error]. */ +private fun RuntimeCatalogState.withAgents(agents: List): RuntimeCatalogState { + val remaining = + error?.lineSequence()?.filterNot { it == agentsError }?.joinToString("\n")?.takeIf { it.isNotBlank() } + return copy(agents = agents, error = remaining, agentsError = null) +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt index 9251f7d30..06103ebd0 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt @@ -293,6 +293,32 @@ class RuntimeCatalogRepositoryTest { assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) assertEquals(3, target.agentCalls) assertFalse(repository.state.value.isRefreshing) + // The load's "Agents: ..." failure no longer applies once the retry got them. + assertNull(repository.state.value.error) + } + + @Test + fun `an agent retry for a runtime that never connected refreshes the whole catalogue`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac", connectError = IllegalStateException("offline")) + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + advanceUntilIdle() + assertNull(repository.state.value.health) + val callsBefore = target.agentCalls + + repository.refreshAgents() + advanceUntilIdle() + + // A full refresh retried the connection instead of asking a dead server for agents. + assertEquals(callsBefore, target.agentCalls) + assertEquals("offline", repository.state.value.error) } @Test From e53d84d4ef0cea20089a1357a4f3e44efd8d8302 Mon Sep 17 00:00:00 2001 From: lcy Date: Sat, 17 Oct 2026 18:15:46 +0800 Subject: [PATCH 4/7] fix(chat): keep the agents error one line, leave early retries to load - decoding errors carry the JSON input on a second line, so the agents failure never matched a line of the joined error and stayed on screen after a successful retry; it is flattened to one line when recorded - a chip tap while the runtime is still connecting no longer starts its own agent poll ahead of the load, which retries the agents once connected --- .../data/repository/RuntimeCatalogRepository.kt | 15 ++++++++++++--- .../repository/RuntimeCatalogRepositoryTest.kt | 5 +++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt index 4ac51e0a8..aa5327e04 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt @@ -301,8 +301,11 @@ class RuntimeCatalogRepository( */ fun refreshAgents() { val target = registry.selected.value ?: return - if (mutableState.value.runtime?.id == target.id && mutableState.value.health == null && !mutableState.value.isRefreshing) { - refresh() + val current = mutableState.value + if (current.runtime?.id == target.id && current.health == null) { + // Not connected yet. A load still in flight will retry the agents itself once it + // connects; a load that gave up gets a full refresh, not a poll of a dead server. + if (!current.isRefreshing) refresh() return } synchronized(agentRetryLock) { @@ -329,8 +332,14 @@ class RuntimeCatalogRepository( val agents: Result>, val workspaces: Result>, ) { + /** + * One line, so [withAgents] can find it again in [RuntimeCatalogState.error]: decoding errors + * carry the JSON input on a second line, which a line-by-line removal would never match. + */ fun agentsFailure(messages: RuntimeCatalogMessages): String? = - agents.exceptionOrNull()?.let { messages.agents(it.safeMessage(messages.connectionFailed)) } + agents.exceptionOrNull()?.let { + messages.agents(it.safeMessage(messages.connectionFailed).replace(Regex("\\s*\\n\\s*"), " ")) + } fun failures(messages: RuntimeCatalogMessages): List = listOfNotNull( diff --git a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt index 06103ebd0..65a5036aa 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt @@ -276,8 +276,9 @@ class RuntimeCatalogRepositoryTest { runTest { val dispatcher = StandardTestDispatcher(testScheduler) val target = FakeTarget("mac") - // A local OpenCode installing its plugins: the first GET /agent fails, the next is empty. - target.agentAnswers.add { error("plugin load failed") } + // A local OpenCode installing its plugins: the first GET /agent fails (with a two-line + // message, as a decoding error has), the next is empty. + target.agentAnswers.add { error("Unexpected JSON token\nJSON input: [{\"hidden\":null}]") } target.agentAnswers.add { emptyList() } val registry = RuntimeRegistry( From 1454e1ee3d77a658abc42aad3f1361efa1c37e87 Mon Sep 17 00:00:00 2001 From: lcy Date: Sat, 17 Oct 2026 19:59:20 +0800 Subject: [PATCH 5/7] fix(chat): show the agent chip loading instead of ignoring taps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A local OpenCode with plugins takes ~25s on its first GET /agent after a start. During that time the mode chip had no agents and a tap only fired a silent background refresh, so the chip looked dead on every app start or runtime restart. Reproduced on an emulator with a proxy delaying the first /agent by 25s: the first 7 taps (26s) showed nothing. - RuntimeCatalogState.agentsLoading: true while agents are being read and none is on screen (load or the background retry), false once they arrive or the retries give up - the chip swaps its shield for a small spinner while loading - a tap always opens the menu: a disabled "Loading agents…" while loading, "Couldn't load agents. Tap to retry." once retries are over; agents that arrive while it is open replace the placeholder - strings in all 8 locales --- .../repository/RuntimeCatalogRepository.kt | 32 +++++++++++--- .../andcode/feature/chat/ChatHomeScreen.kt | 44 ++++++++++++++----- .../feature/settings/SettingsViewModel.kt | 3 ++ .../yugahashimoto/andcode/ui/AndCodeApp.kt | 1 + app/src/main/res/values-ar/strings.xml | 2 + app/src/main/res/values-es/strings.xml | 2 + app/src/main/res/values-fr/strings.xml | 2 + app/src/main/res/values-ja/strings.xml | 2 + app/src/main/res/values-pt-rBR/strings.xml | 2 + app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../RuntimeCatalogRepositoryTest.kt | 31 +++++++++++++ 13 files changed, 112 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt index aa5327e04..c70eb7c24 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt @@ -41,6 +41,12 @@ data class RuntimeCatalogState( val error: String? = null, /** The line of [error] that is about agents, so a later successful agent read can drop it. */ val agentsError: String? = null, + /** + * True while the agent list is being read and none is on screen yet: a local OpenCode installing + * its plugins can take tens of seconds on its first `GET /agent`, and the mode chip shows this + * instead of looking dead. + */ + val agentsLoading: Boolean = false, ) class RuntimeCatalogRepository( @@ -176,9 +182,14 @@ class RuntimeCatalogRepository( // as the fetch took — seconds, while a stopped runtime is retried — and OpenCode // models appeared under Claude Code. if (current.runtime?.id != target.id) { - RuntimeCatalogState(runtime = target, isRefreshing = true) + RuntimeCatalogState(runtime = target, isRefreshing = true, agentsLoading = true) } else { - current.copy(runtime = target, isRefreshing = true, error = null) + current.copy( + runtime = target, + isRefreshing = true, + error = null, + agentsLoading = !current.agents.hasSelectableAgent(), + ) } } @@ -266,6 +277,8 @@ class RuntimeCatalogRepository( isRefreshing = false, error = errors.takeIf { it.isNotEmpty() }?.joinToString("\n"), agentsError = catalog.agentsFailure(messages), + // Still loading when nothing selectable came back: the retry below takes over. + agentsLoading = !agentsToUse.hasSelectableAgent(), ) } // Outside the lock: a local OpenCode installing its plugins can take tens of seconds per @@ -316,12 +329,21 @@ class RuntimeCatalogRepository( if (running.first == target.id) return running.second.cancel() } + mutableState.update { + if (it.runtime?.id == target.id && !it.agents.hasSelectableAgent()) it.copy(agentsLoading = true) else it + } agentRetry = target.id to scope.launch { - val list = loadAgents(target).getOrNull()?.takeIf { it.hasSelectableAgent() } ?: return@launch + val list = loadAgents(target).getOrNull()?.takeIf { it.hasSelectableAgent() } if (registry.selected.value?.id != target.id) return@launch - mutableState.update { if (it.runtime?.id == target.id) it.withAgents(list) else it } + mutableState.update { + when { + it.runtime?.id != target.id -> it + list != null -> it.withAgents(list) + else -> it.copy(agentsLoading = false) + } + } } } } @@ -399,5 +421,5 @@ private fun List?.hasSelectableAgent(): Boolean = this?.selectabl private fun RuntimeCatalogState.withAgents(agents: List): RuntimeCatalogState { val remaining = error?.lineSequence()?.filterNot { it == agentsError }?.joinToString("\n")?.takeIf { it.isNotBlank() } - return copy(agents = agents, error = remaining, agentsError = null) + return copy(agents = agents, error = remaining, agentsError = null, agentsLoading = false) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt index e281300bc..91fac9769 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt @@ -178,6 +178,7 @@ fun ChatHomeScreen( onSelectAgent: (String) -> Unit, /** Asks the runtime for its agents again; the mode chip calls it when its list is empty. */ onRefreshAgents: () -> Unit = {}, + agentsLoading: Boolean = false, selectedVariant: String? = null, onSelectVariant: (String?) -> Unit = {}, attachments: List = emptyList(), @@ -647,6 +648,7 @@ fun ChatHomeScreen( selectedAgentId = selectedAgentId, onSelectAgent = onSelectAgent, onRefreshAgents = onRefreshAgents, + agentsLoading = agentsLoading, thinkingOptions = providers .firstOrNull { it.id == selectedProviderId } @@ -1242,6 +1244,7 @@ private fun ChatComposer( selectedAgentId: String?, onSelectAgent: (String) -> Unit, onRefreshAgents: () -> Unit, + agentsLoading: Boolean = false, thinkingOptions: List, selectedVariant: String?, onSelectVariant: (String?) -> Unit, @@ -1606,6 +1609,7 @@ private fun ChatComposer( selectedAgentId = selectedAgentId, onSelect = onSelectAgent, onRefresh = onRefreshAgents, + loading = agentsLoading, ) // Auto-accept answers permission prompts, so it only means something on a runtime // that asks for permission. Antigravity decides permissions per invocation from its @@ -1875,6 +1879,7 @@ private fun ModeChip( selectedAgentId: String?, onSelect: (String) -> Unit, onRefresh: () -> Unit = {}, + loading: Boolean = false, ) { var expanded by remember { mutableStateOf(false) } // Every primary agent the server reports is offered, not just build/plan: plugins such as @@ -1897,11 +1902,8 @@ private fun ModeChip( .widthIn(max = 92.dp) .clickable( onClick = { - // An empty list is a runtime that has not answered `GET /agent` yet (a - // local server still installing its plugins) or whose answer failed. - // Opening the menu then showed nothing, so the tap read as a dead - // button; ask again instead, and the menu has items next time. - if (selectableAgents.isEmpty()) onRefresh() else expanded = true + expanded = true + if (selectableAgents.isEmpty()) onRefresh() }, ) .testTag("chat-mode-chip"), @@ -1914,11 +1916,19 @@ private fun ModeChip( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Icon( - Icons.Outlined.Shield, - contentDescription = stringResource(R.string.cd_agent_mode), - modifier = Modifier.size(14.dp), - ) + if (loading && selectableAgents.isEmpty()) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + strokeWidth = 1.5.dp, + ) + } else { + Icon( + Icons.Outlined.Shield, + contentDescription = stringResource(R.string.cd_agent_mode), + modifier = Modifier.size(14.dp), + ) + } Text( label, style = MaterialTheme.typography.labelMedium, @@ -1933,6 +1943,20 @@ private fun ModeChip( } } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (selectableAgents.isEmpty()) { + DropdownMenuItem( + text = { + Text( + stringResource( + if (loading) R.string.chat_agents_loading else R.string.chat_agents_load_failed, + ), + ) + }, + onClick = onRefresh, + enabled = !loading, + modifier = if (loading) Modifier.testTag("chat-mode-loading") else Modifier, + ) + } selectableAgents.forEach { agent -> DropdownMenuItem( text = { Text(agent.name) }, diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt index e6c43af34..22623a018 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt @@ -51,6 +51,8 @@ data class SettingsUiState( val availableProviders: List = emptyList(), val connectedProviderIds: Set = emptySet(), val agents: List = emptyList(), + /** The selected runtime is still reading its agents; see [RuntimeCatalogState.agentsLoading]. */ + val agentsLoading: Boolean = false, val providerId: String? = null, val modelId: String? = null, val agentId: String? = null, @@ -217,6 +219,7 @@ class SettingsViewModel( availableProviders = managed.all, connectedProviderIds = (managed.connected.toSet() + oauth.locallyConnected) - oauth.locallyDisconnected, agents = core.runtime.agents.selectablePrimaryAgents(), + agentsLoading = core.runtime.agentsLoading, providerId = core.preferences.providerId, modelId = core.preferences.modelId, agentId = core.preferences.agentId, diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt index 4e3314c0e..648da9b05 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt @@ -1062,6 +1062,7 @@ fun AndCodeApp( state = chatState, providers = settingsState.providers, agents = settingsState.agents, + agentsLoading = settingsState.agentsLoading, selectedProviderId = chatState.selectedProviderId ?: fallbackProviderId, selectedModelId = chatState.selectedModelId ?: fallbackModelId, selectedAgentId = chatState.selectedAgentId ?: settingsState.agentId, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index b4a06946e..56c0e0014 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -484,6 +484,8 @@ الموجّه بناء تخطيط + جارٍ تحميل الوكلاء… + تعذّر تحميل الوكلاء. انقر لإعادة المحاولة. قبول تلقائي تلقائي %1$s tok/s diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c9002a930..ce776e667 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -478,6 +478,8 @@ Prompt Construir Planificar + Cargando agentes… + No se pudieron cargar los agentes. Toca para reintentar. Aceptación automática Auto %1$s tok/s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7794e2f48..8f9d5c997 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -478,6 +478,8 @@ Invite Construire Planifier + Chargement des agents… + Impossible de charger les agents. Touchez pour réessayer. Acceptation automatique Auto %1$s tok/s diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index fc319ecdb..7abc9ca82 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -476,6 +476,8 @@ プロンプト ビルド 計画 + エージェントを読み込み中… + エージェントを読み込めませんでした。タップして再試行。 自動承認 自動 %1$s tok/s diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 567c4d4e5..0e91f4dd7 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -478,6 +478,8 @@ Prompt Construir Planejar + Carregando agentes… + Não foi possível carregar os agentes. Toque para tentar novamente. Aceitação automática Auto %1$s tok/s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c54572b78..409c31c93 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -480,6 +480,8 @@ Промпт Сборка План + Загрузка агентов… + Не удалось загрузить агентов. Нажмите, чтобы повторить. Автоприём Авто %1$s tok/s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 47bc074cb..62fcd3213 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -474,6 +474,8 @@ 提示词 构建 计划 + 正在加载 agent… + 无法加载 agent,点按重试 自动接受 自动 %1$s tok/s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3ca369747..ff4496086 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -464,6 +464,8 @@ Prompt Build Plan + Loading agents… + Couldn\'t load agents. Tap to retry. Auto accept Auto %1$s tok/s diff --git a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt index 65a5036aa..76b62ed2b 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt @@ -397,6 +397,37 @@ class RuntimeCatalogRepositoryTest { assertEquals(callsBefore + 1, target.agentCalls) } + @Test + fun `agents read as loading until they arrive, and stop loading once retries give up`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac") + // The load's read and the whole background retry (1 + 5 reads) fail. + repeat(6) { target.agentAnswers.add { error("plugin load failed") } } + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + + // Before the first read lands the chip has nothing yet, so it reads as loading. + testScheduler.runCurrent() + assertTrue(repository.state.value.agentsLoading) + + advanceUntilIdle() + assertTrue(repository.state.value.agents.isEmpty()) + assertFalse(repository.state.value.agentsLoading) + + // A tap on the empty chip marks it loading straight away, before the retry has run. + repository.refreshAgents() + assertTrue(repository.state.value.agentsLoading) + advanceUntilIdle() + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + assertFalse(repository.state.value.agentsLoading) + } + private class FakeStore( profiles: List = listOf(profileStatic("mac")), override var selectedRuntimeId: String? = null, From e57c41dca39657eb6ba8453bc8f88ff4165f39b7 Mon Sep 17 00:00:00 2001 From: lcy Date: Sat, 17 Oct 2026 20:05:56 +0800 Subject: [PATCH 6/7] fix(chat): apply review on the agent loading state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - load()'s final write reads the agents inside the update, so a retry that succeeded between its fetch and write is no longer overwritten with an empty list (which also left the spinner running) - a retry tap on a never-connected runtime marks loading synchronously, so repeated taps cannot queue one full reconnect each - the spinner carries the loading content description for TalkBack - zh-CN uses 智能体 like the rest of the locale --- .../repository/RuntimeCatalogRepository.kt | 30 ++++++++++++------- .../andcode/feature/chat/ChatHomeScreen.kt | 5 +++- app/src/main/res/values-zh-rCN/strings.xml | 4 +-- .../RuntimeCatalogRepositoryTest.kt | 3 +- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt index c70eb7c24..7de791dab 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt @@ -260,13 +260,16 @@ class RuntimeCatalogRepository( // Like the providers above: a read that failed or offered no agent to pick must not blank // a list this runtime already showed, or a flaky `GET /agent` empties a working picker. val loadedAgents = catalog.agents.getOrNull() - val agentsToUse = - if (loadedAgents.hasSelectableAgent()) { - loadedAgents.orEmpty() - } else { - mutableState.value.agentsFor(target).ifEmpty { loadedAgents.orEmpty() } - } - mutableState.value = + // Read and written in one update: a background agent retry can land between this load's + // fetch and its write, and taking the list from before that would put an empty one back. + mutableState.update { current -> + val agentsToUse = + if (loadedAgents.hasSelectableAgent()) { + loadedAgents.orEmpty() + } else { + current.agentsFor(target).ifEmpty { loadedAgents.orEmpty() } + } + val agentsFound = agentsToUse.hasSelectableAgent() RuntimeCatalogState( runtime = target, health = connection.getOrNull(), @@ -276,10 +279,12 @@ class RuntimeCatalogRepository( workspaces = catalog.workspaces.getOrDefault(emptyList()), isRefreshing = false, error = errors.takeIf { it.isNotEmpty() }?.joinToString("\n"), - agentsError = catalog.agentsFailure(messages), + // A list kept from a retry that already succeeded carries no failure of its own. + agentsError = catalog.agentsFailure(messages).takeUnless { agentsFound && !loadedAgents.hasSelectableAgent() }, // Still loading when nothing selectable came back: the retry below takes over. - agentsLoading = !agentsToUse.hasSelectableAgent(), + agentsLoading = !agentsFound, ) + } } // Outside the lock: a local OpenCode installing its plugins can take tens of seconds per // `GET /agent`, and the rest of the catalogue - and every other refresh - must not wait on it. @@ -318,7 +323,12 @@ class RuntimeCatalogRepository( if (current.runtime?.id == target.id && current.health == null) { // Not connected yet. A load still in flight will retry the agents itself once it // connects; a load that gave up gets a full refresh, not a poll of a dead server. - if (!current.isRefreshing) refresh() + if (!current.isRefreshing) { + // Disables the "tap to retry" item straight away, so repeated taps cannot queue one + // full reconnect each behind the refresh lock before the load gets to run. + mutableState.update { if (it.runtime?.id == target.id) it.copy(agentsLoading = true) else it } + refresh() + } return } synchronized(agentRetryLock) { diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt index 91fac9769..c56a8efb1 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt @@ -112,6 +112,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle @@ -1917,8 +1919,9 @@ private fun ModeChip( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { if (loading && selectableAgents.isEmpty()) { + val loadingDescription = stringResource(R.string.chat_agents_loading) CircularProgressIndicator( - modifier = Modifier.size(14.dp), + modifier = Modifier.size(14.dp).semantics { contentDescription = loadingDescription }, color = MaterialTheme.colorScheme.onSurfaceVariant, strokeWidth = 1.5.dp, ) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 62fcd3213..cc7bb8af1 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -474,8 +474,8 @@ 提示词 构建 计划 - 正在加载 agent… - 无法加载 agent,点按重试 + 正在加载智能体… + 无法加载智能体,点按重试。 自动接受 自动 %1$s tok/s diff --git a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt index 76b62ed2b..a17f5cea0 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt @@ -412,7 +412,8 @@ class RuntimeCatalogRepositoryTest { ) val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) - // Before the first read lands the chip has nothing yet, so it reads as loading. + // The load's own read has failed and published; the background retry is still to come, + // so the chip has nothing to pick yet and reads as loading. testScheduler.runCurrent() assertTrue(repository.state.value.agentsLoading) From f66bb973d5482cbadad106263e04eb3515d383f8 Mon Sep 17 00:00:00 2001 From: lcy Date: Sun, 18 Oct 2026 02:20:29 +0800 Subject: [PATCH 7/7] fix(chat): drop the agents error line with a kept list, keep a racing load loading Apply the pre-PR review on the agent retry: - a load whose GET /agent fails but keeps the list already on screen no longer leaves an "Agents: ..." line in the catalogue error, which nothing would clear until the next refresh - a background retry that gives up while a load of the same runtime is still fetching keeps the chip loading instead of offering a retry that would race the load - the chip itself does not ask for a refresh while it is loading --- .../data/repository/RuntimeCatalogRepository.kt | 14 ++++++++++---- .../andcode/feature/chat/ChatHomeScreen.kt | 2 +- .../repository/RuntimeCatalogRepositoryTest.kt | 3 +++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt index 7de791dab..e6a3c9867 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt @@ -256,7 +256,6 @@ class RuntimeCatalogRepository( } } } - val errors = catalog.failures(messages) // Like the providers above: a read that failed or offered no agent to pick must not blank // a list this runtime already showed, or a flaky `GET /agent` empties a working picker. val loadedAgents = catalog.agents.getOrNull() @@ -270,6 +269,12 @@ class RuntimeCatalogRepository( current.agentsFor(target).ifEmpty { loadedAgents.orEmpty() } } val agentsFound = agentsToUse.hasSelectableAgent() + // A list kept on screen - from an earlier load or a retry that already succeeded - + // carries no failure of its own, so neither the chip nor the "Agents: ..." line in + // the catalogue error reports this read's failure. + val readFailure = catalog.agentsFailure(messages) + val agentsFailure = readFailure.takeUnless { agentsFound && !loadedAgents.hasSelectableAgent() } + val errors = catalog.failures(messages).filterNot { agentsFailure == null && it == readFailure } RuntimeCatalogState( runtime = target, health = connection.getOrNull(), @@ -279,8 +284,7 @@ class RuntimeCatalogRepository( workspaces = catalog.workspaces.getOrDefault(emptyList()), isRefreshing = false, error = errors.takeIf { it.isNotEmpty() }?.joinToString("\n"), - // A list kept from a retry that already succeeded carries no failure of its own. - agentsError = catalog.agentsFailure(messages).takeUnless { agentsFound && !loadedAgents.hasSelectableAgent() }, + agentsError = agentsFailure, // Still loading when nothing selectable came back: the retry below takes over. agentsLoading = !agentsFound, ) @@ -351,7 +355,9 @@ class RuntimeCatalogRepository( when { it.runtime?.id != target.id -> it list != null -> it.withAgents(list) - else -> it.copy(agentsLoading = false) + // A load still in flight for this runtime keeps the chip loading: it reads the + // agents again itself, and a "tap to retry" now would only race it. + else -> it.copy(agentsLoading = it.isRefreshing) } } } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt index c56a8efb1..a374271f8 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt @@ -1905,7 +1905,7 @@ private fun ModeChip( .clickable( onClick = { expanded = true - if (selectableAgents.isEmpty()) onRefresh() + if (selectableAgents.isEmpty() && !loading) onRefresh() }, ) .testTag("chat-mode-chip"), diff --git a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt index a17f5cea0..4247b1cab 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt @@ -369,6 +369,9 @@ class RuntimeCatalogRepositoryTest { advanceUntilIdle() assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + // The kept list is not this read's: no agents failure on the chip or in the catalogue error. + assertNull(repository.state.value.agentsError) + assertNull(repository.state.value.error) } @Test