Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,6 +39,14 @@ data class RuntimeCatalogState(
val workspaces: List<WorkspaceRef> = 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,
/**
* 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(
Expand All @@ -59,6 +69,13 @@ class RuntimeCatalogRepository(
val state: StateFlow<RuntimeCatalogState> = mutableState.asStateFlow()
private val refreshMutex = Mutex()

/**
* 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<String, Job>? = null
private val agentRetryLock = Any()

private val mutableAllSessions = MutableStateFlow<List<RuntimeSessionRef>>(emptyList())

/**
Expand Down Expand Up @@ -165,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(),
)
}
}

Expand Down Expand Up @@ -234,18 +256,111 @@ class RuntimeCatalogRepository(
}
}
}
val errors = catalog.failures(messages)
mutableState.value =
// 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()
// 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()
// 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(),
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"),
agentsError = agentsFailure,
// Still loading when nothing selectable came back: the retry below takes over.
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.
if (!mutableState.value.agentsFor(target).hasSelectableAgent()) refreshAgents()
}

/**
* 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
* 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<List<OpenCodeAgent>> {
var result = runCatching { target.listAgents() }
repeat(AGENT_RETRIES) {
if (result.getOrNull().hasSelectableAgent() || registry.selected.value?.id != target.id) return result
delay(AGENT_RETRY_DELAY_MILLIS)
result = runCatching { target.listAgents() }
}
return result
}

/**
* 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
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) {
// 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) {
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()
}
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() }
if (registry.selected.value?.id != target.id) return@launch
mutableState.update {
when {
it.runtime?.id != target.id -> it
list != null -> it.withAgents(list)
// 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)
}
}
}
}
}

Expand All @@ -255,18 +370,29 @@ class RuntimeCatalogRepository(
val agents: Result<List<OpenCodeAgent>>,
val workspaces: Result<List<WorkspaceRef>>,
) {
/**
* 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).replace(Regex("\\s*\\n\\s*"), " "))
}

fun failures(messages: RuntimeCatalogMessages): List<String> =
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)) },
)
}

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

Expand Down Expand Up @@ -299,3 +425,17 @@ 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<OpenCodeAgent> =
if (runtime?.id == target.id) agents else emptyList()

/** Whether this answer offers at least one agent the mode chip can show. */
private fun List<OpenCodeAgent>?.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<OpenCodeAgent>): RuntimeCatalogState {
val remaining =
error?.lineSequence()?.filterNot { it == agentsError }?.joinToString("\n")?.takeIf { it.isNotBlank() }
return copy(agents = agents, error = remaining, agentsError = null, agentsLoading = false)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -176,6 +178,9 @@ 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 = {},
agentsLoading: Boolean = false,
selectedVariant: String? = null,
onSelectVariant: (String?) -> Unit = {},
attachments: List<PromptAttachment> = emptyList(),
Expand Down Expand Up @@ -644,6 +649,8 @@ fun ChatHomeScreen(
agents = agents,
selectedAgentId = selectedAgentId,
onSelectAgent = onSelectAgent,
onRefreshAgents = onRefreshAgents,
agentsLoading = agentsLoading,
thinkingOptions =
providers
.firstOrNull { it.id == selectedProviderId }
Expand Down Expand Up @@ -1238,6 +1245,8 @@ private fun ChatComposer(
agents: List<OpenCodeAgent>,
selectedAgentId: String?,
onSelectAgent: (String) -> Unit,
onRefreshAgents: () -> Unit,
agentsLoading: Boolean = false,
thinkingOptions: List<String>,
selectedVariant: String?,
onSelectVariant: (String?) -> Unit,
Expand Down Expand Up @@ -1601,6 +1610,8 @@ private fun ChatComposer(
agents = agents,
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
Expand Down Expand Up @@ -1869,6 +1880,8 @@ private fun ModeChip(
agents: List<OpenCodeAgent>,
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
Expand All @@ -1889,7 +1902,13 @@ private fun ModeChip(
modifier =
Modifier
.widthIn(max = 92.dp)
.clickable(onClick = { expanded = true }),
.clickable(
onClick = {
expanded = true
if (selectableAgents.isEmpty() && !loading) onRefresh()
},
)
.testTag("chat-mode-chip"),
shape = RoundedCornerShape(100.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
Expand All @@ -1899,11 +1918,20 @@ 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()) {
val loadingDescription = stringResource(R.string.chat_agents_loading)
CircularProgressIndicator(
modifier = Modifier.size(14.dp).semantics { contentDescription = loadingDescription },
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,
Expand All @@ -1918,6 +1946,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) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ data class SettingsUiState(
val availableProviders: List<OpenCodeProvider> = emptyList(),
val connectedProviderIds: Set<String> = emptySet(),
val agents: List<OpenCodeAgent> = 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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1129,6 +1130,7 @@ fun AndCodeApp(
},
onSelectModel = settingsViewModel::selectModel,
onSelectAgent = settingsViewModel::selectAgent,
onRefreshAgents = app.catalogRepository::refreshAgents,
selectedVariant = chatState.selectedVariant,
onSelectVariant = chatViewModel::selectVariant,
onAttach = { attachmentLauncher.launch("*/*") },
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values-ar/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,8 @@
<string name="chat_system_prompt_default">الموجّه</string>
<string name="chat_mode_build">بناء</string>
<string name="chat_mode_plan">تخطيط</string>
<string name="chat_agents_loading">جارٍ تحميل الوكلاء…</string>
<string name="chat_agents_load_failed">تعذّر تحميل الوكلاء. انقر لإعادة المحاولة.</string>
<string name="chat_mode_auto_accept">قبول تلقائي</string>
<string name="chat_auto_accept_short">تلقائي</string>
<string name="chat_token_speed">%1$s tok/s</string>
Expand Down
Loading
Loading