Skip to content

[LUM-832] Fix cursor flickering in chat composer by making updateNSView efficient at the NSViewRepresentable boundary#25597

Merged
tkheyfets merged 3 commits into
mainfrom
devin/1776191606-lum-832-fix-cursor-flicker
Apr 16, 2026
Merged

[LUM-832] Fix cursor flickering in chat composer by making updateNSView efficient at the NSViewRepresentable boundary#25597
tkheyfets merged 3 commits into
mainfrom
devin/1776191606-lum-832-fix-cursor-flicker

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Fixes cursor flickering in the chat composer by making updateNSView essentially a no-op for text-only changes — the previous implementation reassigned ~10 closures, called unregisterDraggedTypes(), scheduled focus updates, and forced TextKit ensureLayout on every keystroke. The fix routes all callbacks through the Coordinator (wired once in makeNSView), guards every property update behind change detection, replaces the focus @Binding with a one-way value + callback (eliminating the 3-variable focus dance), and removes redundant measureHeight calls.



Open with Devin

Remove @State var cursorPosition from ComposerView — every keystroke
and selection change wrote to this binding, forcing a full SwiftUI body
re-evaluation cycle that caused the NSTextView insertion point to
flicker and sometimes block text deletion/submission.

Cursor position now flows directly from the NSTextView delegate to
ComposerController via a closure callback, bypassing SwiftUI state
entirely. ComposerController's observable property mutations are also
guarded with equality checks to prevent no-op @observable change
notifications from triggering unnecessary view updates.

Closes LUM-832

Co-Authored-By: tkheyfets <timur@vellum.ai>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@tkheyfets

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 3 additional findings.

Open in Devin Review

…ndary

Addresses the architectural root cause of cursor flickering: updateNSView
doing heavyweight work on every keystroke. The previous fix only removed
the cursorPosition binding; this overhaul makes the entire NSViewRepresentable
boundary efficient by default.

Changes:
- Route all ComposerTextView callbacks through the Coordinator (set once in
  makeNSView, not reassigned per-update). Closures capture coordinator weakly
  and forward to coordinator.parent which is refreshed each updateNSView.
- Guard every updateNSView property update behind change detection: isEditable,
  cmdEnterToSend, focus, and unregisterDraggedTypes only fire when their
  inputs actually changed.
- Replace focus @binding with one-way let + onFocusChanged callback, matching
  the cursor-position callback pattern. Eliminates the binding round-trip that
  caused body re-evaluation on every NSTextView focus change.
- Collapse 3 focus state variables (composerFocus, isComposerFocused,
  textViewIsFocused) into 1 (composerFocus). Removes the triangular onChange
  dependency that amplified re-evaluation cascades.
- Remove redundant measureHeight call from updateNSView — frame/bounds
  notification observers already handle this. Only re-measure after external
  text replacement or font changes.
- Seed Coordinator last-applied tracking in makeNSView so the first
  updateNSView skips properties that match their initial values.

Part of LUM-832

Co-Authored-By: tkheyfets <timur@vellum.ai>
@devin-ai-integration devin-ai-integration Bot changed the title [LUM-832] Fix cursor flickering in chat composer by removing @State cursorPosition binding [LUM-832] Fix cursor flickering in chat composer by making updateNSView efficient at the NSViewRepresentable boundary Apr 14, 2026

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 6 additional findings in Devin Review.

Open in Devin Review

Comment on lines +281 to +285
// Re-strip drag types only when TextKit may have re-registered
// them (after font or attribute changes), not on every keystroke.
if fontChanged || colorChanged {
textView.unregisterDraggedTypes()
}

@devin-ai-integration devin-ai-integration Bot Apr 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🚩 unregisterDraggedTypes guard may miss edge cases

The old code called textView.unregisterDraggedTypes() unconditionally on every updateNSView. The new code at ComposerTextEditor.swift:286-288 only calls it when fontChanged || colorChanged || textWasExternallyReplaced. This is based on the assumption that TextKit only re-registers drag types during attribute changes or text storage manipulation. If there are other AppKit code paths that silently re-register drag types (e.g., during scroll view layout, window activation), file paths could be inserted as text on drop. The previous unconditional call was defensive. This is likely fine in practice given the .onDrop modifier on the composer container handles file drops at the SwiftUI level.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid concern. Widened the guard in 25b21d0 to also call unregisterDraggedTypes() after external text replacement (textWasExternallyReplaced), since NSTextStorage manipulation can trigger drag type re-registration beyond just font/color changes. Normal per-keystroke typing (where text flows through the textDidChange delegate, not through updateNSView text sync) still skips the call.

Address two Devin Review findings:

1. Move lastFocused update inside the window-availability check so that
   if textView.window is nil during a view hierarchy transition, the
   focus request is retried on the next updateNSView call instead of
   being permanently lost.

2. Widen unregisterDraggedTypes guard to also fire after external text
   replacement (textWasExternallyReplaced), since NSTextStorage
   manipulation can trigger drag type re-registration beyond just
   font/color changes. Normal per-keystroke typing still skips it.

Part of LUM-832

Co-Authored-By: tkheyfets <timur@vellum.ai>
@tkheyfets tkheyfets merged commit 88fe7e1 into main Apr 16, 2026
6 checks passed
@tkheyfets tkheyfets deleted the devin/1776191606-lum-832-fix-cursor-flicker branch April 16, 2026 18:41
devin-ai-integration Bot added a commit that referenced this pull request Apr 16, 2026
Adopt the callback-based focus / cursor-position wiring and per-property\nchange guards introduced by #25597, and keep the measureHeight coalescer\n+ skip-guard from this branch. The guarded `if textWasExternallyReplaced\n|| fontChanged` re-measure in updateNSView now routes through\n`scheduleMeasureHeight` so it coalesces with frame/bounds notifications\nthat the same event will fire.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
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