feat(settings): restore last session on startup (#1102) (#1105)

This commit is contained in:
M57 authored and GitHub committed 2026-09-13 16:45:04 +03:00
1 parent 7e8af8db01
commit 4246ae196d
16 files changed
+234 -4

No files matched your search

@@ -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<String, String> = emptyMap(),
val themePreference: ThemePreference = ThemePreference.SYSTEM,
val useDynamicColors: Boolean = false,
val themePreset: ThemePreset = ThemePreset.DEFAULT,
@@ -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
@@ -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,
@@ -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) {
@@ -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)
@@ -227,6 +227,8 @@ internal fun SettingsBehaviorPage(
BehaviorSection(
autoReconnect = state.autoReconnect,
onAutoReconnectChange = viewModel::onAutoReconnectChange,
restoreLastSession = state.restoreLastSession,
onRestoreLastSessionChange = viewModel::onRestoreLastSessionChange,
)
}
}
@@ -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)
@@ -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,
),
)
}
}
}
+2
View File
@@ -177,6 +177,8 @@
<string name="settings_sec_behavior">السلوك</string>
<string name="settings_item_auto_reconnect">إعادة الاتصال التلقائي</string>
<string name="settings_desc_auto_reconnect">إعادة الاتصال تلقائيًا عند انقطاعه</string>
<string name="settings_item_restore_session">استعادة الجلسة الأخيرة</string>
<string name="settings_desc_restore_session">إعادة فتح أحدث جلسة محادثة عند بدء التشغيل</string>
<string name="settings_sec_appearance">المظهر</string>
<string name="settings_item_theme">السمة</string>
<string name="settings_sec_chat">المحادثة</string>
+2
View File
@@ -180,6 +180,8 @@
<string name="settings_sec_behavior">동작</string>
<string name="settings_item_auto_reconnect">자동 재연결</string>
<string name="settings_desc_auto_reconnect">연결 끊김 시 자동 재연결</string>
<string name="settings_item_restore_session">마지막 세션 복원</string>
<string name="settings_desc_restore_session">시작 시 가장 최근 채팅 세션을 다시 엽니다</string>
<string name="settings_sec_appearance">테마</string>
<string name="settings_item_theme">테마</string>
<string name="settings_sec_chat">채팅</string>
+2
View File
@@ -177,6 +177,8 @@
<string name="settings_sec_behavior">行为</string>
<string name="settings_item_auto_reconnect">自动重连</string>
<string name="settings_desc_auto_reconnect">断开后自动重新连接</string>
<string name="settings_item_restore_session">恢复上次会话</string>
<string name="settings_desc_restore_session">启动时重新打开最近的聊天会话</string>
<string name="settings_sec_appearance">外观</string>
<string name="settings_item_theme">主题</string>
<string name="settings_sec_chat">对话</string>
+2
View File
@@ -201,6 +201,8 @@
<string name="settings_sec_behavior">Behavior</string>
<string name="settings_item_auto_reconnect">Auto-Reconnect</string>
<string name="settings_desc_auto_reconnect">Automatically reconnect on disconnect</string>
<string name="settings_item_restore_session">Restore Last Session</string>
<string name="settings_desc_restore_session">Reopen the most recent chat session on startup</string>
<string name="settings_sec_appearance">Appearance</string>
<string name="settings_item_theme">Theme</string>
<string name="settings_sec_chat">Chat</string>
@@ -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 {
@@ -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<Map<String, Any>>()
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") }
}
}
@@ -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 {
@@ -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") }
}
}