feat(chat): /resume and /history open the session history tab (issue #864) (#876)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-10 21:43:33 +03:00
1 parent f931565c0d
commit b05cffa9c4
6 files changed
+99 -1

No files matched your search

@@ -43,7 +43,8 @@ object CommandBlocklist {
setOf(
"/clear",
"/redraw",
"/history",
// "/history" is NOT blocked — it's handled client-side (issue
// #864) by opening the session history tab.
"/save",
"/prompt",
"/snapshot",
@@ -74,6 +74,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.HistoryScreen
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
@@ -193,6 +194,15 @@ fun ChatScreen(
}
}
// /resume · /history (issue #864): open the session history tab so the
// user picks a past session — client-side, no gateway round-trip.
LaunchedEffect(state.openHistoryRequested) {
if (state.openHistoryRequested) {
NavigationController.navigateTo(HistoryScreen)
viewModel.consumeOpenHistoryRequest()
}
}
// Arm + trigger older-message paging when the user scrolls near the top.
// Before prepending, capture the anchor (first visible message id + offset)
// so the same content stays under the reader's eye after the insert
@@ -279,6 +279,9 @@ data class ChatUiState(
// #865). Empty until the local store loads; commands without recorded
// usage keep their catalog order.
val slashUsageCounts: Map<String, Int> = emptyMap(),
// Transient nav request from /resume and /history (issue #864): the
// screen consumes it by navigating to the history tab, then clears it.
val openHistoryRequested: Boolean = false,
// In-session model picker (issue #589) — surfaced when the user types /model
// (or taps the top-bar model chip). Mirror of the global model screen's
// picker, but the selection hot-swaps the CURRENT session via the slash path.
@@ -1435,6 +1438,13 @@ class ChatViewModel(
openUpdateConfirm()
}
is SlashResult.OpenHistory -> {
// Client-side: open the session history tab so the user can
// pick a past session to resume (issue #864) — no gateway
// round-trip (the backend slash worker can't answer /resume).
_uiState.update { it.copy(openHistoryRequested = true) }
}
is SlashResult.RpcDispatch -> {
dispatchViaRpc(command)
}
@@ -3231,6 +3241,11 @@ class ChatViewModel(
_uiState.update { it.copy(backgroundCompleteMessage = null) }
}
/** Consume the /resume · /history navigation request (issue #864). */
fun consumeOpenHistoryRequest() {
_uiState.update { it.copy(openHistoryRequested = false) }
}
// ── Approval flow ───────────────────────────────────────────────────
private fun handleApprovalRequest(event: WsEvent.ApprovalRequest) {
@@ -31,6 +31,7 @@ class SlashCommandDispatcher {
"/fork", "/branch" -> SlashResult.SessionBranch
"/model" -> SlashResult.ModelSwitch
"/update" -> SlashResult.Update
"/resume", "/history" -> SlashResult.OpenHistory
else -> SlashResult.RpcDispatch
}
}
@@ -71,4 +72,12 @@ sealed class SlashResult {
* after 45s (issue #862).
*/
data object Update : SlashResult()
/**
* Open the session history tab client-side (issue #864). /resume and
* /history are CLI-flavored interactive commands the mobile gateway path
* can't answer usefully — same class of problem as /update (#862). The
* user picks a past session from history and resumes it from there.
*/
data object OpenHistory : SlashResult()
}
@@ -446,6 +446,58 @@ class ChatViewModelTest {
}
}
// ── /resume · /history open the history tab (issue #864) ────────────────
@Test
fun slashCommand_resume_requestsHistoryNavigationWithoutGateway() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
viewModel.sendMessage("/resume")
advanceUntilIdle()
assertTrue(
"openHistoryRequested must be set for the screen to navigate",
viewModel.uiState.value.openHistoryRequested,
)
// Client-side only: no gateway round-trip for /resume.
verify(exactly = 0) {
HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any())
}
verify(exactly = 0) {
HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any())
}
}
@Test
fun slashCommand_history_requestsHistoryNavigation() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
viewModel.sendMessage("/history")
advanceUntilIdle()
assertTrue(viewModel.uiState.value.openHistoryRequested)
verify(exactly = 0) {
HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any())
}
}
@Test
fun consumeOpenHistoryRequest_clearsFlag() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
viewModel.sendMessage("/resume")
advanceUntilIdle()
assertTrue(viewModel.uiState.value.openHistoryRequested)
viewModel.consumeOpenHistoryRequest()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.openHistoryRequested)
}
@Test
fun testSlashCommand_unknown_showsErrorMessage() =
runTest {
@@ -63,6 +63,17 @@ class SlashCommandDispatcherTest {
assertEquals(SlashResult.Update, dispatcher.dispatch("/Update"))
}
@Test
fun `resume and history route to OpenHistory`() {
// /resume and /history are CLI-flavored interactive commands the
// mobile gateway path can't answer — handled client-side by opening
// the session history tab (issue #864).
assertEquals(SlashResult.OpenHistory, dispatcher.dispatch("/resume"))
assertEquals(SlashResult.OpenHistory, dispatcher.dispatch("/RESUME"))
assertEquals(SlashResult.OpenHistory, dispatcher.dispatch("/history"))
assertEquals(SlashResult.OpenHistory, dispatcher.dispatch("/History"))
}
@Test
fun `NEW uppercase still routes to NewSession`() {
// Dispatcher lower-cases before matching, so case must not matter.