@@ -45,12 +45,14 @@ import kotlinx.coroutines.flow.first
4545import kotlinx.coroutines.flow.filterIsInstance
4646import kotlinx.coroutines.flow.flatMapLatest
4747import kotlinx.coroutines.flow.flowOf
48+import kotlinx.coroutines.flow.flowOn
4849import kotlinx.coroutines.flow.map
4950import kotlinx.coroutines.flow.merge
5051import kotlinx.coroutines.flow.onEach
5152import kotlinx.coroutines.flow.retryWhen
5253import kotlinx.coroutines.flow.stateIn
5354import kotlinx.coroutines.flow.update
55+import kotlinx.coroutines.Dispatchers
5456import kotlinx.coroutines.delay
5557import kotlinx.coroutines.launch
5658
@@ -217,6 +219,11 @@ class ChatViewModel(
217219 * emission that doesn't change the plan doesn't recompose the bar. */
218220 val todoPlan: StateFlow<List<TodoItem>> = messages
219221 .map { currentTodoPlan(it) }
222+ // Run currentTodoPlan off the main thread: it's an O(messages×parts) scan (worst case
223+ // when the conversation has no todowrite) and runs on every conflated messages emission.
224+ // distinctUntilChanged is downstream so it dedupes the result but doesn't prevent the
225+ // scan — without flowOn this scans on Main.immediate per streamed token → jank.
226+ .flowOn(Dispatchers.Default)
220227 .distinctUntilChanged()
221228 .stateIn(
222229 scope = viewModelScope,
@@ -227,6 +234,14 @@ class ChatViewModel(
227234 private val _running = MutableStateFlow(false)
228235 val running: StateFlow<Boolean> = _running.asStateFlow()
229236
237+ /** True once the current run ended via SessionIdle/SessionError, until the next run starts
238+ * (a send) or an SSE reconnect. Gates the run-indicator re-light below: isRunActivity
239+ * returns true for ANY message.part.updated, so without this a trailing/replayed part
240+ * arriving after a legitimate SessionIdle would re-light the "working" indicator with no
241+ * further idle to clear it — a stuck spinner + foreground service. Cleared on reconnect so
242+ * genuine reconnect-recovery re-lighting (its actual purpose) still works. */
243+ @Volatile private var runEndedByIdle = false
244+
230245 /** True while an abort REST call is in flight, so the Stop button can show a
231246 * spinner and prevent double-taps from firing a second abort. */
232247 private val _aborting = MutableStateFlow(false)
@@ -545,6 +560,10 @@ class ChatViewModel(
545560 return@collectLatest
546561 }
547562 _running.value = false
563+ // Allow the re-light below to recover a still-running run after this (re)connect;
564+ // a run that actually finished during the outage emits no live parts, so this
565+ // won't spuriously re-light.
566+ runEndedByIdle = false
548567 clearPermissions()
549568 _failedDraft.value = null
550569 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
@@ -557,6 +576,7 @@ class ChatViewModel(
557576 conn.events.events.collect { event ->
558577 if (SessionRepository.isIdle(event, sessionId)) {
559578 _running.value = false
579+ runEndedByIdle = true
560580 // Auto-send a queued follow-up once the previous run finishes,
561581 // so the user's drafted-while-running message isn't lost.
562582 val queued = _queuedFollowUp.value
@@ -567,6 +587,7 @@ class ChatViewModel(
567587 }
568588 if (SessionRepository.isError(event, sessionId)) {
569589 _running.value = false
590+ runEndedByIdle = true
570591 // The run errored, so the queued follow-up won't auto-send.
571592 // Restore it to the input (if free) so the user's typed text
572593 // isn't silently lost; otherwise just clear the queue.
@@ -580,7 +601,7 @@ class ChatViewModel(
580601 // Restore the run indicator if a live streaming event arrives while it's
581602 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
582603 // agent is still working. Don't revive it once the user has hit Stop.
583− if (SessionRepository.isRunActivity(event, sessionId) && !_aborting.value && !_running.value) {
604+ if (SessionRepository.isRunActivity(event, sessionId) && !_aborting.value && !_running.value && !runEndedByIdle) {
584605 _running.value = true
585606 }
586607 when (event) {
@@ -712,6 +733,9 @@ class ChatViewModel(
712733 return enqueueOffline(trimmed, attachments)
713734 }
714735 if (!_running.compareAndSet(false, true)) return false
736+ // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
737+ // indicator lit (and a later SessionIdle re-closes it).
738+ runEndedByIdle = false
715739 _failedDraft.value = null
716740 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
717741 // while retryFailed() re-submits with the key it stashed on the original failure — so a