* 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:
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()
|
||||
}
|
||||
Reference in new issue
Block a user