FN-7137: stack queued chat messages

Stack queued chat sends above the composer and flush them in order.

- Persist pending chat sends as per-session FIFO arrays with legacy single-message restore fallback.
- Render stacked queued previews above the direct and quick chat composer with per-item dismissal.
- Flush one queued message after each completed or safely verified idle generation, preserving reconnect guards.
- Extend chat hook and composer tests for multi-message queuing, restore, dismissal, and FIFO sends.
- Document the queued stack behavior and add a published package changeset.

Files changed:
 .changeset/fn-7137-stack-queued-chat-messages.md   |   7 +
 docs/dashboard-guide.md                            |   4 +-
 packages/dashboard/app/components/ChatView.css     |  15 +-
 packages/dashboard/app/components/ChatView.tsx     |  38 ++--
 .../__tests__/ChatView.autosize.test.tsx           |   2 +-
 .../__tests__/ChatView.cli-mount.test.tsx          |   2 +-
 .../__tests__/ChatView.core-interactions.test.tsx  |  27 ++-
 .../__tests__/ChatView.default-model-icon.test.tsx |   2 +-
 .../components/__tests__/ChatView.draft.test.tsx   |   2 +-
 .../__tests__/ChatView.hash-mention.test.tsx       |   2 +-
 .../__tests__/ChatView.mobile-render.test.tsx      |   2 +-
 .../components/__tests__/ChatView.rooms.test.tsx   |   2 +-
 .../__tests__/ChatView.scroll-to-top.test.tsx      |   2 +-
 .../__tests__/ChatView.swipe-back.test.tsx         |   2 +-
 .../components/__tests__/ChatView.test-harness.tsx |   2 +-
 .../dashboard/app/hooks/__tests__/useChat.test.ts  | 247 +++++++++++++++++----
 .../app/hooks/chatPendingMessageStorage.ts         |  38 +++-
 packages/dashboard/app/hooks/useChat.ts            | 125 ++++++++---
 18 files changed, 390 insertions(+), 131 deletions(-)

Fusion-Task-Id: FN-7137

Fusion-Task-Lineage: 65dd5ed7-a8db-447b-9a50-ce73ea7b597f

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 15:56:42 -07:00
parent ba599a4ea1
commit 5066f90db2
18 changed files with 398 additions and 139 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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
))}
</div>
)}
{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.
*/}
<div className="chat-pending-message" data-testid="chat-pending-indicator">
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: pendingPreview })}</span>
<button
type="button"
className="chat-pending-message-dismiss"
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
data-testid="chat-pending-dismiss"
onClick={clearPendingMessage}
>
×
</button>
<div className="chat-pending-stack" data-testid="chat-pending-stack">
{pendingMessages.map((pendingMessage, index) => (
<div className="chat-pending-message" data-testid="chat-pending-indicator" key={`${index}-${pendingMessage}`}>
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: getPendingPreview(pendingMessage) })}</span>
<button
type="button"
className="chat-pending-message-dismiss"
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
data-testid={`chat-pending-dismiss-${index}`}
onClick={() => clearPendingMessage(index)}
>
×
</button>
</div>
))}
</div>
<div className="chat-pending-divider" aria-hidden="true" />
</>

View File

@@ -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,

View File

@@ -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,

View File

@@ -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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<Parameters<typeof mockAttachChatStream>[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();

View File

@@ -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;

View File

@@ -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<void>;
hasMoreMessages: boolean;
@@ -294,7 +294,7 @@ export function useChat(
const [streamingText, setStreamingText] = useState("");
const [streamingThinking, setStreamingThinking] = useState("");
const [streamingToolCalls, setStreamingToolCalls] = useState<ToolCallInfo[]>([]);
const [pendingMessage, setPendingMessage] = useState("");
const [pendingMessages, setPendingMessages] = useState<string[]>([]);
// 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<string[]>([]);
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,