Status indicator can disappear after switching between active threads

Open 💬 2 comments Opened Aug 11, 2026 by 92645417d9e5c763259dbebc306e3e

What version of Codex CLI is running?

  • codex-cli rust-v0.148.0-alpha.1.

What subscription do you have?

  • ChatGPT Pro 20x.

Which model were you using?

  • gpt-5.6-sol.

What terminal emulator and version are you using (if applicable)?

  • Alacritty inside tmux.

Codex doctor report

  • Unavailable: all log recording is disabled for performance.

What issue are you seeing?

  • Switching from an active thread to a sub-agent and back can permanently hide the active thread's status indicator even though the original turn is still running.
  • The failure occurs when a thread switch overlaps the deferred status restoration window after streamed commentary finishes but before its stream queues become idle.

What steps can reproduce the bug?

  • Start a long-running turn that produces streamed commentary.
  • Wait until a commentary item finishes while its displayed stream is still draining.
  • Open a running sub-agent before the active thread's status indicator reappears.
  • Return to the main thread.
  • Repeat the switch during this short drain window if the first attempt does not reproduce the issue.
  • Observe that the main thread remains active but its status indicator can stay hidden.

What is the expected behavior?

  • A thread switch preserves both the current status visibility and any pending request to restore the indicator after streaming becomes idle.

Additional information

  • ChatWidget::finalize_completed_assistant_message sets pending_status_indicator_restore when a commentary stream has finalized but still needs restoration after its queues drain.
  • App::select_agent_thread then calls App::store_active_thread_receiver, replaces the active widget with ChatWidget::new_with_app_event, and replays the selected thread snapshot.
  • ChatWidget::capture_thread_input_state and ThreadInputState preserve running/input state but omit current_status, indicator visibility, and pending_status_indicator_restore.
  • Switching during the deferred window therefore destroys the only restore intent with the old ChatWidget; switching back reconstructs a running widget with no event left to make the status visible.
  • Preserving those status fields in the per-thread snapshot and invoking maybe_restore_status_indicator_after_stream_idle after snapshot replay addresses the issue.
  • This report was organized with Codex CLI assistance from observed behavior and source inspection.

View original on GitHub ↗

2 Comments

snowyukitty · 17 days ago

Confirmed on current main (7d486ffa). I reproduced this through the production path by keeping a plan stream queued while commentary completion entered the deferred-restoration window, then switching away and replaying the thread snapshot.

There is one additional edge beyond the snapshot fields described above: restoring those fields alone is not sufficient. Snapshot replay processes both the in-progress turn and TurnStarted; either path can call on_task_started() again, overwrite the restored custom status with Working, and clear the deferred restoration state. The thread-snapshot replay therefore also needs to be idempotent once the running lifecycle has been restored.

I have a focused local patch that:

  • preserves the current status, indicator visibility, and pending restoration in ThreadInputState for in-flight turns;
  • avoids reinitializing an already-restored running lifecycle during thread-snapshot replay;
  • attempts the deferred restoration once replay has drained; and
  • adds both a widget snapshot regression and an app-level replay regression, including a duplicate TurnStarted event.

The regression failed before the fix (Investigating thread switch was replaced by a hidden Working state) and now passes. The related snapshot-replay and input-restoration tests pass as well.

Would a maintainer welcome a PR for this approach? I can keep the change scoped to the TUI state capture/replay path and its regression coverage.

jdcodes1 · 9 days ago

Verified your analysis against main (1f41cc5d92): ThreadInputState carries composer/steer/queue state plus task_running/agent_turn_running, but nothing from status_state — no current_status, no visibility, no pending_status_indicator_restore (https://github.com/openai/codex/blob/1f41cc5d92/codex-rs/tui/src/chatwidget/user_messages.rs#L124-L141). The restore intent lives only on the discarded widget (streaming.rs#L223 sets it; #L110-L124 consumes it), so a switch inside the drain window destroys the one signal that would re-show the indicator, exactly as you traced.

Your fix direction is right; one detail worth adding when snapshotting: turn_runtime.rs#L88 clears pending_status_indicator_restore unconditionally on turn-runtime resets, so the restored flag needs to be applied after snapshot replay finishes reconstructing the running turn (then invoke maybe_restore_status_indicator_after_stream_idle as you proposed) — otherwise the replay path can wipe it a second time. This is a sibling of #38192 (stale completion notifications across the same thread-switch replay), which suggests the broader fix is making the per-thread snapshot own all of status_state rather than adding fields one at a time.