openclaw - ✅(Solved) Fix TUI: status bar stuck on "streaming" after response completes [2 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#66876Fetched 2026-04-16 06:37:27
View on GitHub
Comments
1
Participants
2
Timeline
5
Reactions
0
Author
Timeline (top)
cross-referenced ×2referenced ×2commented ×1

The TUI can get stuck in "streaming" status after a response finishes. The status indicator never returns to "idle".

Root Cause

Race condition in tui-CcmuLL4-.js between the chat stream's final event and the lifecycle stream's end event.

The problem (lines ~2694–2742):

// On every delta:
if (evt.state === "delta") {
  setActivityStatus("streaming");  // ← sets streaming on every chunk
}

// On final:
if (evt.state === "final") {
  const wasActiveRun = state.activeChatRunId === evt.runId;  // ← computed here
  // ...
  finalizeRun({ runId: evt.runId, wasActiveRun, status: "idle" });
}

And in finalizeRun:

const finalizeRun = (params) => {
  noteFinalizedRun(params.runId);
  clearActiveRunIfMatch(params.runId);     // ← clears activeChatRunId
  if (params.wasActiveRun) setActivityStatus(params.status);  // ← only clears if wasActiveRun=true
};

The race: If the lifecycle stream's end event fires and calls clearActiveRunIfMatch before the chat stream's final event is processed, state.activeChatRunId is already null when wasActiveRun is evaluated at line 2702. This makes wasActiveRun = false, so setActivityStatus("idle") is never called by finalizeRun. The status stays "streaming" permanently.

The same guard exists in the lifecycle handler (if (!isActiveRun) return at line 2793), meaning the lifecycle path also won't reset the status if activeChatRunId was already cleared.

Fix Action

Fix

Compute wasActiveRun before calling clearActiveRunIfMatch, or better: always call setActivityStatus("idle") when a final event arrives for any run that was displaying as streaming, regardless of whether it's still the "active" run:

// Option A: capture wasActiveRun before clearActiveRunIfMatch clears it
const finalizeRun = (params) => {
  noteFinalizedRun(params.runId);
  // wasActiveRun is already captured by caller before this call — no change needed here

  clearActiveRunIfMatch(params.runId);
  flushPendingHistoryRefreshIfIdle();
  if (params.wasActiveRun) setActivityStatus(params.status);
  refreshSessionInfo?.();
};

Wait — wasActiveRun IS captured by the caller before finalizeRun is called (line 2702 captures it, then passes it to finalizeRun at line 2739). So the issue must be upstream: state.activeChatRunId gets cleared between when the delta sets "streaming" and when the final event fires.

Most likely culprit: The lifecycle end event arrives first, calls setActivityStatus("idle"), then the chat delta events arrive late (reordering over SSE/WS), set status back to "streaming", and then no further final ever clears it because activeChatRunId was already nulled.

Robust fix: Track streaming state by runId in the UI (similar to how streamingRuns map works for the chat log), and only clear "streaming" status when all tracked streaming runs have finalized — not based solely on wasActiveRun.

Alternatively, the simplest fix: in handleChatEvent, when evt.state === "final", always call setActivityStatus("idle") if streamingRuns has no remaining entries after finalization, rather than gating on wasActiveRun.

Workaround

Send another message — the next message's sendingwaiting transition resets the status correctly.

PR fix notes

PR #66897: fix: resolve TUI status bar stuck on streaming after response completes

Description (problem / solution / changelog)

Summary

The TUI status bar could get permanently stuck showing "streaming" after a response finishes, never returning to "idle".

Root Cause

Race condition between the lifecycle stream's end event and the chat stream's final event in src/tui/tui-event-handlers.ts:

  1. Chat delta events set status to "streaming"
  2. Lifecycle end event fires, setting status to "idle"
  3. Late chat delta events arrive (SSE/WS reordering), setting status back to "streaming"
  4. Chat final event checks wasActiveRun = state.activeChatRunId === evt.runId — but activeChatRunId was already cleared by the lifecycle handler, making wasActiveRun = false
  5. finalizeRun only calls setActivityStatus("idle") when wasActiveRun is true, so status stays "streaming" forever

Fix

In both finalizeRun and terminateRun, also set the activity status when the sessionRuns map is empty (no active runs remain). This ensures status is cleared even when the wasActiveRun check fails due to the race.

Test Plan

  • TUI tests pass
  • Manual: open TUI, send messages, verify status returns to "idle" after responses complete
  • Verify no regression with concurrent/multi-run scenarios

Closes openclaw#66876

Changed files

  • src/tui/tui-event-handlers.ts (modified, +6/-2)

PR #67302: fix(tui): always reset status to idle when all streaming runs finalize regardless of event ordering

Description (problem / solution / changelog)

Summary

Race condition fix for TUI status bar getting stuck on 'streaming' after response completes.

Root Cause

When lifecycle \nd\ fires before chat \ inal:

  1. Lifecycle \nd\ clears \ctiveChatRunId\
  2. Chat \ inal\ arrives → \wasActiveRun\ evaluates false (since \ctiveChatRunId\ was already cleared)
  3. \setActivityStatus('idle')\ never called → status bar stuck on 'streaming'

Fix

In \src/tui/tui-event-handlers.ts, changed both \ inalizeRun\ and \ erminateRun\ conditions from: \\ s if (params.wasActiveRun) { \
to: \\ s if (params.wasActiveRun || !state.activeChatRunId) { \\

When \ctiveChatRunId\ is null (no active run), status is always reset to idle.

Fixes #66876

[AI-assisted]

Changed files

  • src/tui/tui-event-handlers.ts (modified, +2/-2)

Code Example

// On every delta:
if (evt.state === "delta") {
  setActivityStatus("streaming");  // ← sets streaming on every chunk
}

// On final:
if (evt.state === "final") {
  const wasActiveRun = state.activeChatRunId === evt.runId;  // ← computed here
  // ...
  finalizeRun({ runId: evt.runId, wasActiveRun, status: "idle" });
}

---

const finalizeRun = (params) => {
  noteFinalizedRun(params.runId);
  clearActiveRunIfMatch(params.runId);     // ← clears activeChatRunId
  if (params.wasActiveRun) setActivityStatus(params.status);  // ← only clears if wasActiveRun=true
};

---

// Option A: capture wasActiveRun before clearActiveRunIfMatch clears it
const finalizeRun = (params) => {
  noteFinalizedRun(params.runId);
  // wasActiveRun is already captured by caller before this call — no change needed here

  clearActiveRunIfMatch(params.runId);
  flushPendingHistoryRefreshIfIdle();
  if (params.wasActiveRun) setActivityStatus(params.status);
  refreshSessionInfo?.();
};
RAW_BUFFERClick to expand / collapse

Summary

The TUI can get stuck in "streaming" status after a response finishes. The status indicator never returns to "idle".

Version

openclaw 2026.4.14

Steps to Reproduce

  1. Open the TUI (openclaw tui)
  2. Send a message and wait for a response
  3. After the assistant finishes generating, observe the status bar

The status bar continues to show "streaming" instead of returning to "idle".

Root Cause

Race condition in tui-CcmuLL4-.js between the chat stream's final event and the lifecycle stream's end event.

The problem (lines ~2694–2742):

// On every delta:
if (evt.state === "delta") {
  setActivityStatus("streaming");  // ← sets streaming on every chunk
}

// On final:
if (evt.state === "final") {
  const wasActiveRun = state.activeChatRunId === evt.runId;  // ← computed here
  // ...
  finalizeRun({ runId: evt.runId, wasActiveRun, status: "idle" });
}

And in finalizeRun:

const finalizeRun = (params) => {
  noteFinalizedRun(params.runId);
  clearActiveRunIfMatch(params.runId);     // ← clears activeChatRunId
  if (params.wasActiveRun) setActivityStatus(params.status);  // ← only clears if wasActiveRun=true
};

The race: If the lifecycle stream's end event fires and calls clearActiveRunIfMatch before the chat stream's final event is processed, state.activeChatRunId is already null when wasActiveRun is evaluated at line 2702. This makes wasActiveRun = false, so setActivityStatus("idle") is never called by finalizeRun. The status stays "streaming" permanently.

The same guard exists in the lifecycle handler (if (!isActiveRun) return at line 2793), meaning the lifecycle path also won't reset the status if activeChatRunId was already cleared.

Fix

Compute wasActiveRun before calling clearActiveRunIfMatch, or better: always call setActivityStatus("idle") when a final event arrives for any run that was displaying as streaming, regardless of whether it's still the "active" run:

// Option A: capture wasActiveRun before clearActiveRunIfMatch clears it
const finalizeRun = (params) => {
  noteFinalizedRun(params.runId);
  // wasActiveRun is already captured by caller before this call — no change needed here

  clearActiveRunIfMatch(params.runId);
  flushPendingHistoryRefreshIfIdle();
  if (params.wasActiveRun) setActivityStatus(params.status);
  refreshSessionInfo?.();
};

Wait — wasActiveRun IS captured by the caller before finalizeRun is called (line 2702 captures it, then passes it to finalizeRun at line 2739). So the issue must be upstream: state.activeChatRunId gets cleared between when the delta sets "streaming" and when the final event fires.

Most likely culprit: The lifecycle end event arrives first, calls setActivityStatus("idle"), then the chat delta events arrive late (reordering over SSE/WS), set status back to "streaming", and then no further final ever clears it because activeChatRunId was already nulled.

Robust fix: Track streaming state by runId in the UI (similar to how streamingRuns map works for the chat log), and only clear "streaming" status when all tracked streaming runs have finalized — not based solely on wasActiveRun.

Alternatively, the simplest fix: in handleChatEvent, when evt.state === "final", always call setActivityStatus("idle") if streamingRuns has no remaining entries after finalization, rather than gating on wasActiveRun.

Workaround

Send another message — the next message's sendingwaiting transition resets the status correctly.

extent analysis

TL;DR

The most likely fix is to always call setActivityStatus("idle") when a final event arrives for any run that was displaying as streaming, regardless of whether it's still the "active" run.

Guidance

  • Identify the root cause of the issue: a race condition between the chat stream's final event and the lifecycle stream's end event.
  • Consider implementing a robust fix by tracking streaming state by runId in the UI and only clearing "streaming" status when all tracked streaming runs have finalized.
  • Alternatively, modify the handleChatEvent function to always call setActivityStatus("idle") when evt.state === "final" and streamingRuns has no remaining entries after finalization.
  • Verify the fix by testing the TUI with multiple messages and observing the status bar to ensure it returns to "idle" after each response.

Example

// Modified handleChatEvent function
if (evt.state === "final") {
  // ...
  if (streamingRuns.size === 0) {
    setActivityStatus("idle");
  }
}

Notes

The provided fix options assume that the streamingRuns map is accurately tracking the streaming runs. If this is not the case, additional modifications may be necessary.

Recommendation

Apply the workaround of sending another message to reset the status correctly, and consider implementing the robust fix to track streaming state by runId in the UI for a more permanent solution. This approach ensures that the "streaming" status is cleared correctly even in cases where the lifecycle stream's end event arrives before the chat stream's final event.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING