Skip to content

Add message queue to chat UI#36

Merged
ScriptSmith merged 4 commits into
mainfrom
message-queue
May 25, 2026
Merged

Add message queue to chat UI#36
ScriptSmith merged 4 commits into
mainfrom
message-queue

Conversation

@ScriptSmith
Copy link
Copy Markdown
Owner

No description provided.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 24, 2026

Greptile Summary

This PR adds a message queue to the chat UI, allowing users to keep composing and queueing follow-up messages while a response is still streaming. Serialization is keyed off the sendMessage promise (not isStreaming, which flickers between tool rounds), backed by an app-wide singleton that survives the /chat/chat/:id route-remount that occurs on first send.

  • MessageQueue class (messageQueue.ts): framework-agnostic singleton with sendOrQueue, remove, clear, and pub/sub for queue and busy state; wrapped by useMessageQueue hook; well-covered by unit tests.
  • ChatInput changes: textarea and toolbar buttons are no longer disabled during streaming; a separate Stop button is shown while streaming; queued messages render as removable chips above the input.
  • ChatPage cleanup: a useEffect cleanup fires clearQueue() when currentConversation?.id changes or the component unmounts (with a non-undefined id), preventing queued messages from leaking into a different conversation's context.

Confidence Score: 5/5

Safe to merge — the queue serialization logic is sound, the cleanup correctly handles both same-route and cross-route navigation, and the previously noted leakage on New Chat navigation is fixed by this PR.

The core serialization relies on the sendMessage promise rather than the flickering isStreaming flag, which is the right signal. The useEffect-based setSend avoids the concurrent-render mutation concern raised in an earlier review. The useEffect cleanup in ChatPage correctly fires on unmount and dep-change, preventing queued messages from leaking into a different conversation. Unit tests cover ordering, error recovery, remove, clear, and busy-state transitions thoroughly.

No files require special attention. The two minor observations are about the useSyncExternalStore subscription pattern in useMessageQueue.ts and the disabled-button aria-label in ChatInput.tsx.

Important Files Changed

Filename Overview
ui/src/pages/chat/messageQueue.ts New MessageQueue class with clean pub/sub design; pump loop correctly serializes sends and handles errors without stranding the queue.
ui/src/pages/chat/useMessageQueue.ts React wrapper around the singleton; setSend correctly moved to a useEffect (addressing the prior render-phase mutation concern); isBusy/queuedMessages initialized from the singleton and then subscribed via effects — a brief window between initialization and subscription could miss a transition in theory.
ui/src/pages/chat/ChatPage.tsx useEffect cleanup correctly calls clearQueue() on conversation change and cross-route unmount (fixes the previously noted cross-route queue-leak); first-message create/navigate flow preserved.
ui/src/components/ChatInput/ChatInput.tsx Toolbar and textarea correctly re-enabled during streaming; Send/Stop split-button layout is clean; aria-label on primary button reads 'Queue message' even when disabled (non-queue stream), which is slightly misleading for screen readers.
ui/src/pages/chat/tests/messageQueue.test.ts Comprehensive unit tests covering idle send, serialization, ordering, error recovery, remove, clear, busy lifecycle, and latest-send dispatch.
ui/src/pages/chat/useChat.ts Type signature of sendMessage corrected to Promise, matching the existing async implementation; no logic changes.

Sequence Diagram

sequenceDiagram
    participant U as User
    participant CI as ChatInput
    participant CP as ChatPage
    participant MQ as MessageQueue (singleton)
    participant UC as useChat.sendMessage

    U->>CI: "types & clicks Send (idle)"
    CI->>CP: handleSendMessage(content, files)
    CP->>MQ: sendOrQueue(content, files)
    Note over MQ: busy=false → pump() starts
    MQ->>MQ: setBusy(true)
    MQ->>UC: send(content, files) [await]
    Note over CI: isQueuing=true → button shows Queue

    U->>CI: "types & clicks Queue (busy)"
    CI->>CP: handleSendMessage(content2, files2)
    CP->>MQ: sendOrQueue(content2, files2)
    Note over MQ: busy=true → append to queue, emit chip

    UC-->>MQ: Promise resolves (turn complete)
    MQ->>MQ: dequeue next message
    MQ->>UC: send(content2, files2) [await]

    UC-->>MQ: Promise resolves
    MQ->>MQ: queue empty → setBusy(false)
    Note over CI: isQueuing=false → button shows Send

    U->>CP: navigate to new conversation
    CP->>MQ: clearQueue() [via useEffect cleanup]
    Note over MQ: queued messages dropped, busy unchanged
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
ui/src/pages/chat/useMessageQueue.ts:15-17
External store subscription with `useState` + `useEffect` has a small tearing window: `isBusy` (and `queuedMessages`) are snapshotted at render time via `useState(chatMessageQueue.isBusy)`, but the subscription that keeps them up to date is wired only after commit. If the singleton's busy state transitions between render and the effect running, that transition is missed. The canonical React solution for subscribing to an external mutable store is `useSyncExternalStore`, which eliminates the gap and also handles concurrent-mode tearing.

```suggestion
export function useMessageQueue(send: SendFn) {
  const queuedMessages = useSyncExternalStore(
    (cb) => chatMessageQueue.subscribe((_q) => cb()),
    () => chatMessageQueue.getQueue(),
    () => chatMessageQueue.getQueue()
  );
  const isBusy = useSyncExternalStore(
    (cb) => chatMessageQueue.subscribeBusy((_b) => cb()),
    () => chatMessageQueue.isBusy,
    () => chatMessageQueue.isBusy
  );
```

### Issue 2 of 2
ui/src/components/ChatInput/ChatInput.tsx:795-797
When `isStreaming` is true and `isQueuing` is false (an edit-and-rerun/regenerate stream), the primary button is correctly disabled, but its aria-label and visible text both read "Queue" — telling a screen-reader user they can queue a message when they actually cannot. The label should reflect the true state of the control.

```suggestion
              aria-label={isQueuing ? "Queue message" : "Send message"}
            >
              {isQueuing ? "Queue" : "Send"}
```

Reviews (7): Last reviewed commit: "Review fixes" | Re-trigger Greptile

Comment thread ui/src/pages/chat/useMessageQueue.ts Outdated
Comment thread ui/src/components/ChatInput/ChatInput.tsx Outdated
@ScriptSmith
Copy link
Copy Markdown
Owner Author

@greptile-apps

@ScriptSmith
Copy link
Copy Markdown
Owner Author

@greptile-apps

@ScriptSmith
Copy link
Copy Markdown
Owner Author

@greptile-apps

@ScriptSmith ScriptSmith merged commit 5cb93db into main May 25, 2026
21 checks passed
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