Skip to content

perf: cut composer typing latency#325

Merged
hrayleung merged 2 commits into
masterfrom
fix/laggy-input
May 11, 2026
Merged

perf: cut composer typing latency#325
hrayleung merged 2 commits into
masterfrom
fix/laggy-input

Conversation

@hrayleung
Copy link
Copy Markdown
Owner

@hrayleung hrayleung commented May 11, 2026

Summary

The chat composer felt noticeably laggier than other native macOS apps. Three layers of per-keystroke work were stacking up:

  1. ChatView body re-evaluating on every keystroke. messageText was @State on ChatView, so any text change invalidated the whole chat tree (composer + message timeline scaffolding). Moved the text into an @Observable ComposerTextStore. Introduced small wrapper views — ChatComposerBindingHost, ChatCollapsedComposerBarHost, ChatComposerTextChangeObserver — that observe store.text locally. ChatView's body no longer reads the text in any body context; action handlers still access it via a messageText accessor (safe outside body).
  2. NSTextView typing gated on the SwiftUI update cycle. Coordinator.textDidChange used to write the binding synchronously, which meant the keystroke handler couldn't return until SwiftUI scheduled its update. Now the binding write is deferred via DispatchQueue.main.async with a coalescing flag; updateNSView skips syncExternalTextIfNeeded while a flush is pending so a stale binding cannot overwrite the in-flight string when an unrelated state change triggers a re-render between keystrokes.
  3. Surgical hot-path cleanup. Replaced SlashCommandDetection.detectFilter's regex-with-lookbehind with a manual tail-scan and added a mayContainActiveToken precheck. Cached ComposerDraftTextMetrics in @State (instead of recomputing components(separatedBy:) on every body call in the expanded composer). Added a last-reported-height guard in Coordinator so the height callback no longer fires for typing that stays within a single line.

Files of note

  • Sources/UI/ComposerTextStore.swift (new) — @Observable text store.
  • Sources/UI/ChatComposerStateHosts.swift (new) — three small wrappers that scope the observation.
  • Sources/UI/ChatView.swift, ChatView+ComposerHelpers.swift, ChatView+ComposerViews.swift, ChatView+FloatingComposer.swift — switch to the store + wrappers; add the action-context messageText accessor.
  • Sources/UI/DroppableTextEditor+Coordinator.swift, DroppableTextEditor.swift — deferred binding flush, height-cache guard.
  • Sources/UI/SlashCommandMCPSupport.swift, ChatView+SlashCommands.swift — manual tail-scan + precheck.
  • Sources/UI/ExpandedComposerOverlay.swift — cached draft metrics.

Test plan

  • swift build clean.
  • swift test — full suite, 2260 tests, 0 failures.
  • swift test --filter ChatComposerSupportTests — includes new edge-case coverage for detectFilter (empty string, whitespace-only, no-slash, newline/tab boundary, trailing whitespace) plus mayContainActiveToken short-circuit behavior.
  • ./Packaging/package.sh — release .app builds successfully.
  • Manual: type a paragraph in a long conversation (50+ messages) — characters appear instantly, no perceptible delay vs Notes / TextEdit.
  • Manual: hold a key for autorepeat — smooth scrolling, no stutter.
  • Manual: with an MCP server configured, type / → popover appears; type to filter → responsive; Escape → dismisses.
  • Manual: open expanded composer, paste a long draft, type at the end → smooth; word-count footer updates.
  • Manual: send a message, then type a new one → text editor clears and re-accepts input cleanly.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Refactor

    • Optimized text input performance with deferred binding updates and improved state caching
    • Refactored composer text state management for enhanced stability
    • Improved slash command detection with more efficient scanning
  • Tests

    • Added comprehensive test coverage for slash command detection edge cases

Review Change Stack

Three layers of work were happening on every keystroke in the chat
composer, compounding into noticeable input lag:

- ChatView owned `messageText` as @State, so every keystroke invalidated
  ChatView's full body and walked the entire chat tree. Moved text into
  an @observable `ComposerTextStore` and introduced small hosting views
  (`ChatComposerBindingHost`, `ChatCollapsedComposerBarHost`,
  `ChatComposerTextChangeObserver`) that scope the observation to the
  composer subtree.
- NSTextView's `textDidChange` synchronously wrote the SwiftUI binding,
  gating native character display on the SwiftUI update cycle. Deferred
  the write via `DispatchQueue.main.async` with a coalescing flag, and
  taught `updateNSView` to skip `syncExternalTextIfNeeded` while a flush
  is pending so a stale binding cannot overwrite in-flight typing.
- Slash-command detection ran a regex with lookbehind on the full string
  every keystroke, the expanded composer recomputed word/char counts via
  `components(separatedBy:)` on every body call, and the height callback
  fired unconditionally. Replaced the regex with a manual tail-scan plus
  a cheap precheck, cached draft metrics in @State, and added a
  last-height guard in the Coordinator.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 2026

Warning

Rate limit exceeded

@hrayleung has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 42 minutes before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 846ed522-c7b8-4c5f-a5e4-591bdfd9d11c

📥 Commits

Reviewing files that changed from the base of the PR and between 7bfdb09 and f1380cc.

📒 Files selected for processing (5)
  • Sources/UI/ChatComposerStateHosts.swift
  • Sources/UI/ChatView.swift
  • Sources/UI/ComposerTextStore.swift
  • Sources/UI/DroppableTextEditor+Coordinator.swift
  • Sources/UI/ExpandedComposerOverlay.swift
📝 Walkthrough

Walkthrough

This PR refactors ChatView's composer text state to prevent body invalidation on every keystroke. Composer text moves to an observable ComposerTextStore outside ChatView's state, composer overlays wrap in ChatComposerBindingHost to localize binding updates, editor text synchronization defers to the next runloop iteration, and slash-command detection switches from regex to bounded backward scan with early-exit precheck.

Changes

Composer Text State & Binding Decoupling

Layer / File(s) Summary
Observable Text Store
Sources/UI/ComposerTextStore.swift
New @Observable final class ComposerTextStore with text: String property, held in ChatView state but observed independently to avoid body invalidation on keystroke.
ChatView State Refactoring
Sources/UI/ChatView.swift
ChatView's @State messageText replaced with @State composerTextStore = ComposerTextStore(). Guidance added that text reads must flow through wrapper views or action handlers.
Message Text Access Wrapper
Sources/UI/ChatView+ComposerHelpers.swift
New messageText computed property on ChatView forwards reads/writes to composerTextStore.text for safe use in action handlers.
Composer Binding Host Views
Sources/UI/ChatComposerStateHosts.swift
ChatComposerBindingHost computes canSendDraft from text/attachments/quotes/import-gate and supplies fresh binding to content closure. ChatCollapsedComposerBarHost wraps CollapsedComposerBar with hasContent flag. ChatComposerTextChangeObserver listens for text changes invisibly via onChange.
Composer Overlay Wrapping
Sources/UI/ChatView+ComposerViews.swift
composerOverlay and expandedComposerSheet wrap their target views in ChatComposerBindingHost to receive textBinding and canSendDraft rather than binding directly, preserving all other behavior (drop handling, submit/cancel, slash commands).
Floating Composer Host Wiring
Sources/UI/ChatView+FloatingComposer.swift
Collapsed composer bar rendered via ChatCollapsedComposerBarHost instead of directly; composerTextChangeObserver added to content via .overlay modifier.
Editor Binding Update Deferral
Sources/UI/DroppableTextEditor+Coordinator.swift, Sources/UI/DroppableTextEditor.swift
Coordinator introduces hasPendingBindingFlush state and schedules textBinding updates asynchronously via DispatchQueue.main.async instead of inline, guards against duplicate enqueues, and exposes isBindingFlushPending flag. updateNSView skips external text sync when flush is pending to prevent stale binding overwrites.
Content Height Reporting Optimization
Sources/UI/DroppableTextEditor+Coordinator.swift
reportContentHeight caches last reported height and suppresses callbacks when height change is within 0.5-point tolerance.
Expanded Composer Metrics State
Sources/UI/ExpandedComposerOverlay.swift
Draft metrics moved from computed property to @State variable, initialized on appear with editor focus scheduled after motion-adjusted delay, and updated via onChange listener on messageText binding.
Slash Command Detection Optimization
Sources/UI/SlashCommandMCPSupport.swift
Replaces regex-based slash-token detection with bounded backward character scan via activeTokenLookbackLimit. Adds mayContainActiveToken for cheap precheck and activeSlashIndex helper to locate the / anchoring the active trailing token. detectFilter and removeSlashToken now use String.Index slicing instead of ranges.
Slash Command State Update Guard
Sources/UI/ChatView+SlashCommands.swift
updateSlashCommandState adds early return when popover is hidden and text cannot contain active slash token (checked via mayContainActiveToken).
Slash Command Detection Tests
Tests/JinTests/ChatComposerSupportTests.swift
Adds testSlashCommandDetectionEdgeCases covering empty/whitespace/multiline/trailing-space inputs; extends testSlashCommandRemovalDropsOnlyTrailingBoundaryToken with empty and multiline cases; adds testMayContainActiveTokenShortCircuitsWhenSafe verifying precheck logic for safe inputs and token-containing inputs.

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • hrayleung/Jin#247: Changes to DroppableTextEditor's synchronization logic to prevent stale binding overwrites during IME composition and in-flight edits.
  • hrayleung/Jin#287: Modifies ChatView's composer overlay and messageText binding behavior, directly related to the refactored composer binding architecture.
  • hrayleung/Jin#286: Refactors composer draft text storage and observation to avoid expensive recomputations, aligned with this PR's state decoupling strategy.

Poem

🐰 Text flies free from ChatView's weary arms,
Into a store that hums without alarms,
Binding defers—no keystroke blocks the way,
Slash tokens scan, no regex delays the day! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title "perf: cut composer typing latency" directly and clearly describes the main objective of the PR—reducing typing latency in the chat composer through performance optimizations.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/laggy-input

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 and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (3)
Sources/UI/ChatView.swift (1)

83-88: ⚡ Quick win

Add the private_swiftui_state SwiftLint pragma for consistency.

SwiftLint flags composerTextStore (it must be accessible from ChatView extensions, so it can't be private). Other @State vars in this file that have the same constraint already carry // swiftlint:disable:next private_swiftui_state (see lines 91, 96, 117, 132, etc.). Add it here to silence the warning and match the convention.

♻️ Proposed change
     // Composer text lives in an `@Observable` store so per-keystroke writes do
     // not invalidate ChatView's body. Read `composerTextStore.text` only from
     // action handlers (or via the `messageText` accessor); body-context reads
     // must go through ChatComposerBindingHost / ChatComposerTextChangeObserver
     // so the dependency is scoped to those small wrapper views.
+    // swiftlint:disable:next private_swiftui_state
     `@State` var composerTextStore = ComposerTextStore()
🤖 Prompt for 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.

In `@Sources/UI/ChatView.swift` around lines 83 - 88, Add the SwiftLint pragma to
silence the private_swiftui_state warning for the ChatView `@State` property:
above the declaration of composerTextStore (the `@State` var composerTextStore =
ComposerTextStore() inside ChatView), insert the comment //
swiftlint:disable:next private_swiftui_state so it matches the other `@State`
properties in this file and remains accessible to ChatView extensions.
Sources/UI/ComposerTextStore.swift (1)

9-12: 💤 Low value

Consider marking ComposerTextStore as @MainActor.

This store holds UI-bound composer text mutated from SwiftUI views and from Task { @mainactor in ... } action handlers (e.g., toggleSpeechToText in ChatView+ComposerHelpers.swift). Annotating the type with @MainActor documents and enforces main-thread access, which is consistent with the repo guideline to use @MainActor for UI-bound state.

♻️ Proposed change
 `@Observable`
+@MainActor
 final class ComposerTextStore {
     var text: String = ""
 }

As per coding guidelines: "Prefer actor for shared mutable state, use @MainActor for UI-bound state".

🤖 Prompt for 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.

In `@Sources/UI/ComposerTextStore.swift` around lines 9 - 12, Mark the UI-bound
store ComposerTextStore with `@MainActor` so its mutations are enforced on the
main thread; update the declaration of final class ComposerTextStore to be
`@MainActor` final class ComposerTextStore (affecting the class that contains var
text: String) to ensure SwiftUI views and Task { `@MainActor` in ... } handlers
like toggleSpeechToText access it on the main actor.
Sources/UI/ChatComposerStateHosts.swift (1)

7-58: ⚡ Quick win

Align host view type/file naming with Swift UI conventions

This file introduces multiple primary view types and none follow the *View suffix rule. Please split these into separate files (or clearly define one primary type per file) and rename types to end with View for consistency and discoverability.

As per coding guidelines, "Sources/UI/**/*.swift: SwiftUI views should use the naming pattern *View" and "**/*.swift: Use one primary type per file, with the filename matching the type name".

🤖 Prompt for 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.

In `@Sources/UI/ChatComposerStateHosts.swift` around lines 7 - 58, Rename the
three SwiftUI host types to follow the *View suffix and place each primary type
in its own file whose filename matches the type: rename ChatComposerBindingHost
-> ChatComposerBindingView, ChatCollapsedComposerBarHost ->
ChatCollapsedComposerBarView, and ChatComposerTextChangeObserver ->
ChatComposerTextChangeObserverView; move each into a new file named exactly
after the new type, update any call sites/imports to use the new type names, and
keep the internal properties and body implementations unchanged so behavior is
preserved.
🤖 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 `@Sources/UI/ChatComposerStateHosts.swift`:
- Line 35: The collapsed composer "hasContent" check currently uses
composerTextStore.text.isEmpty which treats whitespace-only input as content;
change it to check trimmed text instead (use composerTextStore.text trimmed via
trimmingCharacters(in: .whitespacesAndNewlines)) so hasContent is true only when
the trimmed text is non-empty or hasOtherContent is true; update the expression
that sets hasContent in ChatComposerStateHosts (the place constructing the
collapsed composer state) to use the trimmed check to match sendability logic.

In `@Sources/UI/DroppableTextEditor`+Coordinator.swift:
- Around line 15-16: The deferred binding flush can overwrite intentional
updates and leave hasPendingBindingFlush stuck; fix by (1) when scheduling the
async flush capture the current textBinding value (and/or previousBindingValue)
and store it with hasPendingBindingFlush, (2) in the async block first
clear/reset hasPendingBindingFlush and bail if textView is nil, and (3) before
assigning to textBinding compare the current textBinding to the captured
previous value and only assign if they still match (this preserves intentional
in-between updates like clear-on-submit) while ensuring hasPendingBindingFlush
is always reset even if textView is nil; update the code around uses of
hasPendingBindingFlush, textView, and textBinding to implement this
check-and-reset behavior.

In `@Sources/UI/ExpandedComposerOverlay.swift`:
- Line 47: draftMetrics is initialized empty causing a one-frame flash when
expanding with existing text; update the view to synchronously initialize
draftMetrics from the messageText binding (e.g. provide a custom init that sets
_draftMetrics = State(initialValue: ComposerDraftTextMetrics(messageText:
messageText)) or otherwise assign draftMetrics before the async focus dispatch)
and also apply the same fix to the other instance of ComposerDraftTextMetrics
initialization referenced in the file so ExpandedComposerFooter renders correct
metrics on first render.

---

Nitpick comments:
In `@Sources/UI/ChatComposerStateHosts.swift`:
- Around line 7-58: Rename the three SwiftUI host types to follow the *View
suffix and place each primary type in its own file whose filename matches the
type: rename ChatComposerBindingHost -> ChatComposerBindingView,
ChatCollapsedComposerBarHost -> ChatCollapsedComposerBarView, and
ChatComposerTextChangeObserver -> ChatComposerTextChangeObserverView; move each
into a new file named exactly after the new type, update any call sites/imports
to use the new type names, and keep the internal properties and body
implementations unchanged so behavior is preserved.

In `@Sources/UI/ChatView.swift`:
- Around line 83-88: Add the SwiftLint pragma to silence the
private_swiftui_state warning for the ChatView `@State` property: above the
declaration of composerTextStore (the `@State` var composerTextStore =
ComposerTextStore() inside ChatView), insert the comment //
swiftlint:disable:next private_swiftui_state so it matches the other `@State`
properties in this file and remains accessible to ChatView extensions.

In `@Sources/UI/ComposerTextStore.swift`:
- Around line 9-12: Mark the UI-bound store ComposerTextStore with `@MainActor` so
its mutations are enforced on the main thread; update the declaration of final
class ComposerTextStore to be `@MainActor` final class ComposerTextStore
(affecting the class that contains var text: String) to ensure SwiftUI views and
Task { `@MainActor` in ... } handlers like toggleSpeechToText access it on the
main actor.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e803c7cc-0ae8-45e9-bdc1-4f7f4deec71c

📥 Commits

Reviewing files that changed from the base of the PR and between 2440296 and 7bfdb09.

📒 Files selected for processing (12)
  • Sources/UI/ChatComposerStateHosts.swift
  • Sources/UI/ChatView+ComposerHelpers.swift
  • Sources/UI/ChatView+ComposerViews.swift
  • Sources/UI/ChatView+FloatingComposer.swift
  • Sources/UI/ChatView+SlashCommands.swift
  • Sources/UI/ChatView.swift
  • Sources/UI/ComposerTextStore.swift
  • Sources/UI/DroppableTextEditor+Coordinator.swift
  • Sources/UI/DroppableTextEditor.swift
  • Sources/UI/ExpandedComposerOverlay.swift
  • Sources/UI/SlashCommandMCPSupport.swift
  • Tests/JinTests/ChatComposerSupportTests.swift

Comment thread Sources/UI/ChatComposerStateHosts.swift Outdated
Comment thread Sources/UI/DroppableTextEditor+Coordinator.swift
Comment thread Sources/UI/ExpandedComposerOverlay.swift Outdated
- ChatCollapsedComposerBarHost: use trimmed text for `hasContent` so a
  whitespace-only draft doesn't paint the collapsed chip as having
  content (matches canSendDraft semantics).
- DroppableTextEditor+Coordinator: capture the binding value at schedule
  time and skip the async flush when the binding has been mutated
  externally before it runs (e.g. clear-on-submit). Reset
  `hasPendingBindingFlush` via `defer` so the flag can never get stuck
  if the textView has been deallocated.
- ExpandedComposerOverlay: seed `draftMetrics` from the messageText
  binding in a custom init so the first render of the footer reflects
  the existing draft instead of flashing "0 words · 0 characters" for
  one frame when expanding from compact.
- ChatView: add the `private_swiftui_state` swiftlint pragma above
  `composerTextStore` to match the convention used by other @State
  vars in the file.
- ComposerTextStore: mark `@MainActor` per repo guidelines for UI-bound
  state.

Skipped CodeRabbit's nitpick to rename the helper hosts to `*View` and
split them into one-type-per-file. The repo already groups small helper
view types together (`MessageRowSupportViews.swift`,
`ExpandedComposerOverlayChromeViews.swift`, etc.) and the
`ChatComposerStateHosts.swift` filename follows that same pattern.
@hrayleung hrayleung merged commit 0e837d0 into master May 11, 2026
3 checks passed
@hrayleung hrayleung deleted the fix/laggy-input branch May 14, 2026 11:11
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