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:
7
.changeset/fn-7137-stack-queued-chat-messages.md
Normal file
7
.changeset/fn-7137-stack-queued-chat-messages.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()} />);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user