diff --git a/.changeset/fn-7137-stack-queued-chat-messages.md b/.changeset/fn-7137-stack-queued-chat-messages.md
new file mode 100644
index 0000000000..a18e8c3bb9
--- /dev/null
+++ b/.changeset/fn-7137-stack-queued-chat-messages.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": minor
+---
+
+summary: Stack multiple queued chat messages above the composer and send them in order.
+category: feature
+dev: Direct and Quick Chat queued sends now persist as FIFO arrays with legacy single-string restore fallback.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index f8d00cd822..09c2fff052 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -347,7 +347,7 @@ Chat view provides project-scoped conversations with agents.
- Full Chat and Quick Chat both consume the same streamed `/api/chat/sessions/:id/messages` response contract, and both now prefer the authoritative assistant `message` snapshot on `done` while still accumulating `text` chunks when present (so providers without incremental text streaming still render output immediately)
- In-progress assistant responses now survive refresh/navigation while generation is still active: Chat restores the last durable in-flight text/thinking/tool state immediately, keeps the prior persisted conversation visible, then resumes streaming from the stored replay point; any new text, thinking, or tool-call updates append to that restored bubble instead of replacing it or starting from an empty "Working…" placeholder.
- If a regular Chat stream drops with a hidden-tab/browser-suspension error (for example `Load failed`) while the server is still generating, Chat suppresses the false error banner, re-attaches to the in-progress stream using the durable replay state, and reconciles the final assistant reply when generation completes.
-- If you queue a follow-up user message while the assistant is still streaming, Chat persists that queued text per session, shows the queued preview above the input box with a divider, and restores/sends it once the active response finishes if you leave and return.
+- If you queue follow-up user messages while the assistant is still streaming, Chat persists them per session, stacks each queued preview above the input box with one shared divider, and restores/sends them one at a time in FIFO order once each active response finishes if you leave and return.
- Chat message lists now track near-bottom scroll state: while you are reading older messages, live streaming/new replies do not force-scroll; a **Latest** jump control appears until you return to the tail.
- On mobile direct-chat threads, entering a thread and restoring Chat after tab/page visibility returns re-anchors to the newest message (`scrollTop = scrollHeight`) so the view always opens at the live tail.
- On mobile direct-chat threads, tapping the active title/identity in the thread header opens a lightweight conversation dropdown so you can switch to another direct session or start a New Chat without backing out to the sidebar list first; long conversation titles now stay readable in the dropdown via wrapped option text and taller touch-friendly rows.
@@ -409,7 +409,7 @@ Quick Chat is an optional fast, project-scoped assistant surface for conversatio
- Submitting the inline chooser uses explicit fresh-session creation and immediately persists/selects the new thread, then refreshes the session dropdown list
- On first open for a project, Quick Chat restores the last opened non-archived session from per-project local storage; if that saved session is missing, it falls back to the most recently touched non-archived session by latest activity (`max(lastMessageAt, updatedAt)`), and only falls back to the first agent / configured default model when no prior session exists.
- Closing and reopening Quick Chat keeps the active conversation warm in memory, so messages stay visible without a conversation reload or "Loading conversation…" flash.
-- Queued follow-up messages entered while a Quick Chat response is still streaming now persist per session, so closing/reopening the panel restores the queued text and flushes it once the active response completes.
+- Queued follow-up messages entered while a Quick Chat response is still streaming now persist per session, so closing/reopening the panel restores the queued stack and flushes the messages one at a time in FIFO order as active responses complete.
- Resume lookups still use targeted session queries instead of loading the full active-session list first
- Tool-call summaries in the floating quick-chat panel are intentionally condensed into a single-line header row (especially on small screens) so tool name + status stay scannable without multi-line wrapping
- Question tool calls use the same shared response card as full Chat, with compact spacing in the floating panel and read-only answered history so Quick Chat can continue agent clarification loops without exposing raw tool JSON.
diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css
index 38dca12c68..44c9c761ee 100644
--- a/packages/dashboard/app/components/ChatView.css
+++ b/packages/dashboard/app/components/ChatView.css
@@ -1950,8 +1950,17 @@ Tablet Chat View has enough message-pane width for assistant prose, markdown, to
/* === Chat Pending Message === */
/*
FNXC:ChatComposer 2026-06-27-00:00:
-The single queued-message banner spans the composer above the input row, and the divider below it makes the queued state visually distinct without rendering an empty rule when no pending message exists.
+Queued-message banners stack above the composer input with a capped scroll area, and the shared divider below them makes the queued state visually distinct without rendering an empty rule when the FIFO queue is empty.
*/
+.chat-pending-stack {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ width: 100%;
+ max-height: calc(var(--space-xl) * 6);
+ overflow-y: auto;
+}
+
.chat-pending-message {
display: flex;
align-items: center;
@@ -2015,6 +2024,10 @@ The single queued-message banner spans the composer above the input row, and the
thread takes the full viewport. The thread already renders a back
button (ChevronLeft) on mobile to flip back to the session list. */
@media (max-width: 768px) {
+ .chat-pending-stack {
+ max-height: calc(var(--space-xl) * 5);
+ }
+
.chat-pending-message {
align-items: flex-start;
}
diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx
index 55b66b0a88..35a861e028 100644
--- a/packages/dashboard/app/components/ChatView.tsx
+++ b/packages/dashboard/app/components/ChatView.tsx
@@ -1024,7 +1024,7 @@ export function ChatView({ projectId, addToast, floating = false, onPopOut, onMa
deleteSession,
sendMessage,
stopStreaming,
- pendingMessage,
+ pendingMessages,
clearPendingMessage,
loadMoreMessages,
hasMoreMessages,
@@ -2731,9 +2731,9 @@ export function ChatView({ projectId, addToast, floating = false, onPopOut, onMa
// the render-mode toggle still appears in a slim toolbar.
const hideAssistantIdentity = activeSession?.agentId === FN_AGENT_ID;
- const pendingPreview = pendingMessage.length > 50
- ? `${pendingMessage.slice(0, 50)}…`
- : pendingMessage;
+ const getPendingPreview = (message: string) => message.length > 50
+ ? `${message.slice(0, 50)}…`
+ : message;
const toggleAllAsPlain = useCallback(() => {
setShowAllAsPlain((value) => !value);
@@ -3057,23 +3057,27 @@ export function ChatView({ projectId, addToast, floating = false, onPopOut, onMa
))}
)}
- {pendingMessage && (
+ {pendingMessages.length > 0 && (
<>
{/*
FNXC:ChatComposer 2026-06-27-00:00:
- The single-slot queued chat message must appear above the input box, separated by a divider, so users can notice the pending send without changing the one-pending-message queue model.
+ Queued direct-chat messages stack above the input in FIFO order with one shared divider, so multiple sends remain visible without changing the above-composer placement established by FN-7121.
*/}
-
-
{t("chat.queuedMessage", "Queued: {{preview}}", { preview: pendingPreview })}
-
- ×
-
+
+ {pendingMessages.map((pendingMessage, index) => (
+
+ {t("chat.queuedMessage", "Queued: {{preview}}", { preview: getPendingPreview(pendingMessage) })}
+ clearPendingMessage(index)}
+ >
+ ×
+
+
+ ))}
>
diff --git a/packages/dashboard/app/components/__tests__/ChatView.autosize.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.autosize.test.tsx
index 2515ed1889..f534a6d6a4 100644
--- a/packages/dashboard/app/components/__tests__/ChatView.autosize.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ChatView.autosize.test.tsx
@@ -76,7 +76,7 @@ const defaultChatState: UseChatReturn = {
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
- pendingMessage: "",
+ pendingMessages: [],
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
diff --git a/packages/dashboard/app/components/__tests__/ChatView.cli-mount.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.cli-mount.test.tsx
index cd4037ebb6..aa5ea03e2f 100644
--- a/packages/dashboard/app/components/__tests__/ChatView.cli-mount.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ChatView.cli-mount.test.tsx
@@ -84,7 +84,7 @@ function chatState(session: ChatSessionInfo): UseChatReturn {
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
- pendingMessage: "",
+ pendingMessages: [],
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
diff --git a/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx
index 75b0153693..17b912aad9 100644
--- a/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx
@@ -732,22 +732,23 @@ describe("ChatView core interactions", () => {
expect(screen.getByTestId("chat-send-btn")).toBeInTheDocument();
});
- it("renders pending message indicator above the input row and dismisses it", async () => {
+ it("renders stacked pending message indicators above the input row and dismisses one entry", async () => {
const clearPendingMessage = vi.fn();
const activeSession = activeSessionFixture;
setupMockChat({
activeSession,
messages: [],
- pendingMessage: "Queued while streaming",
+ pendingMessages: ["Queued A", "Queued B", "Queued C with a very long body that should truncate in the preview"],
clearPendingMessage,
});
const { rerender } = await renderWithAct(
);
const indicators = screen.getAllByTestId("chat-pending-indicator");
- expect(indicators).toHaveLength(1);
- const indicator = indicators[0];
- expect(indicator).toHaveTextContent("Queued: Queued while streaming");
+ expect(indicators).toHaveLength(3);
+ expect(indicators[0]).toHaveTextContent("Queued: Queued A");
+ expect(indicators[1]).toHaveTextContent("Queued: Queued B");
+ expect(indicators[2]).toHaveTextContent("Queued: Queued C with a very long body that should truncat…");
const input = screen.getByTestId("chat-input");
const inputArea = input.closest(".chat-input-area");
@@ -756,18 +757,20 @@ describe("ChatView core interactions", () => {
expect(inputArea).not.toBeNull();
expect(inputRow).not.toBeNull();
expect(inputWrapper).not.toBeNull();
- expect(inputArea).toContainElement(indicator);
- expect(inputWrapper).not.toContainElement(indicator);
- expect(indicator.compareDocumentPosition(inputRow!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
- expect(inputArea!.querySelector(".chat-pending-divider")).toBeInTheDocument();
+ indicators.forEach((indicator) => {
+ expect(inputArea).toContainElement(indicator);
+ expect(inputWrapper).not.toContainElement(indicator);
+ expect(indicator.compareDocumentPosition(inputRow!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
+ });
+ expect(inputArea!.querySelectorAll(".chat-pending-divider")).toHaveLength(1);
- await userEvent.click(screen.getByTestId("chat-pending-dismiss"));
- expect(clearPendingMessage).toHaveBeenCalledTimes(1);
+ await userEvent.click(screen.getByTestId("chat-pending-dismiss-1"));
+ expect(clearPendingMessage).toHaveBeenCalledWith(1);
setupMockChat({
activeSession,
messages: [],
- pendingMessage: "",
+ pendingMessages: [],
clearPendingMessage,
});
rerender(
);
diff --git a/packages/dashboard/app/components/__tests__/ChatView.default-model-icon.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.default-model-icon.test.tsx
index 851ec6b186..ec708c09f7 100644
--- a/packages/dashboard/app/components/__tests__/ChatView.default-model-icon.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ChatView.default-model-icon.test.tsx
@@ -83,7 +83,7 @@ function setupMockChat(session: ChatSessionInfo): void {
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
- pendingMessage: "",
+ pendingMessages: [],
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
diff --git a/packages/dashboard/app/components/__tests__/ChatView.draft.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.draft.test.tsx
index 8358a7586d..ce5235aab7 100644
--- a/packages/dashboard/app/components/__tests__/ChatView.draft.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ChatView.draft.test.tsx
@@ -84,7 +84,7 @@ const defaultChatState: UseChatReturn = {
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
- pendingMessage: "",
+ pendingMessages: [],
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
diff --git a/packages/dashboard/app/components/__tests__/ChatView.hash-mention.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.hash-mention.test.tsx
index dd166061e4..21cb1577d7 100644
--- a/packages/dashboard/app/components/__tests__/ChatView.hash-mention.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ChatView.hash-mention.test.tsx
@@ -69,7 +69,7 @@ const defaultChatState: UseChatReturn = {
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
- pendingMessage: "",
+ pendingMessages: [],
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
diff --git a/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx
index 98ffd207b5..9c1f5b3417 100644
--- a/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx
@@ -67,7 +67,7 @@ const defaultChatState: UseChatReturn = {
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
- pendingMessage: "",
+ pendingMessages: [],
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
diff --git a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx
index 84a77b3959..f523bfa061 100644
--- a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx
@@ -76,7 +76,7 @@ const defaultChatState: UseChatReturn = {
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
- pendingMessage: "",
+ pendingMessages: [],
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
diff --git a/packages/dashboard/app/components/__tests__/ChatView.scroll-to-top.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.scroll-to-top.test.tsx
index 2577d41c8b..d599fa9c87 100644
--- a/packages/dashboard/app/components/__tests__/ChatView.scroll-to-top.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ChatView.scroll-to-top.test.tsx
@@ -72,7 +72,7 @@ const defaultChatState: UseChatReturn = {
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
- pendingMessage: "",
+ pendingMessages: [],
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
diff --git a/packages/dashboard/app/components/__tests__/ChatView.swipe-back.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.swipe-back.test.tsx
index 9b09fcf8f7..2c3e4a49a1 100644
--- a/packages/dashboard/app/components/__tests__/ChatView.swipe-back.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ChatView.swipe-back.test.tsx
@@ -88,7 +88,7 @@ function StatefulChatView() {
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
- pendingMessage: "",
+ pendingMessages: [],
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
diff --git a/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx b/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx
index f345a73abb..925e470f1e 100644
--- a/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx
+++ b/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx
@@ -67,7 +67,7 @@ export const defaultChatState: UseChatReturn = {
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
- pendingMessage: "",
+ pendingMessages: [],
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts
index 549b723f3c..53ceaa8244 100644
--- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts
+++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts
@@ -1698,7 +1698,7 @@ describe("useChat", () => {
});
});
- it("stopStreaming with no pendingMessage cancels stream without sending anything", async () => {
+ it("stopStreaming with no pendingMessages cancels stream without sending anything", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
@@ -1727,12 +1727,12 @@ describe("useChat", () => {
await waitFor(() => {
expect(closeFn).toHaveBeenCalledTimes(1);
- expect(result.current.pendingMessage).toBe("");
+ expect(result.current.pendingMessages).toEqual([]);
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
});
});
- it("sending during streaming queues pendingMessage without warning toast", async () => {
+ it("sending during streaming queues pendingMessages without warning toast", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
@@ -1766,7 +1766,7 @@ describe("useChat", () => {
result.current.sendMessage("Queued message");
});
- expect(result.current.pendingMessage).toBe("Queued message");
+ expect(result.current.pendingMessages).toEqual(["Queued message"]);
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
expect(addToast).not.toHaveBeenCalledWith("Still waiting for previous response — message queued", "warning");
});
@@ -1803,7 +1803,7 @@ describe("useChat", () => {
result.current.sendMessage("Queued follow-up");
});
- expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up");
+ expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
});
it("rehydrates queued message from localStorage after remount", async () => {
@@ -1840,7 +1840,7 @@ describe("useChat", () => {
});
await waitFor(() => {
- expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up");
+ expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
});
firstHook.unmount();
@@ -1856,7 +1856,38 @@ describe("useChat", () => {
});
await waitFor(() => {
- expect(secondHook.result.current.pendingMessage).toBe("Queued follow-up");
+ expect(secondHook.result.current.pendingMessages).toEqual(["Queued follow-up"]);
+ });
+ });
+
+ it("rehydrates legacy single-string queued message from localStorage after remount", async () => {
+ const session = {
+ ...makeSession({ id: "session-001", agentId: "agent-001" }),
+ isGenerating: true,
+ inFlightGeneration: {
+ streamingText: "partial",
+ streamingThinking: "",
+ toolCalls: [],
+ },
+ };
+ mockFetchChatSessions.mockResolvedValue({ sessions: [session] });
+ mockFetchChatMessages.mockResolvedValue({ messages: [] });
+ mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
+
+ localStorage.setItem(getChatPendingMessageKey("session-001")!, "Legacy queued follow-up");
+
+ const { result } = renderHook(() => useChat("proj-123"));
+
+ await waitFor(() => {
+ expect(result.current.sessions).toHaveLength(1);
+ });
+
+ act(() => {
+ result.current.selectSession("session-001");
+ });
+
+ await waitFor(() => {
+ expect(result.current.pendingMessages).toEqual(["Legacy queued follow-up"]);
});
});
@@ -1899,7 +1930,7 @@ describe("useChat", () => {
});
await waitFor(() => {
- expect(result.current.pendingMessage).toBe("Queued follow-up");
+ expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
});
act(() => {
@@ -1910,7 +1941,7 @@ describe("useChat", () => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
expect(mockStreamChatResponse.mock.calls[1]?.[0]).toBe("session-001");
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued follow-up");
- expect(result.current.pendingMessage).toBe("");
+ expect(result.current.pendingMessages).toEqual([]);
expect(result.current.isStreaming).toBe(true);
});
@@ -1924,7 +1955,7 @@ describe("useChat", () => {
});
});
- it("keeps only the latest queued message while streaming", async () => {
+ it("stacks queued messages while streaming and flushes them in FIFO order", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
@@ -1958,11 +1989,12 @@ describe("useChat", () => {
});
act(() => {
+ result.current.sendMessage("Queued A");
result.current.sendMessage("Queued B");
result.current.sendMessage("Queued C");
});
- expect(result.current.pendingMessage).toBe("Queued C");
+ expect(result.current.pendingMessages).toEqual(["Queued A", "Queued B", "Queued C"]);
act(() => {
handlers[0]?.onDone?.({ messageId: "msg-001" });
@@ -1970,7 +2002,28 @@ describe("useChat", () => {
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
- expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued C");
+ expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued A");
+ expect(result.current.pendingMessages).toEqual(["Queued B", "Queued C"]);
+ });
+
+ act(() => {
+ handlers[1]?.onDone?.({ messageId: "msg-002" });
+ });
+
+ await waitFor(() => {
+ expect(mockStreamChatResponse).toHaveBeenCalledTimes(3);
+ expect(mockStreamChatResponse.mock.calls[2]?.[1]).toBe("Queued B");
+ expect(result.current.pendingMessages).toEqual(["Queued C"]);
+ });
+
+ act(() => {
+ handlers[2]?.onDone?.({ messageId: "msg-003" });
+ });
+
+ await waitFor(() => {
+ expect(mockStreamChatResponse).toHaveBeenCalledTimes(4);
+ expect(mockStreamChatResponse.mock.calls[3]?.[1]).toBe("Queued C");
+ expect(result.current.pendingMessages).toEqual([]);
});
});
@@ -2048,7 +2101,7 @@ describe("useChat", () => {
});
await waitFor(() => {
- expect(result.current.pendingMessage).toBe("Queued follow-up");
+ expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
});
const subscribeOptions = mockSubscribeSse.mock.calls.at(-1)?.[1];
@@ -2069,7 +2122,7 @@ describe("useChat", () => {
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Queued follow-up");
- expect(result.current.pendingMessage).toBe("");
+ expect(result.current.pendingMessages).toEqual([]);
});
});
@@ -2104,7 +2157,7 @@ describe("useChat", () => {
});
await waitFor(() => {
- expect(result.current.pendingMessage).toBe("Queued follow-up");
+ expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
});
act(() => {
@@ -2116,12 +2169,12 @@ describe("useChat", () => {
expect(mockFetchChatSession).toHaveBeenCalledWith("session-001", "proj-123");
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Queued follow-up");
- expect(result.current.pendingMessage).toBe("");
+ expect(result.current.pendingMessages).toEqual([]);
});
});
});
- it("stopStreaming sends queued pendingMessage after cancelling the stream", async () => {
+ it("stopStreaming sends queued pendingMessages after cancelling the stream", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
@@ -2164,7 +2217,7 @@ describe("useChat", () => {
expect(closeFn).toHaveBeenCalled();
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued follow-up");
- expect(result.current.pendingMessage).toBe("");
+ expect(result.current.pendingMessages).toEqual([]);
});
act(() => {
@@ -2206,8 +2259,8 @@ describe("useChat", () => {
});
await waitFor(() => {
- expect(result.current.pendingMessage).toBe("Queued follow-up");
- expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up");
+ expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
+ expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
});
act(() => {
@@ -2216,11 +2269,11 @@ describe("useChat", () => {
await waitFor(() => {
expect(result.current.activeSession).toBeNull();
- expect(result.current.pendingMessage).toBe("");
+ expect(result.current.pendingMessages).toEqual([]);
expect(result.current.isStreaming).toBe(false);
});
- expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up");
+ expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
act(() => {
result.current.selectSession("session-001");
@@ -2228,7 +2281,7 @@ describe("useChat", () => {
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-001");
- expect(result.current.pendingMessage).toBe("Queued follow-up");
+ expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
expect(result.current.isStreaming).toBe(true);
});
});
@@ -2260,7 +2313,7 @@ describe("useChat", () => {
return { close: vi.fn(), isConnected: () => true };
});
- localStorage.setItem(getChatPendingMessageKey("session-001")!, "Queued follow-up");
+ localStorage.setItem(getChatPendingMessageKey("session-001")!, JSON.stringify(["Queued follow-up"]));
const { result } = renderHook(() => useChat("proj-123"));
@@ -2276,12 +2329,12 @@ describe("useChat", () => {
// reveals the in-flight generation, so the hook attaches instead of
// flushing.
await waitFor(() => {
- expect(result.current.pendingMessage).toBe("Queued follow-up");
+ expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
expect(result.current.isStreaming).toBe(true);
});
expect(mockStreamChatResponse).not.toHaveBeenCalled();
- expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up");
+ expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
// Once the attached generation completes, the queued message flushes.
act(() => {
@@ -2292,21 +2345,34 @@ describe("useChat", () => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
expect(mockStreamChatResponse.mock.calls[0]?.[0]).toBe("session-001");
expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Queued follow-up");
- expect(result.current.pendingMessage).toBe("");
+ expect(result.current.pendingMessages).toEqual([]);
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
});
});
- it("keeps a restored queued message un-flushed while the server validation fetch is pending", async () => {
- // Production latency case: the authoritative fetch takes one network
- // RTT. Nothing may flush (or delete) the restored queue in the interim.
+ it("keeps a restored queued message un-flushed when an attached stream errors but the server is still generating", async () => {
const sessionA = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValue({ sessions: [sessionA] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
- // Server check never resolves within the test — simulates in-flight RTT.
- mockFetchChatSession.mockReturnValue(new Promise(() => {}) as never);
+ mockFetchChatSession.mockResolvedValue({
+ session: {
+ ...sessionA,
+ isGenerating: true,
+ inFlightGeneration: {
+ streamingText: "partial",
+ streamingThinking: "",
+ toolCalls: [],
+ },
+ },
+ });
- localStorage.setItem(getChatPendingMessageKey("session-001")!, "Queued follow-up");
+ const attachHandlers: Array
[1]> = [];
+ mockAttachChatStream.mockImplementation((_sessionId, nextHandlers) => {
+ attachHandlers.push(nextHandlers);
+ return { close: vi.fn(), isConnected: () => true };
+ });
+
+ localStorage.setItem(getChatPendingMessageKey("session-001")!, JSON.stringify(["Queued follow-up"]));
const { result } = renderHook(() => useChat("proj-123"));
@@ -2319,14 +2385,54 @@ describe("useChat", () => {
});
await waitFor(() => {
- expect(result.current.pendingMessage).toBe("Queued follow-up");
+ expect(attachHandlers).toHaveLength(1);
+ expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
expect(mockStreamChatResponse).not.toHaveBeenCalled();
- expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up");
});
- expect(result.current.pendingMessage).toBe("Queued follow-up");
+ act(() => {
+ attachHandlers[0]?.onError?.("network");
+ });
+
+ await waitFor(() => {
+ expect(attachHandlers).toHaveLength(2);
+ expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
+ expect(result.current.isStreaming).toBe(true);
+ expect(mockStreamChatResponse).not.toHaveBeenCalled();
+ expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
+ });
+ });
+
+ it("keeps a restored queued message un-flushed while the server validation fetch is pending", async () => {
+ // Production latency case: the authoritative fetch takes one network
+ // RTT. Nothing may flush (or delete) the restored queue in the interim.
+ const sessionA = makeSession({ id: "session-001", agentId: "agent-001" });
+ mockFetchChatSessions.mockResolvedValue({ sessions: [sessionA] });
+ mockFetchChatMessages.mockResolvedValue({ messages: [] });
+ // Server check never resolves within the test — simulates in-flight RTT.
+ mockFetchChatSession.mockReturnValue(new Promise(() => {}) as never);
+
+ localStorage.setItem(getChatPendingMessageKey("session-001")!, JSON.stringify(["Queued follow-up"]));
+
+ const { result } = renderHook(() => useChat("proj-123"));
+
+ await waitFor(() => {
+ expect(result.current.sessions).toHaveLength(1);
+ });
+
+ act(() => {
+ result.current.selectSession("session-001");
+ });
+
+ await waitFor(() => {
+ expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
+ expect(mockStreamChatResponse).not.toHaveBeenCalled();
+ expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
+ });
+
+ expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
expect(mockStreamChatResponse).not.toHaveBeenCalled();
- expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up");
+ expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
});
it("preserves queued messages across session switches and rehydrates them when returning", async () => {
@@ -2363,7 +2469,7 @@ describe("useChat", () => {
});
await waitFor(() => {
- expect(result.current.pendingMessage).toBe("Queued follow-up");
+ expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
});
act(() => {
@@ -2372,11 +2478,11 @@ describe("useChat", () => {
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-002");
- expect(result.current.pendingMessage).toBe("");
+ expect(result.current.pendingMessages).toEqual([]);
expect(result.current.isStreaming).toBe(false);
});
- expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up");
+ expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
act(() => {
result.current.selectSession("session-001");
@@ -2384,7 +2490,7 @@ describe("useChat", () => {
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-001");
- expect(result.current.pendingMessage).toBe("Queued follow-up");
+ expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
expect(result.current.isStreaming).toBe(true);
});
});
@@ -2408,6 +2514,53 @@ describe("useChat", () => {
expect(localStorage.getItem("fusion:chat-pending:undefined")).toBeNull();
});
+ it("clearPendingMessage with an index removes only that queued message and persists the tail", async () => {
+ const session = makeSession({ id: "session-001", agentId: "agent-001" });
+ mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
+ mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
+
+ mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
+
+ const { result } = renderHook(() => useChat("proj-123"));
+
+ await waitFor(() => {
+ expect(result.current.sessions).toHaveLength(1);
+ });
+
+ act(() => {
+ result.current.selectSession("session-001");
+ });
+
+ await waitFor(() => {
+ expect(result.current.activeSession?.id).toBe("session-001");
+ });
+
+ act(() => {
+ result.current.sendMessage("First");
+ });
+
+ await waitFor(() => {
+ expect(result.current.isStreaming).toBe(true);
+ });
+
+ act(() => {
+ result.current.sendMessage("Queued A");
+ result.current.sendMessage("Queued B");
+ result.current.sendMessage("Queued C");
+ });
+
+ await waitFor(() => {
+ expect(result.current.pendingMessages).toEqual(["Queued A", "Queued B", "Queued C"]);
+ });
+
+ act(() => {
+ result.current.clearPendingMessage(1);
+ });
+
+ expect(result.current.pendingMessages).toEqual(["Queued A", "Queued C"]);
+ expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued A", "Queued C"]));
+ });
+
it("clearPendingMessage clears pending message and removes persisted queue entry", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
@@ -2442,18 +2595,18 @@ describe("useChat", () => {
});
await waitFor(() => {
- expect(result.current.pendingMessage).toBe("Queued follow-up");
+ expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
});
act(() => {
result.current.clearPendingMessage();
});
- expect(result.current.pendingMessage).toBe("");
+ expect(result.current.pendingMessages).toEqual([]);
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
});
- it("createSession removes the prior session's persisted queued message", async () => {
+ it("createSession removes the prior session's persisted queued messages", async () => {
const existingSession = {
...makeSession({ id: "session-001", agentId: "agent-001" }),
isGenerating: true,
@@ -2489,7 +2642,7 @@ describe("useChat", () => {
});
await waitFor(() => {
- expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up");
+ expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
});
await act(async () => {
@@ -2503,7 +2656,7 @@ describe("useChat", () => {
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
});
- it("archiveSession removes the archived session's persisted queued message", async () => {
+ it("archiveSession removes the archived session's persisted queued messages", async () => {
const session = {
...makeSession({ id: "session-001", agentId: "agent-001" }),
isGenerating: true,
@@ -2537,7 +2690,7 @@ describe("useChat", () => {
});
await waitFor(() => {
- expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up");
+ expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
});
await act(async () => {
@@ -2547,7 +2700,7 @@ describe("useChat", () => {
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
});
- it("deleteSession removes the deleted session's persisted queued message", async () => {
+ it("deleteSession removes the deleted session's persisted queued messages", async () => {
const session = {
...makeSession({ id: "session-001", agentId: "agent-001" }),
isGenerating: true,
@@ -2581,7 +2734,7 @@ describe("useChat", () => {
});
await waitFor(() => {
- expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up");
+ expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
});
await act(async () => {
@@ -2595,7 +2748,7 @@ describe("useChat", () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
- localStorage.setItem(getChatPendingMessageKey("session-001")!, "Queued follow-up");
+ localStorage.setItem(getChatPendingMessageKey("session-001")!, JSON.stringify(["Queued follow-up"]));
const { result } = renderHook(() => useChat("proj-123"));
@@ -2615,7 +2768,7 @@ describe("useChat", () => {
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
});
- it("stopStreaming flushes pendingMessage", async () => {
+ it("stopStreaming flushes pendingMessages", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
@@ -2652,7 +2805,7 @@ describe("useChat", () => {
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued follow-up");
- expect(result.current.pendingMessage).toBe("");
+ expect(result.current.pendingMessages).toEqual([]);
});
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
diff --git a/packages/dashboard/app/hooks/chatPendingMessageStorage.ts b/packages/dashboard/app/hooks/chatPendingMessageStorage.ts
index c5e189162f..03749a4236 100644
--- a/packages/dashboard/app/hooks/chatPendingMessageStorage.ts
+++ b/packages/dashboard/app/hooks/chatPendingMessageStorage.ts
@@ -8,33 +8,57 @@ export function getChatPendingMessageKey(sessionId: string | null | undefined):
return `${CHAT_PENDING_MESSAGE_STORAGE_PREFIX}${sessionId}`;
}
-export function getPersistedPendingChatMessage(sessionId: string | null | undefined): string {
+export function getPersistedPendingChatMessages(sessionId: string | null | undefined): string[] {
const key = getChatPendingMessageKey(sessionId);
if (!key || typeof window === "undefined") {
- return "";
+ return [];
}
try {
- return localStorage.getItem(key) ?? "";
+ const value = localStorage.getItem(key);
+ if (!value) {
+ return [];
+ }
+
+ /*
+ FNXC:ChatComposer 2026-06-27-00:00:
+ Queued chat messages persist as a JSON array so reloads retain FIFO order. Legacy single-string values are coerced to a one-item queue so pre-array in-flight messages are not dropped.
+ */
+ try {
+ const parsed = JSON.parse(value) as unknown;
+ if (Array.isArray(parsed)) {
+ return parsed.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
+ }
+ } catch {
+ // Fall through to legacy single-string handling.
+ }
+
+ const legacyMessage = value.trim();
+ return legacyMessage ? [legacyMessage] : [];
} catch {
- return "";
+ return [];
}
}
-export function setPersistedPendingChatMessage(sessionId: string | null | undefined, content: string): void {
+export function setPersistedPendingChatMessages(sessionId: string | null | undefined, messages: string[]): void {
const key = getChatPendingMessageKey(sessionId);
if (!key || typeof window === "undefined") {
return;
}
try {
- localStorage.setItem(key, content);
+ const normalizedMessages = messages.filter((message) => message.trim().length > 0);
+ if (normalizedMessages.length === 0) {
+ localStorage.removeItem(key);
+ return;
+ }
+ localStorage.setItem(key, JSON.stringify(normalizedMessages));
} catch {
// Ignore localStorage failures so chat queuing still works in-memory.
}
}
-export function removePersistedPendingChatMessage(sessionId: string | null | undefined): void {
+export function removePersistedPendingChatMessages(sessionId: string | null | undefined): void {
const key = getChatPendingMessageKey(sessionId);
if (!key || typeof window === "undefined") {
return;
diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts
index d28bd23e16..4c3c5e31e9 100644
--- a/packages/dashboard/app/hooks/useChat.ts
+++ b/packages/dashboard/app/hooks/useChat.ts
@@ -48,9 +48,9 @@ export type { ChatMessageInfo, FailureInfo, FallbackInfo, ToolCallInfo } from ".
import type { ChatMessageInfo, FailureInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
import { createChatStreamHandlers } from "./createChatStreamHandlers";
import {
- getPersistedPendingChatMessage,
- removePersistedPendingChatMessage,
- setPersistedPendingChatMessage,
+ getPersistedPendingChatMessages,
+ removePersistedPendingChatMessages,
+ setPersistedPendingChatMessages,
} from "./chatPendingMessageStorage";
import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "./visibilitySuspension";
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache";
@@ -69,7 +69,7 @@ export interface UseChatReturn {
streamingText: string;
streamingThinking: string;
streamingToolCalls: ToolCallInfo[];
- pendingMessage: string;
+ pendingMessages: string[];
// Session operations
selectSession: (id: string, sessionOverride?: ChatSessionInfo) => void;
@@ -84,7 +84,7 @@ export interface UseChatReturn {
/** Send a message, optionally with file attachments to upload with the prompt. */
sendMessage: (content: string, attachments?: File[]) => void;
stopStreaming: () => void;
- clearPendingMessage: () => void;
+ clearPendingMessage: (index?: number) => void;
loadMoreMessages: () => Promise;
hasMoreMessages: boolean;
@@ -294,7 +294,7 @@ export function useChat(
const [streamingText, setStreamingText] = useState("");
const [streamingThinking, setStreamingThinking] = useState("");
const [streamingToolCalls, setStreamingToolCalls] = useState([]);
- const [pendingMessage, setPendingMessage] = useState("");
+ const [pendingMessages, setPendingMessages] = useState([]);
// Search/filter
const [searchQuery, setSearchQuery] = useState("");
@@ -309,7 +309,12 @@ export function useChat(
const streamRef = useRef<{ close: () => void } | null>(null);
const lastAttachedGenerationRef = useRef<{ sessionId: string; replayFromEventId: number | null } | null>(null);
const cancelledByUserRef = useRef(false);
- const pendingMessageRef = useRef("");
+ const pendingMessagesRef = useRef([]);
+ const attachIfGeneratingRef = useRef<(
+ sessionId: string,
+ inFlightGeneration?: ChatInFlightGenerationState | null,
+ options?: { silent?: boolean; priorThreadLoadAlreadyStarted?: boolean },
+ ) => boolean>(() => false);
// Cancel any pending requestAnimationFrame flushes from the active stream.
// Set when sendMessage starts, cleared on done/error. Called from stopStreaming
// so a clear-then-rAF-fires sequence doesn't flash stale text back in.
@@ -326,8 +331,8 @@ export function useChat(
isStreamingRef.current = isStreaming;
useEffect(() => {
- pendingMessageRef.current = pendingMessage;
- }, [pendingMessage]);
+ pendingMessagesRef.current = pendingMessages;
+ }, [pendingMessages]);
// Tracks message IDs that were added via streaming completion.
// Used to prevent duplicate messages when SSE event arrives before streaming state clears.
@@ -503,32 +508,71 @@ export function useChat(
const resetTransientComposerState = useCallback(() => {
cancelStreamingFlushesRef.current?.();
cancelStreamingFlushesRef.current = null;
- pendingMessageRef.current = "";
- setPendingMessage("");
+ pendingMessagesRef.current = [];
+ setPendingMessages([]);
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(false);
}, []);
- const clearPendingMessage = useCallback(() => {
- removePersistedPendingChatMessage(activeSessionRef.current?.id);
- pendingMessageRef.current = "";
- setPendingMessage("");
- }, []);
-
- const flushPendingMessage = useCallback(() => {
- const queuedMessage = pendingMessageRef.current.trim();
- if (!queuedMessage) {
+ const clearPendingMessage = useCallback((index?: number) => {
+ const sessionId = activeSessionRef.current?.id;
+ if (typeof index === "number") {
+ const nextMessages = pendingMessagesRef.current.filter((_, messageIndex) => messageIndex !== index);
+ pendingMessagesRef.current = nextMessages;
+ setPendingMessages(nextMessages);
+ setPersistedPendingChatMessages(sessionId, nextMessages);
return;
}
- removePersistedPendingChatMessage(activeSessionRef.current?.id);
- pendingMessageRef.current = "";
- setPendingMessage("");
- sendMessageRef.current(queuedMessage);
+ removePersistedPendingChatMessages(sessionId);
+ pendingMessagesRef.current = [];
+ setPendingMessages([]);
}, []);
+ const flushPendingMessage = useCallback(() => {
+ const [queuedMessage, ...remainingMessages] = pendingMessagesRef.current;
+ const trimmedQueuedMessage = queuedMessage?.trim();
+ if (!trimmedQueuedMessage) {
+ return;
+ }
+
+ const sessionId = activeSessionRef.current?.id;
+ pendingMessagesRef.current = remainingMessages;
+ setPendingMessages(remainingMessages);
+ setPersistedPendingChatMessages(sessionId, remainingMessages);
+ sendMessageRef.current(trimmedQueuedMessage);
+ }, []);
+
+ const flushPendingMessageAfterAttachedError = useCallback(async (
+ sessionId: string,
+ options?: { silent?: boolean },
+ ) => {
+ try {
+ const { session: refreshedSession } = await fetchChatSession(sessionId, projectId);
+ if (activeSessionRef.current?.id !== sessionId || pendingMessagesRef.current.length === 0) {
+ return;
+ }
+
+ if (refreshedSession.isGenerating || refreshedSession.inFlightGeneration) {
+ /*
+ FNXC:ChatComposer 2026-06-27-00:00:
+ Attach-stream errors must not dequeue restored messages until an authoritative session fetch proves the server is idle; otherwise a reconnect race can send the FIFO front while the previous generation is still in flight.
+ */
+ attachIfGeneratingRef.current(sessionId, refreshedSession.inFlightGeneration, {
+ silent: options?.silent,
+ priorThreadLoadAlreadyStarted: true,
+ });
+ return;
+ }
+
+ flushPendingMessage();
+ } catch {
+ // Keep the queue durable when the authoritative generation check is unavailable.
+ }
+ }, [flushPendingMessage, projectId]);
+
const attachIfGenerating = useCallback((
sessionId: string,
inFlightGeneration?: ChatInFlightGenerationState | null,
@@ -609,7 +653,7 @@ export function useChat(
addToast?.(failureInfo.summary, "error");
}
void loadMessages(sessionId);
- flushPendingMessage();
+ void flushPendingMessageAfterAttachedError(sessionId, { silent: options?.silent });
},
});
@@ -628,7 +672,8 @@ export function useChat(
});
streamRef.current = stream;
return true;
- }, [addToast, hydrateMessagesFromCache, loadMessages, projectId, flushPendingMessage]);
+ }, [addToast, flushPendingMessage, flushPendingMessageAfterAttachedError, hydrateMessagesFromCache, loadMessages, projectId]);
+ attachIfGeneratingRef.current = attachIfGenerating;
// Select a session
const selectSession = useCallback(
@@ -710,13 +755,17 @@ export function useChat(
return;
}
- const restoredPendingMessage = getPersistedPendingChatMessage(sessionId);
- if (!restoredPendingMessage) {
+ const restoredPendingMessages = getPersistedPendingChatMessages(sessionId);
+ if (restoredPendingMessages.length === 0) {
return;
}
- pendingMessageRef.current = restoredPendingMessage;
- setPendingMessage(restoredPendingMessage);
+ /*
+ FNXC:ChatComposer 2026-06-27-00:00:
+ Queued direct-chat sends are a FIFO array: every send during streaming stacks above the composer and exactly one front item flushes after each stream completion, preserving FN-5852's server-in-flight guard.
+ */
+ pendingMessagesRef.current = restoredPendingMessages;
+ setPendingMessages(restoredPendingMessages);
// Flush only once the server confirms no generation is in flight. The
// local sessions list can hold a stale falsy `isGenerating` (it is a
@@ -729,7 +778,7 @@ export function useChat(
if (
cancelled ||
activeSessionRef.current?.id !== sessionId ||
- pendingMessageRef.current.trim().length === 0
+ pendingMessagesRef.current.length === 0
) {
return;
}
@@ -784,7 +833,7 @@ export function useChat(
return [newSession, ...prev];
});
- removePersistedPendingChatMessage(previousSessionId);
+ removePersistedPendingChatMessages(previousSessionId);
resetTransientComposerState();
selectSession(newSession.id, newSession);
@@ -796,7 +845,7 @@ export function useChat(
// Archive a session
const archiveSession = useCallback(
async (id: string) => {
- removePersistedPendingChatMessage(id);
+ removePersistedPendingChatMessages(id);
await updateChatSession(id, { status: "archived" }, projectId);
// Remove from sessions list
setSessions((prev) => prev.filter((s) => s.id !== id));
@@ -859,7 +908,7 @@ export function useChat(
// Delete a session
const deleteSession = useCallback(
async (id: string) => {
- removePersistedPendingChatMessage(id);
+ removePersistedPendingChatMessages(id);
// Close stream if active
if (activeSession?.id === id && streamRef.current) {
streamRef.current.close();
@@ -971,9 +1020,14 @@ export function useChat(
if (!activeSession) return;
if (isStreamingRef.current) {
- pendingMessageRef.current = content;
- setPendingMessage(content);
- setPersistedPendingChatMessage(activeSession.id, content);
+ const trimmedContent = content.trim();
+ if (!trimmedContent) {
+ return;
+ }
+ const nextMessages = [...pendingMessagesRef.current, trimmedContent];
+ pendingMessagesRef.current = nextMessages;
+ setPendingMessages(nextMessages);
+ setPersistedPendingChatMessages(activeSession.id, nextMessages);
return;
}
@@ -1002,6 +1056,7 @@ export function useChat(
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(true);
+ isStreamingRef.current = true;
const { handlers } = createChatStreamHandlers({
sessionId: activeSession.id,
@@ -1375,7 +1430,7 @@ export function useChat(
streamingText,
streamingThinking,
streamingToolCalls,
- pendingMessage,
+ pendingMessages,
selectSession,
createSession,
archiveSession,