Skip to content

fix(native-chat): replay a draft clear dropped mid-composition - #17392

Merged
nwparker merged 1 commit into
stablyai:mainfrom
nwparker:nwparker/fix-17359-structured-send-clear-mid-composition
Aug 30, 2026
Merged

nwparker merged 1 commit into
stablyai:mainfrom
nwparker:nwparker/fix-17359-structured-send-clear-mid-composition

Conversation

@nwparker

Copy link
Copy Markdown
Contributor

Fixes #17359

The bug

A structured send clears the draft asynchronously, inside
dispatchNativeChatStructuredComposerText(...).then(...), and only on acceptance
(NativeChatComposer.tsx:255). If the user opens the next IME composition before the RPC
resolves:

  1. setDraft('') lands while a composition is active.
  2. NativeChatComposerField's layout effect skips the DOM write — the browser owns the value
    while composing (fix(native-chat): preserve IME composition across async updates #17169).
  3. onImeSettled then adopts element.value as authoritative, and the already-sent text is
    still in it.

Observed dom = "안녕하" for sent = ["안녕"]: the composer keeps the message it just sent, and
it rides along on the next one. The window is one RPC round trip and is widest on remote/SSH
structured sessions. The PTY path clears synchronously and is unaffected.

The fix

The issue lists two options; this takes the second.

Clearing optimistically before awaiting was rejected: the structured path guards on
if (!accepted) return, so an early clear would trade draft-resurrection for draft loss on a
rejected send
.

Instead the field records that a programmatic clear was dropped mid-composition and applies it
at settlement:

  • compositionBaseRef — the value the field held when the IME started (captured in the existing
    onCompositionStart); that is exactly what the clear was meant to erase.
  • droppedDraftClearRef — set in the existing DOM-sync effect when a draft === '' render is
    skipped because a composition is live.
  • At settlement (both compositionend and the blur fallback), the field keeps only the
    composed segment — diffed from both ends, since an IME edits at the caret, which need not be
    the end.

This is the same "defer while composing, flush at settlement" shape the attachment queue already
uses. The "browser always wins at settlement" model from #17169 is preserved: only clears are
carved out. Any other programmatic draft still loses to the live composition, and the clear stays
on the acceptance path, so a rejected send keeps its draft.

Tests

New native-chat-structured-send-composition-clear.test.tsx drives the real NativeChatComposer
(real field, real draft hook, real settle contract) against a structured transport whose
dispatchCommand resolves under test control:

  • the exact reported sequence — send, next composition opens before the RPC resolves, settle —
    ends at 하, and the follow-up send dispatches 하, not 안녕하
  • the same race with the composition away from the caret
  • rejected send keeps the draft (both plain and racing the next composition)
  • a handled-but-rejected command reports its error and keeps the draft to retry

Plus two field-level cases in native-chat-composer-composition.test.tsx for the blur settle path
and for not leaking a dropped clear into the following composition.

Mutation-checked: reverting NativeChatComposerField.tsx fails 3 of the new tests with exactly
the reported 안녕하 symptom; dropping only the flag reset fails the leak test.

Also fixed (issue's "Related" section)

use-native-chat-composer-attachments.test.tsx asserted the deferred flush never steals focus,
but that focus call is inside requestAnimationFrame and the test never advanced a frame — so
the assertions were vacuous. Verified by mutating the flush to applyResolvedPaths(paths, true, ...):
all 50 tests still passed before, and the mutant now fails.

Verification

  • pnpm tc — clean
  • pnpm test src/renderer/src/components/native-chat — 89 files, 908 tests pass
  • npx oxlint on the changed files — clean; pnpm run check:code-quality:changed — 0 new findings

A structured send clears the draft asynchronously, on RPC acceptance. If the
user opens the next IME composition first, the clear lands while the browser
owns the field, the DOM sync skips it, and settlement adopts element.value —
which still holds the message that was already sent.

The field now records a clear dropped mid-composition and applies it at
settlement, keeping only what the IME composed on top of the value the field
held when the composition started. Browser ownership is unchanged for every
other programmatic draft; the clear stays on the acceptance path, so a
rejected send still keeps its draft.

Also advances a frame before the attachment-flush focus assertions, which
were vacuous because the focus they forbid is scheduled in rAF.

Fixes stablyai#17359
@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 2 minutes.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 38bd8e66-5e18-43e8-9f7e-0c15f9a93638

📥 Commits

Reviewing files that changed from the base of the PR and between 7612ee6 and a9724d9.

📒 Files selected for processing (4)
  • src/renderer/src/components/native-chat/NativeChatComposerField.tsx
  • src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx
  • src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx
  • src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx

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.

@greptile-apps

greptile-apps Bot commented Aug 30, 2026

Copy link
Copy Markdown

Greptile Summary

This PR defers structured-send draft clears that arrive during IME composition and replays them when composition settles, with integration coverage for accepted and rejected sends. It also makes the attachment focus assertion advance the animation frame.

  • Captures the textarea value at composition start and derives the newly composed segment at settlement.
  • Covers composition-end and blur settlement, mid-caret composition, rejected structured sends, and deferred attachment focus.
  • The new diffing and generic clear classification leave input-loss and Unicode-corruption cases that require correction.

Confidence Score: 2/5

The PR should not merge until deferred settlement preserves repeated and supplementary-plane composition text and distinguishes launch-draft adoption from accepted-send clears.

The new settlement algorithm can silently erase newly composed text, produce malformed Unicode, and discard launch-draft content under reachable asynchronous composition races.

Files Needing Attention: src/renderer/src/components/native-chat/NativeChatComposerField.tsx

Important Files Changed

Filename Overview
src/renderer/src/components/native-chat/NativeChatComposerField.tsx Adds deferred-clear replay at IME settlement, but the string diff can erase repeated-prefix input, split surrogate pairs, and misclassify launch-draft clears.
src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx Adds blur-settlement and flag-reset coverage but omits ambiguous repeated-text and supplementary-plane cases.
src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx Adds realistic controlled-transport coverage for the reported race and rejected-send behavior.
src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx Correctly advances requestAnimationFrame so the existing no-focus-steal assertion observes scheduled behavior.

Reviews (1): Last reviewed commit: "fix(native-chat): replay a draft clear d..." | Re-trigger Greptile

) {
suffix += 1
}
return settled.slice(prefix, settled.length - suffix)

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 Repeated prefix erases composition

When the new composition inserts text matching the old draft's prefix, such as 안녕하세요 becoming 안안녕하세요, the prefix and suffix scans consume both the insertion and the entire base. The helper then returns an empty string, silently deleting the user's newly composed text.

Knowledge Base Used: Restore Terminal IME Composition Handling

Comment on lines +72 to +81
prefix += 1
}
let suffix = 0
while (
suffix < limit - prefix &&
base[base.length - 1 - suffix] === settled[settled.length - 1 - suffix]
) {
suffix += 1
}
return settled.slice(prefix, settled.length - suffix)

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 Diff splits surrogate pairs

When an IME replaces one supplementary-plane character with another sharing a surrogate code unit, such as 😀 with 😁, the code-unit prefix scan places the slice boundary inside the surrogate pair. The settled draft consequently contains a lone surrogate and displays or sends a replacement character instead of the committed text.

Knowledge Base Used: Restore Terminal IME Composition Handling

// Why: a clear (an async structured send confirming) would otherwise be lost outright and
// the sent text would ride along into the next message. Only clears are carved out of
// browser ownership; every other programmatic draft still loses to the live composition.
droppedDraftClearRef.current ||= draft === '' && textarea.value !== ''

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 Launch clear discards edits

When launch-draft adoption resolves during composition, React's stale draft can still equal the original launch text and trigger setDraft(''). This generic flag treats that update as an accepted-send clear, so settlement removes the composition-start base and discards launch-draft content the user was actively editing.

@pullfrog pullfrog 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.

✅ No new issues found.

Reviewed changes

  • NativeChatComposerField.tsx — records the value at onCompositionStart (compositionBaseRef) and flags a draft === '' render that is skipped while a composition is live; settleImeValue then keeps only the composed segment (a both-ends diff) before delegating to onImeSettled, on both compositionend and the blur fallback.
  • native-chat-composer-composition.test.tsx — field-level cases for the blur settle path and for not leaking a dropped clear into the next composition.
  • native-chat-structured-send-composition-clear.test.tsx — new integration tests driving the real NativeChatComposer against a deferred structured transport: the reported race, composition away from the caret, rejected-send draft retention (plain and racing), and handled-but-rejected command error reporting.
  • use-native-chat-composer-attachments.test.tsx — advances one requestAnimationFrame so the deferred-focus-never-steals assertions actually exercise the scheduled focus.

ℹ️ The reverse ordering still loses freshly composed text

This fix covers only the "clear lands while a composition is live" ordering. In the mirror image — the user sends, starts the next composition, and commits it before the RPC resolves — draft already follows the composed text ('안녕하'), droppedDraftClearRef is never set, and the async .then in NativeChatComposer.tsx then runs setDraft('') unconditionally, wiping the newly composed text along with the sent text. This is pre-existing behavior outside the diff, and on fast local sessions the window is negligible, but it is widest on the same remote/SSH structured sessions this PR targets. Worth confirming the issue scope doesn't require the commit-before-clear ordering, or tracking it as a follow-up (a natural guard is only clearing when draft still equals the sent text).

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@nwparker
nwparker merged commit d607a63 into stablyai:main Aug 30, 2026
29 checks passed
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…yai#17392)

A structured send clears the draft asynchronously, on RPC acceptance. If the
user opens the next IME composition first, the clear lands while the browser
owns the field, the DOM sync skips it, and settlement adopts element.value —
which still holds the message that was already sent.

The field now records a clear dropped mid-composition and applies it at
settlement, keeping only what the IME composed on top of the value the field
held when the composition started. Browser ownership is unchanged for every
other programmatic draft; the clear stays on the acceptance path, so a
rejected send still keeps its draft.

Also advances a frame before the attachment-flush focus assertions, which
were vacuous because the focus they forbid is scheduled in rAF.

Fixes stablyai#17359
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.

[Bug]: Native Chat structured send can resurrect a just-sent message into the next one

1 participant