Skip to content

fix(ai): stop global-assistant streams from flashing/clobbering on send, reload, or switch#2061

Merged
2witstudios merged 4 commits into
masterfrom
pu/chat-stream-bug
Jul 14, 2026
Merged

fix(ai): stop global-assistant streams from flashing/clobbering on send, reload, or switch#2061
2witstudios merged 4 commits into
masterfrom
pu/chat-stream-bug

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Summary

After #2018 (server-owned AI streams), per-page chat (AiChatView.tsx) reattaches cleanly to live streams on reload/reconnect. The global assistant — which exists as two independently-mounted surfaces, GlobalAssistantView.tsx (dashboard) and SidebarChatTab.tsx (right sidebar) — didn't get equivalent treatment:

  • Sending a message caused a visible screen flash.
  • The reply became invisible while generating, even though it was clearly running (activity feed, file edits).
  • Reloading or switching conversations mid-stream never reattached the surface to the live content.
  • The message appeared correctly once generation finished.

Root cause

Both files have "load-on-select" effects (one for global mode, one for agent mode, in each file — four total) that reapply a conversation's persisted messages into the surface's own local useChat state whenever the reference changes (mount, reload, loadConversation, conversation/agent switch). All four were missing the streaming guard their sibling refresh-effects already have, so a mount/reload/switch landing while the surface was actively streaming unconditionally overwrote the in-progress assistant bubble with a stale DB snapshot that predated the reply.

Two theories I ruled out while investigating (kept here so a reviewer doesn't rediscover them):

  • Not a shared useChat instance — I read the installed @ai-sdk/react source directly; useChat's id option only controls when a given call site recreates its own local Chat ref, it is not a cross-component registry. GlobalAssistantView and SidebarChatTab have always had fully independent Chat instances.
  • Not a missing own-tab filter on GlobalChatContext's onUserMessageuseChannelStreamSocket.ts's handleUserMessage already filters isOwnStream before invoking that callback.

Fix

Added a streaming guard to all four load-on-select effects, matching the pattern their sibling refresh-effects already used. No new rendering path was needed — the existing remoteStreamsinflightRemoteStreams/ChatMessagesArea pipeline already renders any not-yet-local stream's accumulated content regardless of ownership, so once the clobber is prevented, that pipeline already covers reattachment.

Update 1: ref-ordering defect (Codex review)

Codex flagged (3 P1 threads) that the guards advanced their "seen" ref before checking the streaming condition, so a load skipped mid-stream was marked seen and permanently dropped once streaming ended. Fixed by moving the ref assignment inside the guarded branch in all three flagged effects, plus the identical pre-existing shape in SidebarChatTab.tsx's refreshSignal effect, plus a related gap this surfaced: GlobalAssistantView.tsx's refreshSignal effect had no streaming guard at all.

Update 2: missing dedup regression (CodeRabbit review)

CodeRabbit caught that GlobalAssistantView.tsx's global-mode load-on-select effect had a streaming guard but no prevRef/dedup, so it re-fired on every streaming transition — including the streaming → not-streaming edge at the end of an ordinary, successful send — reapplying the stale pre-send snapshot and wiping the just-completed reply. Fixed with the same prevGlobalInitialMessagesRef dedup used elsewhere.

Update 3: conversation-scoping regression (found via proactive review, not a reviewer comment)

Ran this repo's code-review skill (8 parallel finder angles + verification) against the diff after the first two rounds landed. Two independent finders converged on a real regression I'd introduced: all six streaming guards checked displayIsStreaming/effectiveIsStreaming — a flag scoped to the surface, not the conversation. Both useChat instances in these files use a stable id per surface (not per conversation), so switching to a different conversation for the same agent (or a different global conversation) does not abort the old conversation's in-flight send — the flag stays true for a conversation the user has already left. The guard would then block loading the newly selected conversation's messages until that unrelated stream happened to finish, stranding the UI on stale/previous-conversation content. The codebase's own existing comments confirm this is a real, previously-encountered scenario ("switching conversation mid-stream does NOT abort the POST").

Fixed by reusing the existing streamConvIdRef/globalStreamConvIdRef (GlobalAssistantView) and heldStreamConvIdRef (SidebarChatTab) — refs that already exist for the Stop/abort path, latching the conversation a stream actually started in — to scope each guard to "is my own stream, for this specific conversation" rather than "is anything streaming." All six effects updated.

Added regression tests pinning the conversation-scoping property directly (own stream in conversation A + switch to idle conversation B must not block B's load) in both test files. Also removed a test that inlined a standalone reimplementation of a bug no longer present anywhere in production code, so it could never actually regress (documentation dressed as an assertion, flagged by the same review pass).

Noted, not fixed (out of scope for this PR):

  • The proactive review also surfaced that 6 call sites across both files now hand-roll a very similar "dedup + guard" pattern with no shared hook. This is a legitimate DRY observation, but I did not extract a shared hook: this PR is a targeted bug fix, two rounds of external review are already anchored to the current code shape, and introducing a new abstraction this late adds risk without a concrete reviewer request. Worth a dedicated follow-up PR if desired.
  • Two related, pre-existing request-sequencing gaps in SidebarChatTab.tsx's loadGlobalMessages/shouldApplyLoadedMessages, neither introduced by this PR (both predate it — I only changed which boolean gates the dispatch of this pre-existing function, not its internal request handling): (1) the streaming guard is checked at dispatch time but the fetch's .then() callback doesn't re-check it before writing, so a new send starting in the ~0.5-3s window before the pending-stream store registers it could still have its optimistic content overwritten; (2) shouldApplyLoadedMessages compares only conversationId, not a per-request sequence number, so if two of this file's independently-guarded effects (refreshSignal and the globalInitialMessages load-on-select) both happen to fire in the same render for the same conversation, their two concurrent fetches aren't ordered — whichever HTTP response resolves last wins, not whichever was requested last. Both are narrow (require rare event coincidences) and would need new request-generation-counter infrastructure in shared code to close properly; flagging for awareness/a future follow-up rather than expanding this PR's scope further.

Known, separate, out-of-scope limitation

Prod runs multiple web instances. streamMulticastRegistry is single-process (documented in #2011/#2018, reconfirmed in #2022's "out of scope: multicast cross-instance join"). If a reload/switch reconnects to a different instance than the one running the generation, there's no live-token source available — with or without this fix. That's a known, deliberately-deferred, larger effort, not something this PR attempts.

Test plan

  • bun run typecheck clean (apps/web)
  • eslint clean on touched files
  • 158 tests pass across right-sidebar/ai-assistant, dashboard, contexts/GlobalChatContext, ai-page/AiChatView
  • Regression tests pin every streaming guard, the ref-advances-only-on-apply ordering, the dedup-on-unrelated-dependency-change fix, and the conversation-scoping fix — SidebarChatTab.test.tsx, GlobalAssistantView.test.tsx
  • All Codex (3) and CodeRabbit (1) review threads replied to with the specific fix commit, left open for reviewer verification
  • Proactively ran this repo's code-review skill (8 finder angles) against the diff and fixed the one confirmed correctness regression it surfaced (conversation-scoping)
  • Manual: dock the global assistant in the sidebar, send a message, confirm no flash and continuous live tokens through completion
  • Manual: send a message, reload immediately, confirm the sidebar reattaches and shows accumulating text rather than a blank composer-disabled state
  • Manual: repeat both with GlobalAssistantView (dashboard) as the active surface, and confirm the reply stays visible after it finishes (the CodeRabbit-flagged regression)
  • Manual: send a message in conversation A, switch to a different idle conversation B (same agent, or a different global conversation) before A finishes streaming, confirm B's messages load immediately rather than waiting for A's stream to end (the conversation-scoping regression)

🤖 Generated with Claude Code

https://claude.ai/code/session_01U8GPExjBCyxrXDtaJVYoWk

… live streams

Sending a message from the global assistant (dashboard or right-sidebar
surface) could visibly flash and make the in-progress reply disappear;
reloading or switching conversations mid-stream never reattached to the
live content. Root cause: four "load-on-select" effects across
GlobalAssistantView.tsx and SidebarChatTab.tsx (global-mode and
agent-mode variants in each) reapplied a conversation's persisted
messages into the surface's own local useChat state whenever the
reference changed (mount, reload, conversation/agent switch) with no
guard against the surface actively streaming — unlike their sibling
refresh-effects, which already had one. The unconditional overwrite
clobbered the in-progress assistant bubble with a stale DB snapshot
that predated the reply.

Add the same streaming guard already used by the sibling effects to
all four call sites. No new rendering path was needed: the existing
remoteStreams/inflightRemoteStreams pipeline already renders a
bootstrapped stream's live content independent of local message state,
once the clobber is prevented.

Also flagged, not addressed here (separate, larger, already-documented
effort per PR #2011/#2018/#2022): cross-instance live-token rejoin
doesn't work when a reload lands on a different web instance than the
one running the generation, since the multicast stream registry is
single-process.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Global and sidebar assistant views now defer refresh and initial-message snapshot updates while streaming, and advance tracking refs only when updates execute. New regression tests cover streaming guards, conversation switches, and retry behavior after streaming ends.

Changes

Assistant streaming guards

Layer / File(s) Summary
Global assistant snapshot guards
apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx, apps/web/src/components/layout/middle-content/page-views/dashboard/__tests__/GlobalAssistantView.test.tsx
Global refresh and initial-message effects skip updates during effective streaming; tests cover prerequisites and deferred ref advancement.
Sidebar assistant snapshot guards
apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx, apps/web/src/components/layout/right-sidebar/ai-assistant/__tests__/SidebarChatTab.test.tsx
Sidebar refresh and load-on-select effects update refs and messages only when not streaming; tests cover conversation switches, blocked loads, and retries.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: preventing assistant streams from flashing or being clobbered during send, reload, or conversation switches.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/chat-stream-bug

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 397f9a5475

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 979 to 980
if (selectedAgent && agentConversationId && !effectiveIsStreaming) {
setAgentMessages(agentInitialMessages);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply skipped dashboard agent load after streaming

When this load-signal effect fires during a bootstrapped/local agent stream, the new guard skips setAgentMessages, but the effect has already copied the current agentConversationLoadSignal into prevAgentLoadSignalRef. When the stream later finishes, effectiveIsStreaming flips and re-runs the effect with the same signal, so the early return above prevents applying the loaded snapshot; after a reload or conversation switch mid-agent-stream the dashboard can keep an empty/stale useChat history because the completion path only synthesizes the assistant bubble. Move the ref update until a load is actually applied, or remember a pending load.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fe87376 — good catch. The guard blocked the apply while streaming, but prevAgentLoadSignalRef.current was still advanced unconditionally, so once streaming ended the effect saw "no change" on the same signal and never retried. Moved the ref assignment inside the guarded branch (only advances when setAgentMessages actually runs), and added a stateful test (ref-advances-only-on-apply in GlobalAssistantView.test.tsx) that simulates the exact multi-render sequence: guard blocks -> guard passes with the same reference -> must still apply.

Comment on lines 755 to 756
if (!selectedAgent && globalIsInitialized && globalConversationId && !displayIsStreaming) {
loadGlobalMessages(globalConversationId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retry skipped sidebar global loads after streaming

If globalInitialMessages changes while displayIsStreaming is true, this guard skips loadGlobalMessages, but the ref was just advanced to that same message array. The re-run when streaming stops sees the same reference and returns before fetching, so selecting or reloading a global conversation during a live stream can leave the sidebar on the old/blank message list instead of loading the selected conversation. Only mark the reference as seen after the load runs, or queue the skipped load.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fe87376 — same defect, same fix. prevSidebarGlobalMessagesRef was advanced before the guard check, so a load skipped mid-stream was marked seen and never retried once streaming ended. Moved the assignment inside the guarded branch, and added a stateful regression test (ref-advances-only-on-apply in SidebarChatTab.test.tsx) that simulates the multi-render sequence directly: guard blocks -> guard passes with the same reference -> must still apply. While fixing this I found the identical shape in the pre-existing refreshSignal effect a few lines above (~612) and applied the same fix there for consistency, since it's the same file and subject.

Comment on lines 772 to 773
if (selectedAgent && !displayIsStreaming) {
setMessages(agentInitialMessages);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retry skipped sidebar agent loads after streaming

When agentInitialMessages changes during an active sidebar agent stream, this branch skips setMessages, but the previous-reference marker was already updated. After the stream completes, the displayIsStreaming dependency causes one more run with the same array reference, which returns before applying it; switching/reloading an agent conversation mid-stream can therefore leave the sidebar's stable useChat instance with stale or empty history. Defer updating the ref until the messages are actually applied, or keep a pending load flag.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fe87376 — same defect, same fix. prevSidebarAgentMessagesRef was advanced before checking !displayIsStreaming, so a load skipped mid-stream was marked seen and never retried once streaming ended. Moved the ref assignment inside the guarded branch, and added a stateful regression test (ref-advances-only-on-apply in SidebarChatTab.test.tsx) that simulates the exact multi-render sequence: guard blocks -> guard passes with the same reference -> must still apply.

Codex review on this PR (3 threads, P1) found that the streaming guards
added to the load-on-select effects advanced their "seen" ref BEFORE
checking the guard. When the guard blocked (streaming), the ref was
still marked seen — so once streaming ended, the effect would see "no
change" on the same reference and permanently skip the deferred load,
leaving the surface on stale or empty history instead of reattaching.

Fix: move the ref assignment inside the guarded branch in all three
flagged effects (GlobalAssistantView's agent load-signal effect,
SidebarChatTab's global and agent load-on-select effects). While
touching this, applied the identical fix to the pre-existing
refreshSignal effect in SidebarChatTab (same defect shape, not
introduced by this PR but same file/subject), and discovered +
fixed a related gap: GlobalAssistantView's refreshSignal effect had
no streaming guard at all, meaning it could clobber an in-progress
reply the same way the original bug did.

Added a stateful "ref-advances-only-on-apply" simulator to both test
files to pin the actual multi-render sequence the bug depends on
(guard blocks, then guard passes with the SAME reference must still
apply) — the previous single-call boolean tests couldn't express this.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx`:
- Around line 999-1012: Update the global-mode message synchronization effect
around setGlobalLocalMessages to use a previous-value ref guard like
prevSidebarGlobalMessagesRef. Apply globalInitialMessages only when the snapshot
changes, while preserving the existing initialization, conversation, agent, and
streaming guards.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e20d8fb8-450f-4363-ab6c-cf9687300be5

📥 Commits

Reviewing files that changed from the base of the PR and between 8ba87a1 and fe87376.

📒 Files selected for processing (4)
  • apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx
  • apps/web/src/components/layout/middle-content/page-views/dashboard/__tests__/GlobalAssistantView.test.tsx
  • apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx
  • apps/web/src/components/layout/right-sidebar/ai-assistant/__tests__/SidebarChatTab.test.tsx

…sage reference

CodeRabbit review found a severe regression in the previous commit: this
effect had no prevRef/dedup check, only a streaming guard. Since
effectiveIsStreaming is itself a dependency, the effect re-fires on
every streaming transition — including the streaming-to-not-streaming
edge at the end of every ordinary send. GlobalChatContext's
onStreamComplete deliberately does not refresh globalInitialMessages
for an own fresh completion ("surface's useChat already has the
message"), so without a dedup check this effect would reapply the
stale PRE-SEND snapshot the instant a stream finished, wiping every
just-completed reply in GlobalAssistantView's global mode.

Added the same prevGlobalInitialMessagesRef dedup + ref-advances-only-
on-apply discipline already used by every other load-on-select effect
in this PR, and a regression test pinning the exact failure sequence:
same snapshot reference, effectiveIsStreaming flips true -> false,
must not re-apply.
… the surface

Found via proactive review (code-review skill, not a reviewer comment):
the streaming guards added earlier in this PR (`!displayIsStreaming`/
`!effectiveIsStreaming`) blocked a load-on-select/refresh effect whenever
this surface's useChat was streaming AT ALL — but useChat's id is a
stable constant per surface, not per conversation, so switching to a
different conversation for the same agent (or a different global
conversation) does NOT abort the old conversation's in-flight send.
The old guard would therefore stay blocked by an unrelated stream still
running in a conversation the user already left, stranding the newly
selected conversation on stale/previous messages until that unrelated
stream happened to finish.

Fixed by comparing the surface's own held stream-start conversation id
(streamConvIdRef/globalStreamConvIdRef in GlobalAssistantView,
heldStreamConvIdRef in SidebarChatTab — both already existed for the
Stop/abort path) against the conversation actually being loaded, so the
guard only blocks when the live stream truly belongs to that
conversation. Six effects updated: SidebarChatTab's refreshSignal,
global load-on-select, and agent load-on-select effects; GlobalAssistantView's
refreshSignal, agent load-signal, and global load-on-select effects.

Added regression tests pinning the conversation-scoping property
directly (own stream in conversation A + switch to idle conversation B
must not block B's load) in both test files. Also removed a test that
inlined a standalone reimplementation of a bug no longer present
anywhere in production code, so it could never actually regress
(documentation dressed as an assertion, flagged by the same review pass).
@2witstudios 2witstudios merged commit 0847eb7 into master Jul 14, 2026
3 checks passed
@2witstudios 2witstudios deleted the pu/chat-stream-bug branch July 14, 2026 12:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant