Skip to content

fix(web): keep a long turn visible across conversation switches and browser reopens - #28

Merged
Rikul merged 3 commits into
mainfrom
claude/happy-euler-dd0r8a
Oct 27, 2026
Merged

Rikul merged 3 commits into
mainfrom
claude/happy-euler-dd0r8a

Conversation

@Rikul

@Rikul Rikul commented Oct 26, 2026

Copy link
Copy Markdown
Owner

Problem

  • Switching conversations mid-turn: the sidebar sent /load, which waited behind the running turn. The UI cleared the chat right away, so the turn's progress lines were lost and its later replies showed up in the conversation you switched to.
  • Closing and reopening the browser mid-turn: turn replies went only to the tab that sent the message. A reopened tab is a new connection, so it showed the busy dots but none of the turn's activity, and not even the final reply until a reload after the turn ended.

Changes

Server

  • BackgroundAgent.process_incoming() adds conversation_id to a turn's metadata, so thinking text, tool status lines, the reply, errors and the trajectory frame all carry it.
  • busy frames now include the turn's conversation_id.
  • WebChannel.send_message() sends every turn frame to all tabs. Command replies (system) still go only to the tab that sent the command.
  • WebChannel records what has been delivered: the last busy frame and the running turn's frames, capped at 500. A tab that connects gets these first. It holds that tab's send lock during the replay, so frames sent in the meantime wait until the replay is done. On replay, stopped is reset to false.

Frontend (app.js)

  • Conversation switching is blocked while a turn is running or pending. This covers sidebar clicks, + New, the Fork menu item and typed /new, /load and /fork. A sidebar click shows a notice explaining why.
  • Frames that arrive while the chat is loading from the server are held until the history is on screen. A replayed final reply that is already in the history isn't shown twice.
  • Turn frames for a conversation the tab isn't showing are dropped.
  • A busy: true for a turn this tab didn't start reloads that conversation from the history, so a second tab sees the user message that started it.
  • The pending dots now clear when a message is refused before its turn starts (e.g. "Restarting, please send that again").

CLAUDE.md is updated to describe the new behaviour.

Testing

  • uv run pytest: 486 passed. New tests cover sending turn frames to all tabs, command replies going to one tab, catch-up for a tab opened mid-turn, frames not replayed after the turn ends, the replay cap, scheduled-task output not counted as a turn frame, and the conversation tag on turn metadata.
  • ruff check and ruff format are clean. pylint reports only a cyclic import in app/core/tool.py, which this PR doesn't touch.
  • I ran the UI in Chromium (Playwright) against a fake slow model:
    • Mid-turn sidebar click: blocked with the notice, and + New was disabled.
    • Close and reopen mid-turn: the new tab showed the progress so far, then the rest live, the final reply and the trajectory button.
    • Second tab: saw the whole turn, and its own message showed up in the first tab.
    • After the turn ended: switching worked again, and a reload showed no duplicate messages.

Known limits

  • Tool status lines and thinking text still aren't saved. After a turn ends, a reload shows only the user message and the final reply, as before.

Generated by Claude Code

…rowser reopens

- Tag every agent-turn frame (thinking, tool status, reply, error,
  trajectory) and busy frame with the turn's conversation ID.
- Broadcast turn frames to all tabs instead of only the tab that sent the
  message, so a reopened browser still gets the turn's replies.
- Replay the last busy frame and the running turn's frames to a tab when it
  connects, so it sees the progress so far.
- UI: block switching conversations (sidebar, + New, Fork, typed
  /new|/load|/fork) while a turn is running or pending; hold incoming frames
  until the history has loaded; follow turns started from another tab;
  clear the pending dots when a message is rejected (e.g. during restart).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dwjz3bkBQygqJ7jmUd7Tb

Copilot AI 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.

🟡 Changes recommended

Five unresolved moderate issues remain in frontend switch guards, replay deduplication, refusal handling, and trajectory delivery.

1 open issue
What changed in this PR

This pull request improves continuity for long-running agent turns across conversation switches, browser reconnects, and multiple tabs by associating turn activity with conversations and replaying in-progress frames.

Changes:

  • Adds conversation identifiers to turn metadata, busy frames, errors, and trajectory notifications.
  • Broadcasts and replays bounded in-progress turn activity across connected tabs.
  • Prevents conversation switching during active or pending turns and buffers frames during history loading.
  • Adds regression tests and documents the updated WebChannel behavior.
File Description
tests/​test_web_channel.py Tests frame broadcasting, replay, capping, and frontend locking behavior.
tests/​test_background_agent.py Tests conversation metadata propagation on turn responses and errors.
CLAUDE.md Documents the updated WebChannel turn and replay behavior.
app/​core/​background_agent.py Adds conversation IDs to turn metadata and error replies.
app/​channels/​web_channel.py Tracks, broadcasts, and replays busy and turn frames across WebSocket connections.
app/​channels/​frontend/​app.js Handles replayed frames, conversation filtering, deduplication, and switch locking.
app/​channels/​frontend/​app.css Adds visual disabled and locked states for conversation controls.

Review effort: Lite

Comment thread app/channels/frontend/app.js
@Rikul

Rikul commented Oct 26, 2026

Copy link
Copy Markdown
Owner Author

@qodo

@qodo-code-review

Copy link
Copy Markdown

@qodo

What would you like me to review about this PR? Please include the specific question, or a finding’s title/location if you’re referring to one.

@Rikul

Rikul commented Oct 26, 2026

Copy link
Copy Markdown
Owner Author

@qodo

What would you like me to review about this PR? Please include the specific question, or a finding’s title/location if you’re referring to one.

@qodo review pr

@qodo-code-review

qodo-code-review Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. An open menu can fork during a turn ✓ Resolved
Description
openConvMenu() sets Fork's disabled state only when it creates the menu, while forkConv() does
not recheck switchLocked(). If a user opens the menu before a turn starts and clicks Fork during
that turn, the retained button clears the chat and sends a conversation-switching command despite
the new lock.
Code

app/channels/frontend/app.js[R251-252]

+            { label: 'Fork',   action: () => forkConv(conv.id),
+              disabled: switchLocked() ? SWITCH_LOCKED : '' },
Relevance

●●● Strong

Concrete race in a newly added lock; PR #26 accepted closely related frontend state bugs.

PR-#26

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
An open menu prevents sidebar rerendering, and the busy-state update changes only list styling
rather than existing menu buttons. The Fork action subsequently clears messages and sends /fork
without another lock check.

app/channels/frontend/app.js[204-224]
app/channels/frontend/app.js[247-274]
app/channels/frontend/app.js[376-393]
app/channels/frontend/app.js[660-667]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A Fork button created before a turn starts remains usable during the turn.
## Fix Focus Areas
- app/channels/frontend/app.js[250-252]
- app/channels/frontend/app.js[376-393]
## Recommended Fix
Check `switchLocked()` in `forkConv()` before clearing or loading messages and again before sending `/fork` after the asynchronous history load. Show the existing locked notice when either check fails.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Reconnecting near the end of a turn shows its reply twice ✓ Resolved
Description
handleFrame() compares a replayed final reply only with lastAiRow(), even though replayed
thinking and > tool [...] status frames append AI rows before the final frame is handled. When a
tab reconnects after agent_loop() stores a tool-using turn’s reply in history but before `busy:
false` is delivered, those rows displace the stored reply as the last AI row, leaving the turn’s
status lines beneath it and causing the final frame to append the same reply again.
Code

app/channels/frontend/app.js[R589-592]

+            // The reply may be in the history already (it is stored as it is sent).
+            const last = caughtUp && data.final ? lastAiRow() : null;
+            const row = last && last.querySelector('.bubble').dataset.raw === data.content
+                ? last : appendMessage(data.content, 'ai');
Relevance

●●● Strong

Reconnect deduplication is invalidated by intermediate rows; PR #26 accepted a closely related
frontend replay bug.

PR-#26

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The reconnect path loads history before draining held frames. The agent queues progress and final
frames, stores the final assistant content in history, and clears _turn_frames only when `busy:
false` is delivered, leaving a window in which catch-up replays the turn after its reply has been
loaded from history. Because non-final frames are appended as AI rows before the held final frame is
handled, lastAiRow() points to a progress line rather than the stored reply when the dedupe
comparison runs.

app/core/background_agent.py[238-246]
app/core/background_agent.py[99-132]
app/channels/web_channel.py[614-621]
app/channels/frontend/app.js[582-611]
app/channels/frontend/app.js[519-525]
app/channels/frontend/app.js[553-562]
app/channels/frontend/app.js[589-611]
app/core/background_agent.py[101-130]
app/core/background_agent.py[237-259]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A final reply already loaded from history is appended again during catch-up because replayed thinking and tool-status rows move it out of the last AI row position before the final frame is checked.

## Fix Focus Areas
- app/channels/frontend/app.js[553-564]
- app/channels/frontend/app.js[582-597]

## Recommended Fix
Before draining held frames in `holdFrames`, retain the last AI row loaded from history and compare its `dataset.raw` with any held `final` frame’s content, rather than comparing against the current last row after progress frames have been appended. When they match, skip the held non-final turn frames and final frame for that turn, while preserving trajectory attachment to the matched history row and busy handling.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. A task reply unlocks a pending chat ✓ Resolved
Description
handleFrame() clears pendingTurn for any untagged message received while the agent is not yet
busy, although an untagged message is not necessarily a refusal of this tab's request. If a
scheduled task delivers output between sending a user message and receiving its busy frame,
conversation switching becomes available while that user turn is still pending.
Code

app/channels/frontend/app.js[R583-586]

+            if (data.conversation_id == null && !agentBusy && pendingTurn) {
+                // e.g. "Restarting, please send that again": the turn never started
+                pendingTurn = false;
+                renderBusy();
Relevance

●●● Strong

Untagged scheduled output can falsely clear pending state, undermining the PR’s explicit
switching-lock behavior.

PR-#26

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Scheduled-task delivery uses default metadata without a turn conversation ID, and WebChannel
broadcasts messages with no requesting tab. The frontend's new condition treats that broadcast as a
rejected pending turn and releases the switching lock.

app/core/scheduled_tasks.py[315-326]
app/channels/web_channel.py[584-612]
app/channels/frontend/app.js[582-587]
app/channels/frontend/app.js[623-628]
app/channels/frontend/app.js[1047-1060]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Unrelated untagged broadcasts clear the pending-turn lock before the queued turn starts.
## Fix Focus Areas
- app/channels/frontend/app.js[582-587]
- app/channels/web_channel.py[603-612]
- app/core/background_agent.py[179-186]
## Recommended Fix
Give pre-turn refusal frames an explicit indicator tied to the requesting tab or request, and clear `pendingTurn` only for that indicator. Leave scheduled-task broadcasts unable to change pending-turn state.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

4. Turn progress can appear above the reloaded history ✓ Resolved
Description
holdFrames handles its saved frames with frames.forEach(f => handleFrame(f, true)) after setting
heldFrames = null. If one of those frames is a busy: true for another conversation, onBusy
starts a nested holdFrames reload, but the frames still left in the same batch are not held. They
go straight to handleFrame right after clearMessages(), so they are drawn first and the fetched
history is then appended below them.
Code

app/channels/frontend/app.js[R558-561]

+            if (outer) {
+                const frames = heldFrames;
+                heldFrames = null;
+                frames.forEach(f => handleFrame(f, true));
Relevance

●● Moderate

Plausible nested asynchronous ordering bug, but no closely matching historical acceptance or
rejection precedent.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
onBusy calls holdFrames without awaiting it. The nested call runs clearMessages()
synchronously and then awaits loadMessages, so control returns to the outer forEach. That loop
calls handleFrame directly instead of pushing onto the new heldFrames. loadMessages only
appends rows, so the history ends up below the frames that were already drawn.

app/channels/frontend/app.js[553-564]
app/channels/frontend/app.js[637-648]
app/channels/frontend/app.js[169-182]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When a held frame starts a nested reload, the frames left in the batch skip the new hold and are drawn before the reloaded history.

## Fix Focus Areas
- app/channels/frontend/app.js[553-564]

## Recommended Fix
In the `finally` block, go through the frames with an index. Before handling each frame, check `heldFrames`: if it is non-null again (a nested hold started), push the remaining frames onto it and stop, for example `for (let i=0;i<frames.length;i++){ if (heldFrames) { heldFrames.push(...frames.slice(i)); break; } handleFrame(frames[i], true); }`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 6 rules
Review mode: Auto: 🧠 Deep: Cross-tab state, replay ordering, frontend locking, and turn routing create many independent edge cases.

Grey Divider

Tip of the day
💡 Did you know, you can describe a rule in plain language on the Rules page and Qodo drafts it for you

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread app/channels/frontend/app.js
Comment thread app/channels/frontend/app.js Outdated
Comment thread app/channels/frontend/app.js Outdated
Comment thread app/channels/frontend/app.js Outdated
- Fork re-checks the switch lock when clicked (the menu may predate the
  turn) and again after loading the history.
- Only a message marked not_started (the restart refusal) clears the
  pending-turn dots; scheduled-task output no longer unlocks switching.
- Catch-up: when the loaded history already ends with a held turn's reply,
  drop that turn's held lines instead of showing them (and the reply
  again) below it.
- A held frame that starts another reload hands the remaining frames to
  that reload instead of drawing them above its history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dwjz3bkBQygqJ7jmUd7Tb
@Rikul
Rikul marked this pull request as ready for review October 27, 2026 21:54
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T21:56:02.097528Z 6faa631 Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: 6faa631a97

ℹ️ 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 thread app/channels/frontend/app.js Outdated
The busy frame a tab gets on connect is now marked replay: its turn began
before the tab loaded the history, so the user message is already there.
A busy: true that merely waited while the history loaded (another tab
started a turn meanwhile) no longer counts as covered, so the tab reloads
the conversation and shows that turn's user message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dwjz3bkBQygqJ7jmUd7Tb

Copilot AI 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.

🟡 Changes recommended

A critical concurrency issue remains because conversation-switch commands can still be accepted while another turn is active, allowing the shared agent to switch and lose the in-progress reply.

1 open issue
1 resolved since last review

Review effort: Lite

Comment thread app/channels/frontend/app.js
@Rikul
Rikul merged commit 8d3e7c3 into main Oct 27, 2026
2 checks passed
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.

3 participants