pingdotgg / pingdotgg/t3code

[Bug]: iOS composer wedges — send stays disabled and the draft carries into other threads

Open
#7,390 0 comments 2 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
23k
Forks
5.9k
Avg merge
11h 14m
Merged PRs (30d)
357

Description

Area

apps/mobile

Summary

On iOS, the composer can get into a state where the send button stays disabled with text visibly in the box, and that text carries over into every other thread you open. Typing one more character fixes it; so does restarting the app. It shows up after the app has been open a while and, in my experience, while an agent is actively streaming into a long thread.

The typed text is real and visible, so the composer looks healthy — it just cannot be sent, and it follows you from thread to thread.

Steps to reproduce

Timing-dependent, but the mechanism is deterministic (see Analysis).

  1. Open a thread with a long history and an agent actively streaming into it (mine: ~6,000 activities, ~350 messages).
  2. Type a message into the composer while the agent is producing output.
  3. Switch to another thread whose draft is empty.
  4. The text you typed on the first thread is still in the composer, and the send button is dimmed. It stays that way across further thread switches until you type another character or restart the app.
Expected behavior

Each thread's composer shows that thread's own draft, and the send button is enabled whenever the composer visibly contains text.

Actual behavior

The previous thread's text stays in the editor, the send button reads the new thread's (empty) draft and stays disabled, and nothing recovers it except a keystroke or an app restart.

Analysis

The mobile send button has exactly one input:

// apps/mobile/src/features/threads/ThreadComposer.tsx
const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
const canSend = hasContent;

draftMessage comes from the per-thread draft store, so "text visible but send disabled" means the JS draft and the native editor disagree. Because the draft store is keyed per thread (scopedThreadKey), text surviving a thread switch can only mean the native editor is rejecting the controlled document rather than lagging behind it.

The composer editor is revision-gated. The native view drops any controlled document stamped behind its own counter:

// apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
guard let document = decode(ComposerControlledDocumentPayload.self, from: documentJson),
      document.mostRecentEventCount >= nativeEventCount else {
  return
}

and the JS side deliberately stamps behind when a rendered value matches an older snapshot with a different selection:

// apps/mobile/src/native/composerEditorRevision.ts
if (newestValueEventCount !== null && mostRecentEventCount > 0) {
  return Math.min(newestValueEventCount, mostRecentEventCount - 1);
}

That is correct as a mid-typing race guard. The problem is what happens when the parent replaces the document instead of editing it — a thread switch, or a send clearing the draft.

Normally a settled render classifies as a native echo, pruneAcknowledgedComposerNativeEvents collapses the history to the newest snapshot, and the replacement value is not found in it, so it is stamped at the current revision and applied. But when renders lag behind native events, that settle render never runs and the history still contains the previous document's states — including an early empty one. The replacement (an empty draft) then matches that stale snapshot, gets stamped behind the native revision, and is rejected. Every subsequent render repeats the same classification, so it never recovers; only a fresh native event (a keystroke), which outranks the stale counter, breaks the loop.

Feeding that sequence through the real functions in composerEditorRevision.ts:

typed on A (renders lagging): native="hello" count=5 draftA="hello" snapshots=6
switch to B (draft ""):       doc=REJECTED (stale revision)
                              native shows="hello"  draftB=""  send enabled=false
after 50 renders:             STILL WEDGED
after typing "!":             native="hello!" draftB="hello!" send enabled=true

That reproduces all four observed symptoms, including the carry-over and the keystroke recovery. It also means the carried text gets adopted into the wrong thread's draft when it finally heals, which is a quiet data-correctness problem on top of the dead button.

What makes renders lag. useThreadComposerState rebuilds the entire feed whenever the thread detail changes:

// apps/mobile/src/state/use-thread-composer-state.ts
const selectedThreadFeed = useMemo(
  () => (selectedThreadDetail ? buildThreadFeed(selectedThreadDetail) : []),
  [selectedThreadDetail],
);

buildThreadFeed maps every message, derives work-log entries from every activity, and sorts the whole history. Nothing throttles this on the render path — the only debounce in the thread state (packages/client-runtime/src/state/threads.ts) is on cache persistence. Measured on my data, that is ~10ms per rebuild for a 6,081-activity thread on an M-series Mac, running up to 8×/second during streaming, and considerably slower on device. That is the window the wedge needs.

I think the feed rebuild deserves its own issue as a straight performance problem; this one is about the composer being unable to recover once the two sides disagree.

Note on a wrong turn

I first suspected Fabric view recycling handing a pooled native view (with a non-zero nativeEventCount) to a fresh JS component. That is not possible here — ExpoFabricView.shouldBeRecycled() returns false, so Expo views are never pooled. Recording it in case anyone else follows the same trail.

Environment
  • Surface: iOS (iPad), T3 Code mobile
  • Appears after the app has been open a while, with an agent streaming into a large thread
  • Recovers on the next keystroke or an app restart

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with apps/mobile/src/native/composerEditorRevision.ts and apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift, then trace the composer state in apps/mobile/src/features/threads/ThreadComposer.tsx. Reproduce the stale-snapshot sequence while switching threads during streaming, and inspect use-thread-composer-state.ts for the render lag. Done means thread switches and draft clearing recover correctly, without carrying text into another thread or leaving send disabled.

Written by the indexing model from the issue text.

Assessment

Tech stack
swift, typescript
Domain
mobile
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.