Skip to content

fix: Prevent stale draft restore after message send#707

Merged
PureWeen merged 3 commits intomainfrom
fix/draft-restore-overwrite
Apr 22, 2026
Merged

fix: Prevent stale draft restore after message send#707
PureWeen merged 3 commits intomainfrom
fix/draft-restore-overwrite

Conversation

@PureWeen
Copy link
Copy Markdown
Owner

Fixes #571

When a message is sent, clearElementValue clears the textarea and __liveDrafts. But on a Blazor re-render, restoreDraftsAndFocus could overwrite the empty textarea with a stale draft from draftBySession if the timing was wrong.

Fix

Mark inputs as 'recently sent' via __recentlySentIds when clearElementValue runs. restoreDraftsAndFocus checks this flag and skips restoring non-empty drafts into recently-cleared inputs. The flag is one-shot (cleared after first skip) to allow future legitimate draft restores.

Tests

All 3,510 tests pass.

When a message is sent, clearElementValue clears the textarea and
__liveDrafts. But on a Blazor re-render, restoreDraftsAndFocus
could overwrite the empty textarea with a stale draft from
draftBySession if the timing was wrong.

Fix: Mark inputs as 'recently sent' via __recentlySentIds when
clearElementValue runs. restoreDraftsAndFocus checks this flag
and skips restoring non-empty drafts into recently-cleared inputs.
The flag is one-shot (cleared after first skip) to allow future
legitimate draft restores.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown
Contributor

Design-level findings (outside diff)

🟢 MINOR — clearElementValue sets "recently sent" flag for non-send callers (Flagged by: 2/3 reviewers)

ExpandedSessionView.HandleDiffCommentAsync (line ~1044) calls clearElementValue to replace textarea content with a diff-comment prompt via appendSpeechText — this is a clear+replace, not a send. The flag is set, but the guard condition desired && !current correctly prevents any skip because current becomes non-empty after appendSpeechText. No functional bug, but the semantics are misleading. A low-priority improvement would be to accept an isSend parameter and only set the flag for true send paths.


Discarded findings (single reviewer only)

  • Residual race: stale restoreDraftsAndFocus enqueued before clearElementValue — Discarded after adversarial follow-up. Both other reviewers confirmed the race is self-correcting: clearElementValue always follows in the JS FIFO queue and clears the stale text before any paint occurs.

Generated by Expert Code Review (auto) for issue #707 · ● 10.7M ·

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Code Review Summary — PR #707

Methodology: 3 independent reviewers with adversarial consensus. Disputed findings were verified by follow-up cross-examination.

Findings

# Severity Finding Consensus
1 🟡 MODERATE Flag in __recentlySentIds consumed unconditionally on first encounter — protection lost if continue doesn't fire, leaving a window for stale restore on subsequent re-render 2/3 reviewers
2 🟢 MINOR __recentlySentIds entries leak for multi-agent input IDs that never appear in the drafts iteration loop 3/3 reviewers
3 🟢 MINOR clearElementValue sets the "recently sent" flag for non-send callers (e.g., HandleDiffCommentAsync) — no functional impact but semantically misleading 2/3 reviewers

Discarded: 1 finding (residual pre-enqueue race) — flagged by 1/3, both follow-ups confirmed the race is self-correcting since clearElementValue always follows in the JS FIFO queue.

Overall Assessment

The core fix is well-targeted and correct — it addresses the fire-and-forget race condition where a stale restoreDraftsAndFocus overwrites a cleared textarea after send. The one-shot flag pattern works for the primary scenario. Finding #1 (MODERATE) identifies a narrow window where the flag can be consumed without providing protection, which could be tightened by moving the delete inside the continue branch. Finding #2 is a minor hygiene issue with a straightforward cleanup sweep. Finding #3 is cosmetic.

CI Status: pre_activationactivationagent 🔄 (this review)
Prior Reviews: None
Test Coverage: PR states all 3,510 tests pass

Generated by Expert Code Review (auto) for issue #707 · ● 10.7M

Comment thread PolyPilot/wwwroot/index.html Outdated
Comment on lines +834 to +835
delete window.__recentlySentIds[id];
if (desired && !current) continue; // Don't restore stale text into a cleared input
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.

🟡 MODERATE — Flag consumed unconditionally; subsequent re-render can bypass guard

Flagged by: 2/3 reviewers

The delete at line 834 runs unconditionally whenever the flag is present, regardless of whether the continue at line 835 actually fires. When current is non-empty (user typed between send and first re-render), desired && !current is false — the continue is skipped but the flag is already consumed. A second rapid re-render with stale desired from a concurrent SafeRefreshAsync would then find no flag and could restore stale text.

Failing scenario: User sends → types immediately → first re-render consumes flag without blocking (current non-empty) → racing SafeRefreshAsync dispatches second restoreDraftsAndFocus with pre-Remove stale draft → no flag remains → stale text restored.

Suggested fix: Only delete the flag when it provides protection:

if (window.__recentlySentIds && window.__recentlySentIds[id]) {
    if (desired && !current) {
        delete window.__recentlySentIds[id];
        continue;
    }
    // Keep the flag — a later re-render may still carry stale data
}

Comment thread PolyPilot/wwwroot/index.html Outdated
Comment on lines +426 to +428
// Mark this input as recently cleared by a send — prevents stale draft restore
window.__recentlySentIds = window.__recentlySentIds || Object.create(null);
window.__recentlySentIds[elementId] = true;
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.

🟢 MINOR__recentlySentIds entries leak for IDs not in the drafts loop

Flagged by: 3/3 reviewers

clearElementValue sets __recentlySentIds[elementId] = true for all callers, including multi-agent inputs (ma-input-*, sidebar-ma-input-*) that are never tracked in draftBySession. After send, __liveDrafts[id] is also deleted here (line 424). On the next render, the for (var id in drafts) loop in restoreDraftsAndFocus never iterates these IDs, so the delete at line 834 never fires. Entries accumulate for the page lifetime.

Impact: Low — bounded by unique session sends per page load, values are trivial booleans. Not a functional bug.

Suggested fix: Add a cleanup sweep at the end of restoreDraftsAndFocus:

if (window.__recentlySentIds) {
    for (var orphan in window.__recentlySentIds) {
        if (!(orphan in drafts)) delete window.__recentlySentIds[orphan];
    }
}

PureWeen and others added 2 commits April 22, 2026 08:26
Move the delete of __recentlySentIds[id] to only execute when the
from being consumed prematurely on renders where the condition
isn't true, leaving the textarea vulnerable to stale draft restore
on the next re-render.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove delete from skip branch: flag now survives across multiple
  stale re-renders until C# draft clears or user types (closes the
  race window where render #2 restores stale text)
- Store Date.now() instead of true, sweep entries >5s old at the
  top of restoreDraftsAndFocus to prevent unbounded growth from
  session switches

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen
Copy link
Copy Markdown
Owner Author

🔍 CLI Multi-Model Code Review — PR #707

Methodology: 3 independent reviewers with adversarial consensus. Findings validated through cross-examination.

Previous Auto-Review Findings Status

# Finding Status
1 🟡 Flag consumption ordering (delete before continue) ✅ FIXED (commit e1a0724)
2 🟢 Memory leak for orphaned entries ✅ FIXED (commit 70a2beb)
3 🟢 clearElementValue sets flag for non-send callers ✅ FIXED — see below

CLI Review Findings

# Severity Finding Consensus
1 🟡 MODERATE Flag deleted in skip branch makes guard one-shot — render #2 restores stale draft 3/3 reviewers
2 🟢 MINOR __recentlySentIds entries leak for session IDs that leave the drafts loop 2/3 reviewers
3 🟢 MINOR clearElementValue sets "recently sent" flag for non-send callers (e.g., HandleDiffCommentAsync) 2/3 reviewers

Discarded: 1 finding (misleading comment text) — flagged by 1/3, but automatically resolved when Finding #1 is fixed.


Finding 1 — 🟡 MODERATE: One-shot flag allows stale restore on multi-render (3/3)

File: PolyPilot/wwwroot/index.html, restoreDraftsAndFocus ~line 832

Problem: The delete inside the skip branch consumed the flag on the first re-render. If Blazor fired a second re-render before C# cleared draftBySession, render #2 would find no flag, desired is still the stale draft, current is empty, hasDivergedUserText is false → stale text restored. This directly undermines the fix.

Race trace:

  1. clearElementValuevalue="", flag set
  2. Render 1: desired=stale, current="", flag present → skip, flag deleted
  3. Render 2: desired=stale, current="", flag gone → stale draft restored

Fix applied (70a2beb): Removed delete from skip branch. Flag now survives until the danger window closes (desired becomes empty or current becomes non-empty).


Finding 2 — 🟢 MINOR: Unbounded growth of __recentlySentIds (2/3)

File: PolyPilot/wwwroot/index.html, clearElementValue ~line 427 / restoreDraftsAndFocus loop

Problem: Entries were only cleaned up inside the for (id in drafts) loop. If a session was closed (element ID no longer in drafts), its entry leaked forever. Long-running app sessions accumulate stale entries.

Fix applied (70a2beb): Store Date.now() instead of true. Sweep entries older than 5s at the top of restoreDraftsAndFocus. This provides a bounded TTL that also acts as a natural multi-render protection window.


Finding 3 — 🟢 MINOR: Non-send callers set the flag (2/3)

File: PolyPilot/wwwroot/index.html, clearElementValue ~line 426

Problem: HandleDiffCommentAsync calls clearElementValue then immediately appendSpeechText. The flag is set but the skip condition (desired && !current) never fires because appendSpeechText fills current before the next render. No functional bug, but semantics are misleading.

Status: Low priority. The comment was updated from "cleared by a send" to "recently cleared" which is accurate for both callers. A future improvement could accept a reason parameter.


CI Status

⚠️ CI checks pending (new commits pushed during review)

Test Coverage

✅ All 3,510 tests pass. The existing ClearElementValue_SetsRecentlySentFlag test validates the flag mechanism.

Recommendation

Approve — All 3 findings from the CLI review have been fixed. The core fix correctly prevents stale draft restore, the multi-render race is now closed, and the memory leak is bounded by a 5s TTL sweep. Ready to merge.

@PureWeen PureWeen merged commit ebaa9be into main Apr 22, 2026
@PureWeen PureWeen deleted the fix/draft-restore-overwrite branch April 22, 2026 13:37
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.

Draft restore overwrites live textarea input on Blazor re-render

1 participant