Summary
When a chat run is active (started from one browser tab), other tabs showing the same session display a "responding in another tab" state and block input. This is unnecessary — the server already supports concurrent chat.send calls for the same session (there's no per-session concurrency gate), and streaming events already broadcast correctly to all connected WebSocket clients.
Problem
Current behavior:
- Tab A sends a message → run starts → streaming begins
- Tab B (same session, same browser) sees the streaming output but shows "responding in another tab" and blocks/disables input
- User must wait for the run to complete on Tab A before sending from Tab B
Why this is wrong:
- No server-side gate:
chat.send is keyed by idempotencyKey (run ID), not by session. Multiple concurrent runs for the same session work fine.
- Events already broadcast correctly: WebSocket
chat events (delta/final/aborted) go to all connected clients regardless of which tab initiated the run.
- The streaming already works: Tab B receives and renders streaming deltas from Tab A's run. The event-driven architecture is already in place.
- UX is confusing: Users expect tab-agnostic behavior (like any modern chat app). Blocking input because "another tab is responding" feels like a bug, not a feature.
Evidence from Source
The blocking appears to stem from the chatRunId state being local to each tab:
isChatBusy() checks host.chatSending || Boolean(host.chatRunId) (app-chat.ts:47)
chatRunId is only set by the tab that called sendChatMessage() — passive tabs keep chatRunId = null
- However,
chatStream IS set on passive tabs via handleChatEvent() → delta handler
- This means
isBusy (props.sending || props.stream !== null || props.canAbort) evaluates to true on passive tabs because stream !== null, which may be affecting UI state
- The send button's
?disabled only checks !props.connected || props.sending, which should be fine — but the overall "busy" visual state and queue behavior (flushChatQueue skips when busy) creates the blocking UX
The queue system also contributes: flushChatQueue() returns early when isChatBusy() is true, so queued messages on the passive tab won't drain.
Proposed Fix
The webchat should treat runs as session-scoped, not tab-scoped:
-
Track active runs by session, not just by tab-initiated runId: When a tab receives a chat delta event for its current session (with a runId it didn't initiate), it should adopt that runId into chatRunId so it can properly track completion, enable the stop button, and release the "busy" state when the run finishes.
-
Don't block input during streaming: The input textarea and send button should remain fully functional during an active run. If the user sends while a run is in progress, it should either:
- Queue the message and send it after the current run completes (current queue behavior, but with working drain), OR
- Send immediately as a concurrent run (server already supports this)
-
Show informational state, not blocking state: Instead of blocking, show a lightweight indicator that a run is in progress (streaming indicator, stop button). The user should be able to type and send at any time.
-
Consistent abort across tabs: Both tabs should see the stop button for the active run and be able to abort it (the server already checks ownerDeviceId/ownerConnId for abort authorization).
Related Issues
Environment
- OpenClaw v2026.4.3
- WebChat UI (Lit-based)
- Multiple browser tabs on same session
Summary
When a chat run is active (started from one browser tab), other tabs showing the same session display a "responding in another tab" state and block input. This is unnecessary — the server already supports concurrent
chat.sendcalls for the same session (there's no per-session concurrency gate), and streaming events already broadcast correctly to all connected WebSocket clients.Problem
Current behavior:
Why this is wrong:
chat.sendis keyed byidempotencyKey(run ID), not by session. Multiple concurrent runs for the same session work fine.chatevents (delta/final/aborted) go to all connected clients regardless of which tab initiated the run.Evidence from Source
The blocking appears to stem from the
chatRunIdstate being local to each tab:isChatBusy()checkshost.chatSending || Boolean(host.chatRunId)(app-chat.ts:47)chatRunIdis only set by the tab that calledsendChatMessage()— passive tabs keepchatRunId = nullchatStreamIS set on passive tabs viahandleChatEvent()→ delta handlerisBusy(props.sending || props.stream !== null || props.canAbort) evaluates totrueon passive tabs becausestream !== null, which may be affecting UI state?disabledonly checks!props.connected || props.sending, which should be fine — but the overall "busy" visual state and queue behavior (flushChatQueueskips when busy) creates the blocking UXThe queue system also contributes:
flushChatQueue()returns early whenisChatBusy()is true, so queued messages on the passive tab won't drain.Proposed Fix
The webchat should treat runs as session-scoped, not tab-scoped:
Track active runs by session, not just by tab-initiated runId: When a tab receives a
chatdelta event for its current session (with a runId it didn't initiate), it should adopt that runId intochatRunIdso it can properly track completion, enable the stop button, and release the "busy" state when the run finishes.Don't block input during streaming: The input textarea and send button should remain fully functional during an active run. If the user sends while a run is in progress, it should either:
Show informational state, not blocking state: Instead of blocking, show a lightweight indicator that a run is in progress (streaming indicator, stop button). The user should be able to type and send at any time.
Consistent abort across tabs: Both tabs should see the stop button for the active run and be able to abort it (the server already checks
ownerDeviceId/ownerConnIdfor abort authorization).Related Issues
Environment