Skip to content

fix(desktop): bind channel and thread context at compose time to prevent wrong-channel send#1472

Merged
wpfleger96 merged 4 commits into
mainfrom
duncan/send-channel-binding
Jul 2, 2026
Merged

fix(desktop): bind channel and thread context at compose time to prevent wrong-channel send#1472
wpfleger96 merged 4 commits into
mainfrom
duncan/send-channel-binding

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Problem

When a user:

  1. Composes a message in channel A mentioning a managed agent not yet in the channel
  2. Switches to channel B before the async add_channel_members call resolves

The message was delivered to channel B. The send pipeline was reading the currently selected channel from latest-value refs at flush time, after slow async work (agent attach/start) opened a race window. The same late-bind class applied to thread replies: parentEventId and threadHeadId were read from live refs after the mention-flow awaits.

Fix

Channel binding at compose time

  • capturedChannelId captured synchronously at submitMessage entry and threaded as data through the full async pipeline.
  • resolveEffectiveChannel(capturedChannelId, channelsCache, fallback): null captured id → fallback (legacy callers); supplied-but-unresolvable → null (throw at call site, never misdeliver).
  • Both mutationFn and onMutate in useSendMessageMutation route through resolveEffectiveChannel.
  • All three channel-scoped mutations (useAttachManagedAgentToChannelMutation, useCreateChannelManagedAgentMutation, useAddChannelMembersMutation) accept channelId in variables; onSettled derives the invalidation target from variables?.channelId ?? channelId so the server-mutated channel stays fresh after a switch.
  • Composer clear/restore guards use a live channelIdRef so navigation-away doesn't wipe the new channel's draft.

Thread-reply context capture

  • New onCaptureSendContext optional prop on MessageComposer, called synchronously at submitMessage before any awaits.
  • MessageThreadPanel provides it: reads replyTargetMessage?.id ?? threadHeadId into an immutable { parentEventId, threadHeadId } object at submit time.
  • Bail-at-submit if parentEventId is null — no post-await discovery.
  • resolveThreadReplyTarget(threadContext, liveReplyTargetId, liveThreadHeadId): when threadContext is non-null, uses its values exclusively (no live-ref fallback — the F7 degenerate case is structurally impossible). When null, falls back to live refs (legacy callers).
  • handleSendThreadReply calls resolveThreadReplyTarget — zero inline live-ref reads after the mention-flow awaits.
  • Post-send UI updates skipped if the user navigated away.
  • capturedThreadContext threaded through useMentionSendFlowPendingNonMemberMentionSendcompleteSendonSendRef.current.

E2E regression

  • addChannelMembersDelayMs knob added to MockBridgeOptions and handleAddChannelMembers (same pattern as sendMessageDelayMs).
  • send-channel-binding.spec.ts: exact repro — managed agent not in channel A, 500ms bridge delay, submit + immediate switch to channel B, assert message appears in A's timeline and not B's. Plus a baseline no-switch test.

Tests

Unit tests in sendChannelBinding.test.mjs exercise both pure functions:

  • resolveEffectiveChannel: captured-id-wins, null-on-miss, legacy-fallback, empty-cache
  • resolveThreadReplyTarget: captured-context-wins-over-live-refs (the race), null parent → null, null threadHeadId uses context not live ref, legacy null context falls back to live refs, null context + no reply target → thread head, all-null → null

Files changed

File Change
desktop/src/features/messages/hooks.ts resolveEffectiveChannel + resolveThreadReplyTarget pure fns; capturedChannelId in useSendMessageMutation
desktop/src/features/messages/lib/sendChannelBinding.test.mjs 11 unit tests covering both pure fns
desktop/src/features/messages/ui/useMentionSendFlow.ts capturedChannelId + capturedThreadContext threaded; channelIdRef for composer guards
desktop/src/features/messages/ui/MessageComposer.tsx onCaptureSendContext prop; bail-at-submit for null parentEventId
desktop/src/features/messages/ui/MessageThreadPanel.tsx onCaptureSendContext impl; removed dead threadHeadRef
desktop/src/features/channels/useChannelPaneHandlers.ts handleSendThreadReply uses resolveThreadReplyTarget; UI guard on navigation
desktop/src/features/channels/ui/ChannelPane.types.ts threadContext in onSendThreadReply signature
desktop/src/features/agents/hooks.ts onSettled uses variables?.channelId ?? channelId
desktop/src/features/channels/hooks.ts onSettled uses variables?.channelId ?? channelId
desktop/src/testing/e2eBridge.ts addChannelMembersDelayMs in handleAddChannelMembers
desktop/tests/helpers/bridge.ts addChannelMembersDelayMs in MockBridgeOptions
desktop/tests/e2e/send-channel-binding.spec.ts E2E regression spec (new)
desktop/playwright.config.ts Spec added to smoke project

npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 2 commits July 2, 2026 15:01
…l send

When the user tagged an agent and immediately switched channels, the
outgoing message landed in the newly-selected channel instead of the
channel it was composed in.

Root cause: the send pipeline read the channel from "latest-value" refs
that are refreshed every render. useMentionSendFlow's async agent-prep
awaits (createMentionedPersonaAgents, ensureManagedAgentMentionsReady)
opened a window during which a channel switch rotated both onSendRef and
sendMutateRef to the new channel's handlers. When completeSend finally
called onSendRef.current(), it targeted the wrong channel.

Fix: capture channelId at submitMessage entry and thread it as data
through the entire pipeline:

  submitMessage  →  sendMessageWithMentionFlow (capturedChannelId)
    →  PendingNonMemberMentionSend.capturedChannelId
    →  completeSend  →  onSendRef.current(..., channelId)
    →  handleSendMessage / handleSendThreadReply  →  sendMutateRef.current
    →  useSendMessageMutation variables.channelId

In useSendMessageMutation, mutationFn and onMutate resolve the target
channel from the query cache using variables.channelId when provided,
falling back to the closed-over channel for callers that don't supply
one. This ensures both the actual send and the optimistic cache write
target the compose-time channel.

Also guard clearComposer() in completeSend: when capturedChannelId no
longer matches the live channelId the user has switched away, skip the
clear so the newly-active channel's composer text is not wiped.

The non-member-prompt paths (handleSendWithoutInviting,
handleInviteNonMembers) already carry capturedChannelId via the shared
PendingNonMemberMentionSend draft, so they are fixed by the same change.

InboxDetailPane is immune: its composer receives channelId from the item
data rather than from live navigation state.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
F1: channelIdRef for live channel in completeSend guard
The clearComposer/restore guard compared draft.capturedChannelId to the
completeSend closure's channelId, which is always the compose-time channel
on the direct-send path (the outer useCallback captures the same render).
A === A always, so the guard never fired. Add channelIdRef updated every
render (outside useCallback) and compare against channelIdRef.current so
the guard reads the live navigation state, not the frozen submit-time
value.

F2: thread capturedChannelId into all channel-scoped mutation call sites
attachAgentMutation, createPersonaAgentMutation, and addMembersMutation
were instantiated with the hook's live channelId. RQ observer.setOptions
refreshes mutationFn every render, so any call after a mid-flight switch
executed the switched-to channel's closure. Extend each mutation's
variable type with an optional channelId field (same pattern as
useSendMessageMutation) and pass draft.capturedChannelId / capturedChannelId
through ensureManagedAgentMentionsReady, createMentionedPersonaAgents, and
handleInviteNonMembers so every Tauri call targets the compose-time channel.

F3: throw on non-null cache miss instead of silent live-channel fallback
effectiveChannel = cacheLookup(capturedId) ?? channel was the original
code; when the captured id was non-null but absent from the channels cache
(most likely for a brand-new channel, exactly the repro's step 1), the
send silently fell through to the live channel. Split the resolution:
capturedId == null → use fallbackChannel (legacy callers); non-null →
return null on cache miss so the caller throws 'Channel is no longer
available' rather than misdelivering. Extract resolveEffectiveChannel as a
named pure function used by both mutationFn and onMutate.

F4: add resolveEffectiveChannel unit tests that pin the invariant
Five deterministic pure-function tests:
- capturedId present → returns compose-time channel (core invariant)
- capturedId not in cache → returns null (F3 throw case)
- capturedId null → falls back to closed-over channel (legacy path)
- capturedId undefined → same as null
- empty cache + non-null capturedId → returns null

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Thread-reply send context (CRITICAL):
- Add onCaptureSendContext optional prop to MessageComposer, called
  synchronously at submitMessage before any awaits.
- MessageThreadPanel provides onCaptureSendContext that reads live refs
  at submit time (replyTargetMessageRef, threadHeadId), producing an
  immutable {parentEventId, threadHeadId} object.
- Bail at submit time if parentEventId is null — no post-await discovery.
- handleSendThreadReply uses threadContext?.parentEventId ??  ... falling
  back to live refs only for direct callers that bypass onCaptureSendContext.
- Post-send UI updates guarded: skip setThreadReplyTargetId /
  setThreadScrollTargetId if user navigated away (live ref check).
- capturedThreadContext threaded through useMentionSendFlow →
  PendingNonMemberMentionSend → completeSend → onSendRef.current.

onSettled invalidation (IMPORTANT-1):
- useAttachManagedAgentToChannelMutation, useCreateChannelManagedAgentMutation,
  useAddChannelMembersMutation each now derive the effective channel from
  mutation variables in onSettled: variables?.channelId ?? channelId.
  Server-mutated channel stays fresh; no stale-A after A→B switch.

E2E + bridge delay knob (IMPORTANT-2):
- Add addChannelMembersDelayMs to MockBridgeOptions (bridge.ts + e2eBridge.ts)
  and thread into handleAddChannelMembers (same pattern as sendMessageDelayMs).
- send-channel-binding.spec.ts: exact repro (managed agent not in channel A,
  500ms delay, submit + immediate switch to B, assert message in A not B) plus
  baseline no-switch test.
- Add spec to playwright.config.ts smoke project.
- Unit tests in sendChannelBinding.test.mjs cover thread context invariants.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 changed the title fix(desktop): bind channel id at compose time to prevent wrong-channel send fix(desktop): bind channel and thread context at compose time to prevent wrong-channel send Jul 2, 2026
F5 — extract resolveThreadReplyTarget pure fn and replace tautological tests:
- Add resolveThreadReplyTarget(threadContext, liveReplyTargetId, liveThreadHeadId)
  to messages/hooks.ts alongside resolveEffectiveChannel. When threadContext is
  non-null, uses it exclusively (no ?? fallback to live refs). When null, falls
  back to liveReplyTargetId ?? liveThreadHeadId. Returns null when no
  parentEventId can be resolved.
- handleSendThreadReply now calls resolveThreadReplyTarget and destructures the
  result — the live-ref resolution logic is no longer inline in the callback.
- Replace 4 tautological makeCapturedThreadContext tests with 6 tests of the
  production function: captured-wins-over-live-refs (the race), null parent
  returns null, null threadHeadId uses context not live ref (F7), legacy null
  context falls back to live refs, null context + no reply target falls back
  to thread head, all-null returns null.

F6 — remove dead threadHeadRef in MessageThreadPanel:
- threadHeadRef was created and live-updated but never read after onCaptureSendContext
  was changed to use the closure threadHeadId directly. Remove both lines.

F7 — closed by resolveThreadReplyTarget:
- The ?? openThreadHeadIdRef.current fallback on a non-null threadContext is now
  structurally impossible: resolveThreadReplyTarget returns threadContext.threadHeadId
  (which may be null) without any ?? fallback to live refs.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 enabled auto-merge (squash) July 2, 2026 20:06
@wpfleger96 wpfleger96 merged commit d369ca9 into main Jul 2, 2026
25 checks passed
@wpfleger96 wpfleger96 deleted the duncan/send-channel-binding branch July 2, 2026 20:15
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.

2 participants