Skip to content

ios: stop re-rendering the conversation on every keystroke - #403

Closed
0xSero wants to merge 1 commit into
mainfrom
fix/composer-typing-freeze
Closed

0xSero wants to merge 1 commit into
mainfrom
fix/composer-typing-freeze

Conversation

@0xSero

@0xSero 0xSero commented Sep 30, 2026

Copy link
Copy Markdown
Owner

Purpose

Typing in a conversation froze once a turn had finished. Each keystroke re-rendered the whole conversation screen and transcript, and that cost grows with the transcript.

Root cause

The draft text was a property on ConversationScreenModel, bound with $bindableScreenModel.composerInputText inside ConversationDestinationScreen.body. Every keystroke re-ran that body, rebuilt ConversationView with fresh closures, and re-rendered the message list.

Measured with temporary render probes, 104 keystrokes after a finished Claude turn (live kittylitter host, iPhone 17 Pro simulator):

View Before After
ConversationView.body 81 1
MessageList.body 268 4
BottomChrome.body 81 1
Pinned-diff rebuild 81 1
InputBar.body (the composer) 82 100

Changes

  • The draft text and attachments move to ConversationComposerDraft, an observable passed by reference. Only ComposerDraftBinder (a leaf) builds the bindings.
  • ConversationPinnedContextStrip built its combined diff in init on every parent re-render; it is now built in onChange(initial: true).
  • New live UI tests against a kittylitter host (skipped unless env vars are set): typing after a turn settles, a screenshot tour, open-session timing.

Android checked: its draft lives in ComposerBar state, and the write-back flow has no parent collector, so no change is needed.

Verification

make ios-sim-fast succeeds; live test testOpenSession with send passes; probe counts above.

🤖 Generated with Claude Code

The composer draft was a property on ConversationScreenModel, bound with
$bindableScreenModel.composerInputText inside ConversationDestinationScreen's
body. Every keystroke re-ran that body, rebuilt ConversationView with new
closures, and re-rendered the transcript. Measured over 104 keystrokes
after a finished Claude turn in the simulator:

  ConversationView.body   81 -> 1
  MessageList.body       268 -> 4
  BottomChrome.body       81 -> 1
  pinned-diff rebuild     81 -> 1

The cost grows with the transcript, which is why typing froze once a turn
had added tool output and markdown.

- Move the draft text and attachments into ConversationComposerDraft, an
  observable passed down by reference. Only ComposerDraftBinder, a leaf in
  the bottom chrome, builds the bindings, so keystrokes invalidate the
  composer alone.
- ConversationPinnedContextStrip built the combined diff in init on every
  parent re-render although SwiftUI keeps only the first @State initial
  value; build it in onChange(initial: true) instead.
- Add live UI tests against a kittylitter host (skipped unless env is set):
  typing after a turn settles, a screenshot tour, and open-session timing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@0xSero

0xSero commented Oct 1, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #404, which shipped these changes in 2.1.9.

@0xSero 0xSero closed this Oct 1, 2026
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