@@ -45,12 +45,14 @@ import kotlinx.coroutines.flow.first
45import kotlinx.coroutines.flow.filterIsInstance
45import kotlinx.coroutines.flow.filterIsInstance
46import kotlinx.coroutines.flow.flatMapLatest
46import kotlinx.coroutines.flow.flatMapLatest
47import kotlinx.coroutines.flow.flowOf
47import kotlinx.coroutines.flow.flowOf
48+import kotlinx.coroutines.flow.flowOn
48import kotlinx.coroutines.flow.map
49import kotlinx.coroutines.flow.map
49import kotlinx.coroutines.flow.merge
50import kotlinx.coroutines.flow.merge
50import kotlinx.coroutines.flow.onEach
51import kotlinx.coroutines.flow.onEach
51import kotlinx.coroutines.flow.retryWhen
52import kotlinx.coroutines.flow.retryWhen
52import kotlinx.coroutines.flow.stateIn
53import kotlinx.coroutines.flow.stateIn
53import kotlinx.coroutines.flow.update
54import kotlinx.coroutines.flow.update
55+import kotlinx.coroutines.Dispatchers
54import kotlinx.coroutines.delay
56import kotlinx.coroutines.delay
55import kotlinx.coroutines.launch
57import kotlinx.coroutines.launch
@@ -217,6 +219,11 @@ class ChatViewModel(
217 * emission that doesn't change the plan doesn't recompose the bar. */
219 * emission that doesn't change the plan doesn't recompose the bar. */
218 val todoPlan: StateFlow<List<TodoItem>> = messages
220 val todoPlan: StateFlow<List<TodoItem>> = messages
219 .map { currentTodoPlan(it) }
221 .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)
220 .distinctUntilChanged()
227 .distinctUntilChanged()
221 .stateIn(
228 .stateIn(
222 scope = viewModelScope,
229 scope = viewModelScope,
@@ -227,6 +234,14 @@ class ChatViewModel(
227 private val _running = MutableStateFlow(false)
234 private val _running = MutableStateFlow(false)
228 val running: StateFlow<Boolean> = _running.asStateFlow()
235 val running: StateFlow<Boolean> = _running.asStateFlow()
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
230 /** True while an abort REST call is in flight, so the Stop button can show a
245 /** True while an abort REST call is in flight, so the Stop button can show a
231 * spinner and prevent double-taps from firing a second abort. */
246 * spinner and prevent double-taps from firing a second abort. */
232 private val _aborting = MutableStateFlow(false)
247 private val _aborting = MutableStateFlow(false)
@@ -545,6 +560,10 @@ class ChatViewModel(
545 return@collectLatest
560 return@collectLatest
547 _running.value = false
562 _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
548 clearPermissions()
567 clearPermissions()
549 _failedDraft.value = null
568 _failedDraft.value = null
550 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
569 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
@@ -557,6 +576,7 @@ class ChatViewModel(
557 conn.events.events.collect { event ->
576 conn.events.events.collect { event ->
558 if (SessionRepository.isIdle(event, sessionId)) {
577 if (SessionRepository.isIdle(event, sessionId)) {
559 _running.value = false
578 _running.value = false
579+ runEndedByIdle = true
560 // Auto-send a queued follow-up once the previous run finishes,
580 // Auto-send a queued follow-up once the previous run finishes,
561 // so the user's drafted-while-running message isn't lost.
581 // so the user's drafted-while-running message isn't lost.
562 val queued = _queuedFollowUp.value
582 val queued = _queuedFollowUp.value
@@ -567,6 +587,7 @@ class ChatViewModel(
568 if (SessionRepository.isError(event, sessionId)) {
588 if (SessionRepository.isError(event, sessionId)) {
569 _running.value = false
589 _running.value = false
590+ runEndedByIdle = true
570 // The run errored, so the queued follow-up won't auto-send.
591 // The run errored, so the queued follow-up won't auto-send.
571 // Restore it to the input (if free) so the user's typed text
592 // Restore it to the input (if free) so the user's typed text
572 // isn't silently lost; otherwise just clear the queue.
593 // isn't silently lost; otherwise just clear the queue.
@@ -580,7 +601,7 @@ class ChatViewModel(
580 // Restore the run indicator if a live streaming event arrives while it's
601 // Restore the run indicator if a live streaming event arrives while it's
581 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
602 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
582 // agent is still working. Don't revive it once the user has hit Stop.
603 // 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) {
584 _running.value = true
605 _running.value = true
586 when (event) {
607 when (event) {
@@ -712,6 +733,9 @@ class ChatViewModel(
712 return enqueueOffline(trimmed, attachments)
733 return enqueueOffline(trimmed, attachments)
714 if (!_running.compareAndSet(false, true)) return false
735 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
715 _failedDraft.value = null
739 _failedDraft.value = null
716 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
740 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
717 // while retryFailed() re-submits with the key it stashed on the original failure — so a
741 // while retryFailed() re-submits with the key it stashed on the original failure — so a