diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index 2139218c..1112d746 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -12,6 +12,8 @@ data class ServerStoreState( val port: Int = 9119, val baseUrl: String? = null, val autoReconnect: Boolean = true, + val restoreLastSession: Boolean = false, + val lastOpenedSessionIds: Map = emptyMap(), val themePreference: ThemePreference = ThemePreference.SYSTEM, val useDynamicColors: Boolean = false, val themePreset: ThemePreset = ThemePreset.DEFAULT, diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 33a15a4f..2cf351ff 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -616,6 +616,47 @@ object AuthManager { serverStore.update { it.copy(autoReconnect = enabled) } } + // ── Restore last session on startup (issue #1102) ───────────────────── + + private fun currentSessionScopeKey(): String { + val connId = getSelectedProfileId()?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID + val serverProfile = activeProfileId.value?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID + return "$connId:$serverProfile" + } + + fun isRestoreLastSession(): Boolean = serverStore.getLatestState().restoreLastSession + + fun setRestoreLastSession(enabled: Boolean) { + serverStore.update { it.copy(restoreLastSession = enabled) } + } + + fun getLastOpenedSessionId(): String? = serverStore.getLatestState().lastOpenedSessionIds[currentSessionScopeKey()] + + fun setLastOpenedSessionId(sessionId: String?) { + val key = currentSessionScopeKey() + serverStore.update { state -> + val updated = + if (sessionId != null) { + state.lastOpenedSessionIds + (key to sessionId) + } else { + state.lastOpenedSessionIds - key + } + state.copy(lastOpenedSessionIds = updated) + } + } + + fun clearLastOpenedSessionId() { + setLastOpenedSessionId(null) + } + + fun clearLastOpenedSessionIdsForConnection(connectionProfileId: String) { + serverStore.update { state -> + val prefix = "$connectionProfileId:" + val updated = state.lastOpenedSessionIds.filterKeys { !it.startsWith(prefix) } + state.copy(lastOpenedSessionIds = updated) + } + } + // ── Theme preference ────────────────────────────────────────────────── fun getThemePreference(): ThemePreference = serverStore.getLatestState().themePreference 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 52438642..1c23f3b8 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 @@ -677,6 +677,13 @@ class ChatViewModel( if (!initial.isNullOrBlank()) { initialSessionId = null switchSession(initial) + } else if (AuthManager.isRestoreLastSession()) { + val restoredId = AuthManager.getLastOpenedSessionId() + if (!restoredId.isNullOrBlank()) { + switchSession(restoredId) + } else { + createNewSession(setLoading = false) + } } else { createNewSession(setLoading = false) } @@ -1406,6 +1413,7 @@ class ChatViewModel( userMessage: ChatMessage? = null, ) { val dispatchGeneration = sessionGeneration + AuthManager.setLastOpenedSessionId(storageSessionId) val msgToPersist = userMessage ?: ChatMessage( role = MessageRole.USER, @@ -2356,6 +2364,7 @@ class ChatViewModel( .find { it.id == sessionId } ?.title ?: "Hermes" val generation = resetSessionState(sessionId, title, isLoading = true) + AuthManager.setLastOpenedSessionId(sessionId) viewModelScope.launch { // Warm-cache fast-path (desktop parity): paint the cached Room // transcript immediately so the screen never sits blank, then load @@ -2708,6 +2717,9 @@ class ChatViewModel( } sessionGoneRecoveryInFlight = true cancelResumeRetry() + if (AuthManager.getLastOpenedSessionId() == sessionId) { + AuthManager.clearLastOpenedSessionId() + } _uiState.update { it.copy( isLoading = false, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 6b053b09..bf8b0135 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.sessions import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.BulkDeleteRequest import com.m57.hermescontrol.data.model.PruneRequest import com.m57.hermescontrol.data.model.SessionInfo @@ -689,6 +690,9 @@ class SessionsViewModel( } when (result) { is NetworkResult.Success -> { + if (AuthManager.getLastOpenedSessionId() == sessionId) { + AuthManager.clearLastOpenedSessionId() + } _uiState.update { val updatedSessions = it.sessions.filter { s -> s.id != sessionId } val newTotal = (it.total - 1).coerceAtLeast(0) @@ -739,6 +743,10 @@ class SessionsViewModel( } when (result) { is NetworkResult.Success -> { + val lastOpened = AuthManager.getLastOpenedSessionId() + if (lastOpened != null && ids.contains(lastOpened)) { + AuthManager.clearLastOpenedSessionId() + } val deletedCount = result.data.deleted val toastMsg = if (deletedCount > 0) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index 67d8d42b..071af035 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -113,7 +113,7 @@ fun SettingsScreen( icon = Icons.Filled.Tune, label = stringResource(R.string.settings_sec_behavior), summary = - if (state.autoReconnect) { + if (state.autoReconnect || state.restoreLastSession) { stringResource(R.string.settings_summary_on) } else { stringResource(R.string.settings_summary_off) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt index db5a0e92..561072be 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -227,6 +227,8 @@ internal fun SettingsBehaviorPage( BehaviorSection( autoReconnect = state.autoReconnect, onAutoReconnectChange = viewModel::onAutoReconnectChange, + restoreLastSession = state.restoreLastSession, + onRestoreLastSessionChange = viewModel::onRestoreLastSessionChange, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 20dd7f6b..594da4d2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -28,6 +28,7 @@ data class SettingsUiState( val transportWarning: String? = null, val token: String = "", val autoReconnect: Boolean = true, + val restoreLastSession: Boolean = false, val themePreference: ThemePreference = ThemePreference.SYSTEM, val useDynamicColors: Boolean = false, val themePreset: ThemePreset = ThemePreset.DEFAULT, @@ -72,6 +73,7 @@ class SettingsViewModel( val baseUrl = AuthManager.getBaseUrl() val token = AuthManager.getToken() ?: "" val autoReconnect = AuthManager.isAutoReconnect() + val restoreLastSession = AuthManager.isRestoreLastSession() val themePreference = AuthManager.getThemePreference() val useDynamicColors = AuthManager.isUseDynamicColors() val themePreset = AuthManager.getThemePreset() @@ -92,6 +94,7 @@ class SettingsViewModel( transportWarning = transportWarning, token = token, autoReconnect = autoReconnect, + restoreLastSession = restoreLastSession, themePreference = themePreference, useDynamicColors = useDynamicColors, themePreset = themePreset, @@ -120,6 +123,7 @@ class SettingsViewModel( val updatedProfiles = AuthManager.getConnectionProfiles().filter { it.id != profileId } AuthManager.saveConnectionProfiles(updatedProfiles) AuthManager.setProfileToken(profileId, null) + AuthManager.clearLastOpenedSessionIdsForConnection(profileId) // Never leave selection null (issue #478): if the deleted profile was selected, // fall back to the default profile instead of clearing selection. val reselectDefault = @@ -283,6 +287,11 @@ class SettingsViewModel( AuthManager.setAutoReconnect(enabled) } + fun onRestoreLastSessionChange(enabled: Boolean) { + _uiState.update { it.copy(restoreLastSession = enabled, isSaved = false) } + AuthManager.setRestoreLastSession(enabled) + } + fun onThemeChange(theme: ThemePreference) { _uiState.update { it.copy(themePreference = theme, isSaved = false) } AuthManager.setThemePreference(theme) @@ -323,6 +332,7 @@ class SettingsViewModel( AuthManager.setToken(null) AuthManager.setSessionCookie(null) AuthManager.setWsAuthParam("token") + AuthManager.clearLastOpenedSessionId() HermesWsClient.disconnect(clearPendingMessages = true) // Don't rebuild ApiClient here — let the navigation complete first } @@ -337,6 +347,7 @@ class SettingsViewModel( AuthManager.setBaseUrl(normalized) AuthManager.setToken(state.token) AuthManager.setAutoReconnect(state.autoReconnect) + AuthManager.setRestoreLastSession(state.restoreLastSession) // B6 (Jun 18 2026, kanban t_86e9be9b): persist theme choice so it // survives a cold start (was previously dropped on save()). AuthManager.setThemePreference(state.themePreference) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt index e9417d77..1bb9bd17 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt @@ -3,17 +3,19 @@ package com.m57.hermescontrol.ui.settings.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.ui.settings.SectionCard @@ -21,6 +23,8 @@ import com.m57.hermescontrol.ui.settings.SectionCard internal fun BehaviorSection( autoReconnect: Boolean, onAutoReconnectChange: (Boolean) -> Unit, + restoreLastSession: Boolean, + onRestoreLastSessionChange: (Boolean) -> Unit, ) { SectionCard { Row( @@ -28,7 +32,7 @@ internal fun BehaviorSection( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Column { + Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(R.string.settings_item_auto_reconnect), style = MaterialTheme.typography.bodyLarge, @@ -51,5 +55,38 @@ internal fun BehaviorSection( ), ) } + + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_item_restore_session), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.settings_desc_restore_session), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Switch( + checked = restoreLastSession, + onCheckedChange = onRestoreLastSessionChange, + colors = + SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 1fabe321..44861de2 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -177,6 +177,8 @@ السلوك إعادة الاتصال التلقائي إعادة الاتصال تلقائيًا عند انقطاعه +استعادة الجلسة الأخيرة +إعادة فتح أحدث جلسة محادثة عند بدء التشغيل المظهر السمة المحادثة diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 631c47b6..32266616 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -180,6 +180,8 @@ 동작 자동 재연결 연결 끊김 시 자동 재연결 + 마지막 세션 복원 + 시작 시 가장 최근 채팅 세션을 다시 엽니다 테마 테마 채팅 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 3745a59c..af463661 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -177,6 +177,8 @@ 行为 自动重连 断开后自动重新连接 + 恢复上次会话 + 启动时重新打开最近的聊天会话 外观 主题 对话 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8b4897ef..1da97682 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -201,6 +201,8 @@ Behavior Auto-Reconnect Automatically reconnect on disconnect + Restore Last Session + Reopen the most recent chat session on startup Appearance Theme Chat diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt index 437cbe3a..da758fe5 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt @@ -90,6 +90,10 @@ class ChatUpdateCommandTest { every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.isRestoreLastSession() } returns false + every { AuthManager.getLastOpenedSessionId() } returns null + every { AuthManager.setLastOpenedSessionId(any()) } returns Unit + every { AuthManager.clearLastOpenedSessionId() } returns Unit every { HermesWsClient.events } returns mockEventsFlow every { HermesWsClient.connectionStatus } returns mockConnectionStatus every { HermesWsClient.connect() } answers { 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 ca845d81..1803c960 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 @@ -164,6 +164,10 @@ class ChatViewModelTest { every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.isRestoreLastSession() } returns false + every { AuthManager.getLastOpenedSessionId() } returns null + every { AuthManager.setLastOpenedSessionId(any()) } returns Unit + every { AuthManager.clearLastOpenedSessionId() } returns Unit every { HermesWsClient.events } returns mockEventsFlow every { HermesWsClient.connectionStatus } returns mockConnectionStatus every { HermesWsClient.connect() } answers { @@ -5083,4 +5087,75 @@ class ChatViewModelTest { assertTrue(updated.logs.any { it.text.contains("Stopped subagent") }) io.mockk.verify { HermesWsClient.sendRedirect(sessionId, "/stop sub-123", any()) } } + + @Test + fun testHandleGatewayReady_withRestoreLastSessionEnabled_resumesStoredSession() = + runTest { + every { AuthManager.isRestoreLastSession() } returns true + every { AuthManager.getLastOpenedSessionId() } returns "session-stored-999" + + val resumeParamsSlot = slot>() + every { + HermesWsClient.send(WsMethods.SESSION_RESUME, capture(resumeParamsSlot), any()) + } answers { + "req-resume-test" + } + + val viewModel = createViewModel() + advanceUntilIdle() + + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + + io.mockk.verify { + HermesWsClient.send( + WsMethods.SESSION_RESUME, + any(), + any(), + ) + } + assertEquals("session-stored-999", resumeParamsSlot.captured["session_id"]) + } + + @Test + fun testHandleGatewayReady_withRestoreLastSessionDisabled_createsNewSession() = + runTest { + every { AuthManager.isRestoreLastSession() } returns false + every { AuthManager.getLastOpenedSessionId() } returns "session-stored-999" + + val viewModel = createViewModel() + advanceUntilIdle() + + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + + io.mockk.verify { + HermesWsClient.send( + WsMethods.SESSION_CREATE, + any(), + any(), + ) + } + io.mockk.verify(exactly = 0) { + HermesWsClient.send( + WsMethods.SESSION_RESUME, + any(), + any(), + ) + } + } + + @Test + fun testSwitchSession_updatesLastOpenedSessionId() = + runTest { + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + viewModel.switchSession("session-other-456") + advanceUntilIdle() + + io.mockk.verify { AuthManager.setLastOpenedSessionId("session-other-456") } + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index a029d76b..6b09f2f5 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -95,6 +95,10 @@ class SlashCommandDispatchRpcTest { every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.isRestoreLastSession() } returns false + every { AuthManager.getLastOpenedSessionId() } returns null + every { AuthManager.setLastOpenedSessionId(any()) } returns Unit + every { AuthManager.clearLastOpenedSessionId() } returns Unit every { HermesWsClient.events } returns mockEventsFlow every { HermesWsClient.connectionStatus } returns mockConnectionStatus every { HermesWsClient.connect() } answers { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index c50acacc..b23b6ec1 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt @@ -63,6 +63,10 @@ class SettingsViewModelTest { every { AuthManager.getAppLanguage() } returns "system" every { AuthManager.getToken() } returns "" every { AuthManager.isAutoReconnect() } returns true + every { AuthManager.isRestoreLastSession() } returns false + every { AuthManager.setRestoreLastSession(any()) } returns Unit + every { AuthManager.clearLastOpenedSessionIdsForConnection(any()) } returns Unit + every { AuthManager.clearLastOpenedSessionId() } returns Unit every { AuthManager.getThemePreference() } returns ThemePreference.SYSTEM every { AuthManager.isUseDynamicColors() } returns true every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT @@ -253,4 +257,26 @@ class SettingsViewModelTest { verify { AuthManager.setSelectedProfileId(any()) } assertEquals(true, viewModel.uiState.value.navigateToLogin) } + + @Test + fun testRestoreLastSession_loadsAndUpdatesState() { + every { AuthManager.isRestoreLastSession() } returns true + val viewModel = createViewModel() + + assertEquals(true, viewModel.uiState.value.restoreLastSession) + + viewModel.onRestoreLastSessionChange(false) + assertEquals(false, viewModel.uiState.value.restoreLastSession) + verify { AuthManager.setRestoreLastSession(false) } + } + + @Test + fun testDeleteProfile_clearsStoredSessionIdsForConnection() { + val viewModel = createViewModel() + + viewModel.deleteProfile("prof-1") + testDispatcher.scheduler.advanceUntilIdle() + + verify { AuthManager.clearLastOpenedSessionIdsForConnection("prof-1") } + } }