diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt index 541795eb..0e0fb7c4 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt @@ -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", diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 13a6c615..1034ff1d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 88a1feb6..abaa75b6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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 = 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) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt index 1c641999..aea64bc5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt @@ -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() } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index eedfcaea..65807b67 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -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 { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt index 659dcbd2..1559caed 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt @@ -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.