Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions agent_instructions/modifying_chat_ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,42 @@ Key selectors:
3. **New callbacks?** → Wrap in `useCallback` with tight dependencies
4. **New selectors?** → Create surgical selector hooks in the store file

## Message Queue (sending while streaming)

The input stays editable while a response streams. An **idle** send goes out immediately; a send
issued **while a turn is in flight** is queued and dispatched when the current turn completes.

- The queue is a plain, framework-agnostic class: `pages/chat/messageQueue.ts` (`MessageQueue`),
used as an **app-wide singleton** (`chatMessageQueue`) and wrapped by the `useMessageQueue` hook.
`ChatPage` calls `sendOrQueue(content, files)`; the hook exposes `queuedMessages` +
`removeQueuedMessage` + `clearQueue`, threaded through `ChatView` to `ChatInput`, which renders
removable chips above the input. The class is unit-tested in
`pages/chat/__tests__/messageQueue.test.ts`.
- **Why a singleton, not component state:** the first message of a conversation navigates `/chat` →
`/chat/:id` (two separate `<Route>` elements), which **remounts `ChatPage`**. The first turn's
stream is *not* aborted on unmount (useChat has no unmount cleanup), so it keeps running on the
global stores. Component-scoped queue state would reset the in-flight lock on the remounted
instance, and a queued message would start a **second turn concurrently** — two `initStreaming`
calls for one model render two side-by-side responses ("the whole thing restarts"). The singleton
keeps the lock alive across the remount. The hook pushes the latest `sendMessage` in via
`setSend` every render and subscribes for queue updates; `ChatPage` calls `clearQueue()` on a real
conversation switch (skipping the create transition) so queued messages don't leak across
conversations.
- `useChat`'s `sendMessage` is **async and resolves only when the whole turn completes** (including
multi-round tool execution). Serialization keys off that promise. Do **not** drive the queue off
`isStreaming` transitions — `isStreaming` briefly flips false *between* tool rounds (see the
comment near `useChat.ts` "more rounds coming"), which would dispatch the next message mid-turn
and clobber the in-flight stream (`initStreaming` resets the streaming store).
- Only queue when busy. Routing the **first/idle** send through a long-lived drain loop is fragile:
that send also creates the conversation and navigates (`/chat` → `/chat/:id`), and coupling the
queue lock to that lifecycle caused ordering/stuck-queue bugs. Idle sends now bypass the queue
entirely (no chip), matching pre-queue behavior.
- `MessageQueue` reads `send` through a getter so each dispatch uses the latest `sendMessage`
closure (picks up model/tool/config changes made while draining). A failed turn is caught so it
never strands the rest of the queue or leaves the queue stuck busy.
- **Stop** is a separate button (shown only while streaming) that aborts the in-flight response; it
does not clear the queue. Remove queued messages via their chip's ✕.

## Model Instances

The chat supports **model instances** — multiple copies of the same model with different settings (e.g., compare GPT-4 with temperature 0.3 vs 0.9):
Expand Down
23 changes: 16 additions & 7 deletions docs/content/docs/features/chat-ui.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,22 @@ The chat UI is optimized for high-performance multi-model streaming:

### Streaming Features

| Feature | Description |
| ------------------- | ----------------------------------------------- |
| Live markdown | Content renders as markdown while streaming |
| Syntax highlighting | Code blocks highlighted in real-time |
| Auto-scroll | Follows streaming content, pauses on scroll-up |
| Cancel | Stop any or all streams mid-generation |
| Usage stats | Time-to-first-token and tokens/second displayed |
| Feature | Description |
| ------------------- | --------------------------------------------------------- |
| Live markdown | Content renders as markdown while streaming |
| Syntax highlighting | Code blocks highlighted in real-time |
| Auto-scroll | Follows streaming content, pauses on scroll-up |
| Cancel | Stop any or all streams mid-generation |
| Message queue | Keep typing and queue follow-ups while a response streams |
| Usage stats | Time-to-first-token and tokens/second displayed |

### Message Queue

You don't have to wait for a response to finish before composing the next message. While a
response is streaming, the input stays editable and the send button changes to **Queue** —
queued messages appear as removable chips above the input and are sent one at a time as each
turn (including any tool-execution rounds) completes. A separate **Stop** button remains
available to cancel the in-flight response without affecting queued messages.

### Usage Statistics

Expand Down
100 changes: 100 additions & 0 deletions ui/src/components/ChatInput/ChatInput.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,106 @@ export const Streaming: Story = {
},
};

/**
* Test: While streaming, the textarea stays enabled and "Send" becomes "Queue"
* (queuing the message) while a separate Stop button aborts the response.
*/
export const StreamingAllowsQueueing: Story = {
args: {
// A queue-backed turn is in flight: both flags are set, so the primary
// button queues the next message rather than starting a concurrent turn.
isStreaming: true,
isQueuing: true,
placeholder: "Type a message...",
onSend: fn(),
onStop: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);

// Textarea remains usable while streaming
const textarea = canvas.getByPlaceholderText("Type a message...");
await expect(textarea).toBeEnabled();
await userEvent.type(textarea, "Next question");

// Primary button now queues rather than stops
const queueButton = canvas.getByRole("button", { name: /queue message/i });
await expect(queueButton).toBeEnabled();
await userEvent.click(queueButton);
await expect(args.onSend).toHaveBeenCalledWith("Next question", []);
await expect(args.onStop).not.toHaveBeenCalled();

// A distinct Stop button is still available to abort the in-flight response
const stopButton = canvas.getByRole("button", { name: /stop response/i });
await userEvent.click(stopButton);
await expect(args.onStop).toHaveBeenCalled();
},
};

/**
* Test: A stream started outside the queue (editAndRerun/regenerateResponse sets
* `isStreaming` but not `isQueuing`) disables the primary button, so a click or
* Enter can't start a second concurrent turn that would clobber the active one.
*/
export const NonQueueStreamBlocksSend: Story = {
args: {
isStreaming: true,
isQueuing: false,
placeholder: "Type a message...",
onSend: fn(),
onStop: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);

const textarea = canvas.getByPlaceholderText("Type a message...");
await userEvent.type(textarea, "Next question");

// The primary button is present but disabled in this state.
const queueButton = canvas.getByRole("button", { name: /queue message/i });
await expect(queueButton).toBeDisabled();

// Enter must not slip past the disabled button and dispatch a send.
await userEvent.type(textarea, "{Enter}");
await expect(args.onSend).not.toHaveBeenCalled();

// Stop remains available to abort the externally-started stream.
const stopButton = canvas.getByRole("button", { name: /stop response/i });
await userEvent.click(stopButton);
await expect(args.onStop).toHaveBeenCalled();
},
};

/**
* Test: Queued messages render as removable chips above the input
*/
export const WithQueuedMessages: Story = {
args: {
// Messages are queued, so the queue is busy and queueing stays enabled.
isStreaming: true,
isQueuing: true,
placeholder: "Type a message...",
onRemoveQueuedMessage: fn(),
queuedMessages: [
{ id: "q1", content: "First queued message", files: [] },
{ id: "q2", content: "Second queued message", files: [] },
],
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);

// Both queued messages are listed
await expect(canvas.getByText("First queued message")).toBeInTheDocument();
await expect(canvas.getByText("Second queued message")).toBeInTheDocument();

// Removing the first chip calls back with its id
const removeButtons = canvas.getAllByRole("button", { name: /remove queued message/i });
await expect(removeButtons).toHaveLength(2);
await userEvent.click(removeButtons[0]);
await expect(args.onRemoveQueuedMessage).toHaveBeenCalledWith("q1");
},
};

/**
* Test: Typing enables the send button
*/
Expand Down
Loading
Loading