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,
),
)
}
}
}