Skip to content

fix(mothership): persist queued messages, edit-in-place preserves order#4769

Open
waleedlatif1 wants to merge 2 commits into
stagingfrom
waleedlatif1/mship-queue-fix
Open

fix(mothership): persist queued messages, edit-in-place preserves order#4769
waleedlatif1 wants to merge 2 commits into
stagingfrom
waleedlatif1/mship-queue-fix

Conversation

@waleedlatif1
Copy link
Copy Markdown
Collaborator

Summary

  • Mothership message queue moved from component-local useState into a new useMothershipQueueStore (Zustand + sessionStorage), keyed by chatId — queue now persists across nav and reload, and chat-delete clears its bucket
  • Editing a queued message now replaces in place at the original index (via replaceAt) instead of removing + re-appending at the tail
  • Edit button + "send now" disabled on the head while it's being dispatched; new "cancel edit" affordance on the in-edit row
  • Pending-chat sentinel (pending::<shortId>) migrates to the real chatId on adoptResolvedChatId, so first-send queues follow their resolved chat
  • Auto-kick effect drains the queue on rehydrate, gated on chatHistory.activeStreamId so it doesn't race the reconnect path
  • Stripped volatile queuedSendHandoff on edit + on persist so a fresh handoff is minted at send time
  • Wired through to the workflow panel copilot for parity

Type of Change

  • Bug fix

Testing

Tested manually — queue survives chat-switch, home reset, browser reload; edit on a non-head queued message preserves position; edit on the dispatching head is disabled; cancel-edit leaves the slot intact. Added 16 store unit tests covering enqueue / insertAt / replaceAt (incl. handoff strip) / remove (incl. editing cleanup) / migrate / clearChat / setEditing. Council audit caught and fixed a stale-closure send-after-edit race and a stale-handoff issue.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel
Copy link
Copy Markdown

vercel Bot commented May 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped May 28, 2026 5:00pm

Request Review

@cursor
Copy link
Copy Markdown

cursor Bot commented May 28, 2026

PR Summary

Medium Risk
Touches send/dispatch, stream handoff, and chat-switch migration paths in useChat; mistakes could duplicate sends or drop queued messages, though behavior is covered by new store tests and explicit reconnect gating.

Overview
Moves the Mothership message queue out of useChat local state into a persisted useMothershipQueueStore (per-chat buckets in sessionStorage), so queued sends survive navigation and reload within a tab. Pending chats use a pending::<id> key that migrates to the real chatId when the stream resolves; deleting a task clears that chat’s bucket.

Edit-in-place keeps the queued slot at its index (replaceAt on submit) instead of removing and re-appending. The dispatcher pauses while the head is bound to the composer, reads live queue content at send time, and an effect auto-drains when history shows no active stream (without racing reconnect). Cancel edit resumes dispatch; edit/send now are disabled while the head is dispatching.

UI passes editingQueuedId, dispatchingHeadId, and cancel-edit through home, workflow copilot, and QueuedMessages (highlight + X vs pencil/send). Store unit tests and handoff stripping on edit/persist round out the change.

Reviewed by Cursor Bugbot for commit 7482443. Configure here.

Comment thread apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts Outdated
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 28, 2026

Greptile Summary

This PR moves the mothership message queue from component-local useState into a new useMothershipQueueStore (Zustand + sessionStorage), keyed by chatId, so queued messages survive navigation and same-tab reloads. It also rewires the edit flow to replace messages in-place via replaceAt rather than remove-then-append, preserving queue order.

  • New useMothershipQueueStore: enqueue, insertAt, replaceAt, remove, setEditing, migrate, clearChat actions; partialize strips volatile handoff seeds before writing to sessionStorage; 16 unit tests cover the full API including the migrate-merge and handoff-strip invariants.
  • use-chat.ts integration: pendingChatKeyRef sentinel migrates to real chatId on adoptResolvedChatId; auto-drain useEffect rehydrates the queue on return, gated on chatHistory.activeStreamId; dispatchQueuedMessage reads liveMsg from the store instead of the closure-captured snapshot so in-place edits are sent correctly; stale sessionStorage handoff is cleared at the start of editQueuedMessage.
  • UI affordances: edit and send-now buttons disabled while the head is dispatching; new "Cancel edit" X button for the slot in edit mode; both home and workflow-copilot panels wired for parity.

Confidence Score: 4/5

Safe to merge after the editing-persistence fix; the queue logic and dispatch integration are otherwise well-considered.

The only material defect is in store.ts: editing: state.editing is included in the partialize snapshot written to sessionStorage. Because sessionStorage survives same-tab F5 reloads, a user who hard-reloads mid-edit gets a rehydrated store where editing[chatKey] still points to the head message. The auto-drain effect fires and the dispatch loop hits the editing === msg.id guard, silently exits, and no dep ever changes to retrigger it — leaving the queue visually frozen. The user must notice the Cancel edit X button to manually unblock. Dropping editing from partialize closes the hole entirely.

apps/sim/stores/mothership-queue/store.ts — the partialize function in the persist middleware.

Important Files Changed

Filename Overview
apps/sim/stores/mothership-queue/store.ts New Zustand + sessionStorage store for the message queue; store logic is sound, but persisting editing to sessionStorage causes the queue to appear stuck after a hard page reload.
apps/sim/stores/mothership-queue/types.ts New type definitions for QueuedMothershipMessage, QueuedSendHandoffSeed, and QueuedMessageEditPatch; clean and well-scoped.
apps/sim/stores/mothership-queue/store.test.ts 16 unit tests covering all store actions including the migrate-merge and handoff-strip cases; good coverage.
apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts Queue migrated from useState to Zustand store with correct chatKey lifecycle, edit-in-place via replaceAt, stale-handoff cleared on editQueuedMessage, and auto-drain effect gated on activeStreamId; integration looks correct.
apps/sim/hooks/queries/tasks.ts Wires clearChat into the task-delete onSettled callbacks, consistent with existing React Query cache-removal patterns.
apps/sim/app/workspace/[workspaceId]/home/components/queued-messages/queued-messages.tsx Adds editing/dispatching visual states, disables edit and send-now buttons on the dispatching head, and adds a cancel-edit affordance.

Sequence Diagram

sequenceDiagram
    participant User
    participant useChat
    participant QueueStore as useMothershipQueueStore
    participant SessionStorage
    participant Dispatcher as dispatchQueueHead

    User->>useChat: sendMessage() while stream active
    useChat->>QueueStore: enqueue(chatKey, msg)
    QueueStore->>SessionStorage: persist(queues)

    User->>useChat: editQueuedMessage(id)
    useChat->>useChat: clearQueuedSendHandoffState(id)
    useChat->>QueueStore: setEditing(chatKey, id)
    useChat-->>User: returns QueuedMessage (composer pre-populated)

    User->>useChat: sendMessage() [edit submit]
    useChat->>QueueStore: replaceAt(chatKey, id, patch)
    useChat->>QueueStore: setEditing(chatKey, null)
    useChat->>Dispatcher: enqueueQueueDispatch send_head

    Note over Dispatcher: Skips head if editing[chatKey] === head.id

    Dispatcher->>QueueStore: read liveMsg (not closure snapshot)
    Dispatcher->>useChat: startSendMessage(liveMsg.content)
    useChat->>QueueStore: remove(chatKey, id)

    Note over QueueStore,SessionStorage: On hard reload — editing rehydrated causes auto-drain to stall

    User->>useChat: cancelQueueEdit()
    useChat->>QueueStore: setEditing(chatKey, null)
    useChat->>Dispatcher: enqueueQueueDispatch send_head
Loading

Reviews (2): Last reviewed commit: "fix(mothership): pause drain while head ..." | Re-trigger Greptile

Comment thread apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
Comment thread apps/sim/stores/mothership-queue/store.ts
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment on lines +182 to +190
partialize: (state) => ({
queues: Object.fromEntries(
Object.entries(state.queues).map(([key, messages]) => [
key,
messages.map(stripVolatile),
])
),
editing: state.editing,
}),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Persisting editing to sessionStorage creates a queue dead-lock after a hard page reload. sessionStorage survives F5 reloads within the same browser tab — so if the user was mid-edit when they reloaded, editing[chatKey] = "msgId" is rehydrated. The auto-drain useEffect fires (queue length > 0), kicks the dispatch loop, and the loop hits if (queueState.editing[activeChatKey] === msg.id) continue — silently exiting with nothing dispatched. No dependency in the auto-drain effect changes afterward, so it never retriggers. The queue appears permanently stalled until the user notices the "Cancel edit" X button and clicks it. Because editing is purely transient UI state (the composer is not pre-populated after reload regardless), it should not be persisted.

Suggested change
partialize: (state) => ({
queues: Object.fromEntries(
Object.entries(state.queues).map(([key, messages]) => [
key,
messages.map(stripVolatile),
])
),
editing: state.editing,
}),
partialize: (state) => ({
queues: Object.fromEntries(
Object.entries(state.queues).map(([key, messages]) => [
key,
messages.map(stripVolatile),
])
),
}),

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 7482443. Configure here.

messages.map(stripVolatile),
])
),
editing: state.editing,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Persisted editing state blocks queue dispatch after reload

High Severity

The partialize function persists editing to sessionStorage, but editing is inherently composer-scoped and cannot survive a page reload. On browser reload, React cleanup effects don't run, so the stale editing value rehydrates. The initialChatId effect won't clear it either because chatKeyRef.current === initialChatId on same-chat reload makes the guard at line 2344 evaluate to false. The dispatch loop then sees the head message as "being edited" and continues past it, leaving the queue permanently stuck until the user manually clicks "cancel edit."

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7482443. Configure here.

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