@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
17import kotlinx.coroutines.flow.SharingStarted
17import kotlinx.coroutines.flow.SharingStarted
18import kotlinx.coroutines.flow.StateFlow
18import kotlinx.coroutines.flow.StateFlow
19import kotlinx.coroutines.flow.asStateFlow
19import kotlinx.coroutines.flow.asStateFlow
20+import kotlinx.coroutines.flow.collectLatest
20import kotlinx.coroutines.flow.flatMapLatest
21import kotlinx.coroutines.flow.flatMapLatest
21import kotlinx.coroutines.flow.stateIn
22import kotlinx.coroutines.flow.stateIn
22import kotlinx.coroutines.flow.update
23import kotlinx.coroutines.flow.update
@@ -90,8 +91,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
90 private var previewJob: Job? = null
91 private var previewJob: Job? = null
92 /** Per-session in-flight preview loads, so rapid SSE events for the same session
93 /** Per-session in-flight preview loads, so rapid SSE events for the same session
93− * coalesce into a single request instead of firing one per event. */
94+ * coalesce into a single request instead of firing one per event. Accessed from
95+ * multiple coroutines (the SSE event handler and [loadPreview]), so kept concurrent. */
94− private val livePreviewJobs = mutableMapOf<String, Job>()
96+ private val livePreviewJobs = java.util.concurrent.ConcurrentHashMap<String, Job>()
96 private val activeProfileId: String?
98 private val activeProfileId: String?
97 get() = container.activeConnection.value?.profile?.id
99 get() = container.activeConnection.value?.profile?.id
@@ -105,16 +107,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
106 private fun observeSessionEvents() {
108 private fun observeSessionEvents() {
107 viewModelScope.launch {
109 viewModelScope.launch {
108− container.activeConnection.collect { conn ->
110+ container.activeConnection.collectLatest { conn ->
109− if (conn == null) return@collect
111+ if (conn == null) return@collectLatest
110 conn.events.events.collect { event ->
112 conn.events.events.collect { event ->
111 when (event) {
113 when (event) {
112 is SessionUpdated -> {
114 is SessionUpdated -> {
113 val session = event.properties.info
115 val session = event.properties.info
114− val current = _state.value.sessions
116+ _state.update { s ->
115− val updated = (current.filterNot { it.id == session.id } + session)
117+ val updated = (s.sessions.filterNot { it.id == session.id } + session)
116− .sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
118+ .sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
117− _state.value = _state.value.copy(sessions = updated)
119+ s.copy(sessions = updated)
118 loadPreview(session.id)
121 loadPreview(session.id)
120 is SessionDeleted -> {
123 is SessionDeleted -> {
@@ -138,7 +141,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
140 fun setQuery(query: String) {
143 fun setQuery(query: String) {
141− _state.value = _state.value.copy(query = query)
144+ _state.update { it.copy(query = query) }
144 fun refresh() {
147 fun refresh() {
@@ -147,24 +150,24 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
147 if (_state.value.sessions.isEmpty()) {
150 if (_state.value.sessions.isEmpty()) {
148 _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
151 _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
150− _state.value = _state.value.copy(loading = false)
153+ _state.update { it.copy(loading = false) }
151 _transientError.value = container.string(R.string.not_connected)
154 _transientError.value = container.string(R.string.not_connected)
155 _serverLabel.value = conn.profile.displayLabel
158 _serverLabel.value = conn.profile.displayLabel
156− _state.value = _state.value.copy(loading = true, error = null)
159+ _state.update { it.copy(loading = true, error = null) }
157 _refreshing.value = true
160 _refreshing.value = true
158 viewModelScope.launch {
161 viewModelScope.launch {
159 runCatching { conn.repository.listSessions() }
162 runCatching { conn.repository.listSessions() }
160 .onSuccess { list ->
163 .onSuccess { list ->
161 val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
164 val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
162− _state.value = _state.value.copy(sessions = sorted, loading = false, error = null)
165+ _state.update { it.copy(sessions = sorted, loading = false, error = null) }
163 loadPreviews(sorted)
166 loadPreviews(sorted)
165 .onFailure {
168 .onFailure {
166 if (_state.value.sessions.isNotEmpty()) {
169 if (_state.value.sessions.isNotEmpty()) {
167− _state.value = _state.value.copy(loading = false)
170+ _state.update { it.copy(loading = false) }
168 _transientError.value = container.friendlyError(it)
171 _transientError.value = container.friendlyError(it)
170 _state.value = SessionListState(loading = false, error = container.friendlyError(it))
173 _state.value = SessionListState(loading = false, error = container.friendlyError(it))
@@ -226,7 +229,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
226 runCatching { conn.repository.createSession() }
229 runCatching { conn.repository.createSession() }
227 .onSuccess { onCreated(it.id); refresh() }
230 .onSuccess { onCreated(it.id); refresh() }
228 .onFailure {
231 .onFailure {
229− _state.value = _state.value.copy(error = null)
232+ _state.update { it.copy(error = null) }
230 _transientError.value = container.friendlyError(it)
233 _transientError.value = container.friendlyError(it)
@@ -241,7 +244,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
241 refresh()
244 refresh()
243 .onFailure {
246 .onFailure {
244− _state.value = _state.value.copy(error = null)
247+ _state.update { it.copy(error = null) }
245 _transientError.value = container.friendlyError(it)
248 _transientError.value = container.friendlyError(it)
@@ -255,7 +258,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
255 runCatching { conn.api.updateSession(session.id, title) }
258 runCatching { conn.api.updateSession(session.id, title) }
256 .onSuccess { refresh() }
259 .onSuccess { refresh() }
257 .onFailure {
260 .onFailure {
258− _state.value = _state.value.copy(error = null)
261+ _state.update { it.copy(error = null) }
259 _transientError.value = container.friendlyError(it)
262 _transientError.value = container.friendlyError(it)
@@ -266,7 +269,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
266 if (profile.id == activeProfileId) return
269 if (profile.id == activeProfileId) return
267 if (_switchingId.value != null) return
270 if (_switchingId.value != null) return
268 _switchingId.value = profile.id
271 _switchingId.value = profile.id
269− _state.value = _state.value.copy(loading = true, error = null)
272+ _state.update { it.copy(loading = true, error = null) }
270 viewModelScope.launch {
273 viewModelScope.launch {
271 val result = runCatching {
274 val result = runCatching {
272 val conn = container.connect(profile)
275 val conn = container.connect(profile)