fix(chat): /update handled client-side with progress popup (issue #862) (#873)

* fix(chat): /update handled client-side with progress popup (issue #862)

* test(chat): stub actionProgress controller in ChatScreenTest (issue #862)

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-10 19:30:32 +03:00
1 parent 96e43f354f
commit 5ab7ad16df
6 files changed
+363 -2

No files matched your search

@@ -37,6 +37,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -46,6 +47,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@@ -94,6 +96,7 @@ import com.m57.hermescontrol.ui.chat.components.SearchBarRow
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import com.m57.hermescontrol.ui.chat.components.tailContentKey
import com.m57.hermescontrol.ui.common.ActionProgressDialog
import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -717,6 +720,33 @@ fun ChatScreen(
)
}
// /update from chat (issue #862): confirm, then the shared progress
// popup tracks the background update (live log tail + final state).
if (state.updateConfirmOpen) {
AlertDialog(
onDismissRequest = viewModel::closeUpdateConfirm,
title = { Text(stringResource(R.string.system_update_confirm_title)) },
text = { Text(stringResource(R.string.system_update_confirm_desc)) },
confirmButton = {
TextButton(onClick = {
viewModel.closeUpdateConfirm()
viewModel.applyUpdate()
}) {
Text(stringResource(R.string.system_confirm_update_now))
}
},
dismissButton = {
TextButton(onClick = viewModel::closeUpdateConfirm) {
Text(stringResource(R.string.system_confirm_cancel))
}
},
)
}
ActionProgressDialog(
controller = viewModel.actionProgress,
title = stringResource(R.string.system_update_progress_title),
)
// In-session model picker (issue #589) — opens on "/model".
if (state.showModelPicker) {
ModelPickerDialog(
@@ -32,6 +32,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ActionProgressController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -261,6 +262,8 @@ data class ChatUiState(
val sudoPrompt: SudoPromptUi? = null,
val secretPrompt: SecretPromptUi? = null,
val showSessionPicker: Boolean = false,
// /update confirm dialog (issue #862) — the command is handled client-side
val updateConfirmOpen: Boolean = false,
// Search state
val isSearchActive: Boolean = false,
val searchQuery: String = "",
@@ -454,6 +457,14 @@ class ChatViewModel(
// ── Session persistence ──────────────────────────────────────────────
private val repo: ChatPersistenceRepository = repo
private val slashDispatcher = SlashCommandDispatcher()
/**
* Progress popup for `/update` from chat (issue #862). The backend `/update`
* handler is interactive + session-exiting and can never answer the slash
* worker (45s timeout), so the command is intercepted client-side and
* routed through the same REST action + shared popup as the System screen.
*/
val actionProgress = ActionProgressController(scope = viewModelScope)
private val searchDelegate =
ChatSearchDelegate(
scope = viewModelScope,
@@ -1396,12 +1407,59 @@ class ChatViewModel(
handleModelSwitch(command)
}
is SlashResult.Update -> {
openUpdateConfirm()
}
is SlashResult.RpcDispatch -> {
dispatchViaRpc(command)
}
}
}
// ── Update from chat (issue #862) ────────────────────────────────────
// `/update` can't travel via the slash worker (the backend handler is
// interactive + session-exiting → guaranteed 45s timeout). Intercept it
// client-side: confirm, then trigger the same REST action the System
// screen uses and track it in the shared ActionProgressDialog.
fun openUpdateConfirm() {
_uiState.update { it.copy(updateConfirmOpen = true) }
}
fun closeUpdateConfirm() {
_uiState.update { it.copy(updateConfirmOpen = false) }
}
/**
* Run the backend update: `POST /api/hermes/update` returns immediately
* (`{ok, name}`) while `hermes update` runs in the background, so
* [actionProgress] polls its status log until it exits and the popup shows
* the live tail + final state.
*/
fun applyUpdate() {
actionProgress.open()
viewModelScope.launch(ioDispatcher) {
val result = safeApiCall { ApiClient.hermesApi.updateHermes() }
when (result) {
is NetworkResult.Success -> {
val name = result.data.name
if (name != null) {
actionProgress.markStarted(name)
} else {
actionProgress.fail(
"Update started but the backend did not report an action name",
)
}
}
is NetworkResult.Failure -> {
actionProgress.fail("Failed to start update: ${result.error.message}")
}
}
}
}
/**
* Fork the active conversation via the session.branch WS RPC (issue #533).
* The backend already supports session.branch; the mobile previously had
@@ -13,8 +13,12 @@ package com.m57.hermescontrol.ui.chat
* get their own results: `/fork` goes via the `session.branch` RPC and `/model`
* via the `config.set` RPC (key="model" → gateway `_apply_model_switch`; the
* TUI gateway's `prompt.submit` does NOT parse slash commands, so sending it as
* a normal prompt makes the LLM treat it as text). Everything else is forwarded
* to the backend via [SlashResult.RpcDispatch].
* a normal prompt makes the LLM treat it as text). `/update` is intercepted
* too: the backend handler is interactive + session-exiting (confirmation modal
* + relaunch as `hermes update`), so it can never produce the single response
* the slash worker waits for and always dies with a 45s "slash worker timed
* out" (issue #862). Everything else is forwarded to the backend via
* [SlashResult.RpcDispatch].
*/
class SlashCommandDispatcher {
fun dispatch(command: String): SlashResult {
@@ -26,6 +30,7 @@ class SlashCommandDispatcher {
"/new" -> SlashResult.NewSession
"/fork", "/branch" -> SlashResult.SessionBranch
"/model" -> SlashResult.ModelSwitch
"/update" -> SlashResult.Update
else -> SlashResult.RpcDispatch
}
}
@@ -55,4 +60,15 @@ sealed class SlashResult {
* `/model <model> --provider <slug> --session`.
*/
data object ModelSwitch : SlashResult()
/**
* Trigger the backend update via the REST action API
* (`POST /api/hermes/update`, the System screen's flow) and track it in
* the shared [com.m57.hermescontrol.ui.common.ActionProgressDialog].
* NOT sent via slash.exec — the backend `/update` handler is interactive +
* session-exiting (confirmation modal + relaunch), so it can never produce
* the single WS response the slash worker waits for and always times out
* after 45s (issue #862).
*/
data object Update : SlashResult()
}