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)
|
- 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.
|
- 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 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.
|
- 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, 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.
|
- 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
|
- 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.
|
- 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.
|
- 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
|
- 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
|
- 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.
|
- 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 === */
|
/* === Chat Pending Message === */
|
||||||
/*
|
/*
|
||||||
FNXC:ChatComposer 2026-06-27-00:00:
|
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 {
|
.chat-pending-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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
|
thread takes the full viewport. The thread already renders a back
|
||||||
button (ChevronLeft) on mobile to flip back to the session list. */
|
button (ChevronLeft) on mobile to flip back to the session list. */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.chat-pending-stack {
|
||||||
|
max-height: calc(var(--space-xl) * 5);
|
||||||
|
}
|
||||||
|
|
||||||
.chat-pending-message {
|
.chat-pending-message {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1024,7 +1024,7 @@ export function ChatView({ projectId, addToast, floating = false, onPopOut, onMa
|
|||||||
deleteSession,
|
deleteSession,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
stopStreaming,
|
stopStreaming,
|
||||||
pendingMessage,
|
pendingMessages,
|
||||||
clearPendingMessage,
|
clearPendingMessage,
|
||||||
loadMoreMessages,
|
loadMoreMessages,
|
||||||
hasMoreMessages,
|
hasMoreMessages,
|
||||||
@@ -2731,9 +2731,9 @@ export function ChatView({ projectId, addToast, floating = false, onPopOut, onMa
|
|||||||
// the render-mode toggle still appears in a slim toolbar.
|
// the render-mode toggle still appears in a slim toolbar.
|
||||||
const hideAssistantIdentity = activeSession?.agentId === FN_AGENT_ID;
|
const hideAssistantIdentity = activeSession?.agentId === FN_AGENT_ID;
|
||||||
|
|
||||||
const pendingPreview = pendingMessage.length > 50
|
const getPendingPreview = (message: string) => message.length > 50
|
||||||
? `${pendingMessage.slice(0, 50)}…`
|
? `${message.slice(0, 50)}…`
|
||||||
: pendingMessage;
|
: message;
|
||||||
|
|
||||||
const toggleAllAsPlain = useCallback(() => {
|
const toggleAllAsPlain = useCallback(() => {
|
||||||
setShowAllAsPlain((value) => !value);
|
setShowAllAsPlain((value) => !value);
|
||||||
@@ -3057,23 +3057,27 @@ export function ChatView({ projectId, addToast, floating = false, onPopOut, onMa
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{pendingMessage && (
|
{pendingMessages.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{/*
|
{/*
|
||||||
FNXC:ChatComposer 2026-06-27-00:00:
|
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">
|
<div className="chat-pending-stack" data-testid="chat-pending-stack">
|
||||||
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: pendingPreview })}</span>
|
{pendingMessages.map((pendingMessage, index) => (
|
||||||
<button
|
<div className="chat-pending-message" data-testid="chat-pending-indicator" key={`${index}-${pendingMessage}`}>
|
||||||
type="button"
|
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: getPendingPreview(pendingMessage) })}</span>
|
||||||
className="chat-pending-message-dismiss"
|
<button
|
||||||
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
|
type="button"
|
||||||
data-testid="chat-pending-dismiss"
|
className="chat-pending-message-dismiss"
|
||||||
onClick={clearPendingMessage}
|
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
|
||||||
>
|
data-testid={`chat-pending-dismiss-${index}`}
|
||||||
×
|
onClick={() => clearPendingMessage(index)}
|
||||||
</button>
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-pending-divider" aria-hidden="true" />
|
<div className="chat-pending-divider" aria-hidden="true" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ const defaultChatState: UseChatReturn = {
|
|||||||
deleteSession: vi.fn(),
|
deleteSession: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stopStreaming: vi.fn(),
|
stopStreaming: vi.fn(),
|
||||||
pendingMessage: "",
|
pendingMessages: [],
|
||||||
clearPendingMessage: vi.fn(),
|
clearPendingMessage: vi.fn(),
|
||||||
loadMoreMessages: vi.fn(),
|
loadMoreMessages: vi.fn(),
|
||||||
hasMoreMessages: false,
|
hasMoreMessages: false,
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function chatState(session: ChatSessionInfo): UseChatReturn {
|
|||||||
deleteSession: vi.fn(),
|
deleteSession: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stopStreaming: vi.fn(),
|
stopStreaming: vi.fn(),
|
||||||
pendingMessage: "",
|
pendingMessages: [],
|
||||||
clearPendingMessage: vi.fn(),
|
clearPendingMessage: vi.fn(),
|
||||||
loadMoreMessages: vi.fn(),
|
loadMoreMessages: vi.fn(),
|
||||||
hasMoreMessages: false,
|
hasMoreMessages: false,
|
||||||
|
|||||||
@@ -732,22 +732,23 @@ describe("ChatView core interactions", () => {
|
|||||||
expect(screen.getByTestId("chat-send-btn")).toBeInTheDocument();
|
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 clearPendingMessage = vi.fn();
|
||||||
const activeSession = activeSessionFixture;
|
const activeSession = activeSessionFixture;
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
activeSession,
|
activeSession,
|
||||||
messages: [],
|
messages: [],
|
||||||
pendingMessage: "Queued while streaming",
|
pendingMessages: ["Queued A", "Queued B", "Queued C with a very long body that should truncate in the preview"],
|
||||||
clearPendingMessage,
|
clearPendingMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { rerender } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
const { rerender } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
const indicators = screen.getAllByTestId("chat-pending-indicator");
|
const indicators = screen.getAllByTestId("chat-pending-indicator");
|
||||||
expect(indicators).toHaveLength(1);
|
expect(indicators).toHaveLength(3);
|
||||||
const indicator = indicators[0];
|
expect(indicators[0]).toHaveTextContent("Queued: Queued A");
|
||||||
expect(indicator).toHaveTextContent("Queued: Queued while streaming");
|
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 input = screen.getByTestId("chat-input");
|
||||||
const inputArea = input.closest(".chat-input-area");
|
const inputArea = input.closest(".chat-input-area");
|
||||||
@@ -756,18 +757,20 @@ describe("ChatView core interactions", () => {
|
|||||||
expect(inputArea).not.toBeNull();
|
expect(inputArea).not.toBeNull();
|
||||||
expect(inputRow).not.toBeNull();
|
expect(inputRow).not.toBeNull();
|
||||||
expect(inputWrapper).not.toBeNull();
|
expect(inputWrapper).not.toBeNull();
|
||||||
expect(inputArea).toContainElement(indicator);
|
indicators.forEach((indicator) => {
|
||||||
expect(inputWrapper).not.toContainElement(indicator);
|
expect(inputArea).toContainElement(indicator);
|
||||||
expect(indicator.compareDocumentPosition(inputRow!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
expect(inputWrapper).not.toContainElement(indicator);
|
||||||
expect(inputArea!.querySelector(".chat-pending-divider")).toBeInTheDocument();
|
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"));
|
await userEvent.click(screen.getByTestId("chat-pending-dismiss-1"));
|
||||||
expect(clearPendingMessage).toHaveBeenCalledTimes(1);
|
expect(clearPendingMessage).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
activeSession,
|
activeSession,
|
||||||
messages: [],
|
messages: [],
|
||||||
pendingMessage: "",
|
pendingMessages: [],
|
||||||
clearPendingMessage,
|
clearPendingMessage,
|
||||||
});
|
});
|
||||||
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ function setupMockChat(session: ChatSessionInfo): void {
|
|||||||
deleteSession: vi.fn(),
|
deleteSession: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stopStreaming: vi.fn(),
|
stopStreaming: vi.fn(),
|
||||||
pendingMessage: "",
|
pendingMessages: [],
|
||||||
clearPendingMessage: vi.fn(),
|
clearPendingMessage: vi.fn(),
|
||||||
loadMoreMessages: vi.fn(),
|
loadMoreMessages: vi.fn(),
|
||||||
hasMoreMessages: false,
|
hasMoreMessages: false,
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const defaultChatState: UseChatReturn = {
|
|||||||
deleteSession: vi.fn(),
|
deleteSession: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stopStreaming: vi.fn(),
|
stopStreaming: vi.fn(),
|
||||||
pendingMessage: "",
|
pendingMessages: [],
|
||||||
clearPendingMessage: vi.fn(),
|
clearPendingMessage: vi.fn(),
|
||||||
loadMoreMessages: vi.fn(),
|
loadMoreMessages: vi.fn(),
|
||||||
hasMoreMessages: false,
|
hasMoreMessages: false,
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const defaultChatState: UseChatReturn = {
|
|||||||
deleteSession: vi.fn(),
|
deleteSession: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stopStreaming: vi.fn(),
|
stopStreaming: vi.fn(),
|
||||||
pendingMessage: "",
|
pendingMessages: [],
|
||||||
clearPendingMessage: vi.fn(),
|
clearPendingMessage: vi.fn(),
|
||||||
loadMoreMessages: vi.fn(),
|
loadMoreMessages: vi.fn(),
|
||||||
hasMoreMessages: false,
|
hasMoreMessages: false,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const defaultChatState: UseChatReturn = {
|
|||||||
deleteSession: vi.fn(),
|
deleteSession: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stopStreaming: vi.fn(),
|
stopStreaming: vi.fn(),
|
||||||
pendingMessage: "",
|
pendingMessages: [],
|
||||||
clearPendingMessage: vi.fn(),
|
clearPendingMessage: vi.fn(),
|
||||||
loadMoreMessages: vi.fn(),
|
loadMoreMessages: vi.fn(),
|
||||||
hasMoreMessages: false,
|
hasMoreMessages: false,
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ const defaultChatState: UseChatReturn = {
|
|||||||
deleteSession: vi.fn(),
|
deleteSession: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stopStreaming: vi.fn(),
|
stopStreaming: vi.fn(),
|
||||||
pendingMessage: "",
|
pendingMessages: [],
|
||||||
clearPendingMessage: vi.fn(),
|
clearPendingMessage: vi.fn(),
|
||||||
loadMoreMessages: vi.fn(),
|
loadMoreMessages: vi.fn(),
|
||||||
hasMoreMessages: false,
|
hasMoreMessages: false,
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const defaultChatState: UseChatReturn = {
|
|||||||
deleteSession: vi.fn(),
|
deleteSession: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stopStreaming: vi.fn(),
|
stopStreaming: vi.fn(),
|
||||||
pendingMessage: "",
|
pendingMessages: [],
|
||||||
clearPendingMessage: vi.fn(),
|
clearPendingMessage: vi.fn(),
|
||||||
loadMoreMessages: vi.fn(),
|
loadMoreMessages: vi.fn(),
|
||||||
hasMoreMessages: false,
|
hasMoreMessages: false,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function StatefulChatView() {
|
|||||||
deleteSession: vi.fn(),
|
deleteSession: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stopStreaming: vi.fn(),
|
stopStreaming: vi.fn(),
|
||||||
pendingMessage: "",
|
pendingMessages: [],
|
||||||
clearPendingMessage: vi.fn(),
|
clearPendingMessage: vi.fn(),
|
||||||
loadMoreMessages: vi.fn(),
|
loadMoreMessages: vi.fn(),
|
||||||
hasMoreMessages: false,
|
hasMoreMessages: false,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const defaultChatState: UseChatReturn = {
|
|||||||
deleteSession: vi.fn(),
|
deleteSession: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stopStreaming: vi.fn(),
|
stopStreaming: vi.fn(),
|
||||||
pendingMessage: "",
|
pendingMessages: [],
|
||||||
clearPendingMessage: vi.fn(),
|
clearPendingMessage: vi.fn(),
|
||||||
loadMoreMessages: vi.fn(),
|
loadMoreMessages: vi.fn(),
|
||||||
hasMoreMessages: false,
|
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" });
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||||
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
|
||||||
@@ -1727,12 +1727,12 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(closeFn).toHaveBeenCalledTimes(1);
|
expect(closeFn).toHaveBeenCalledTimes(1);
|
||||||
expect(result.current.pendingMessage).toBe("");
|
expect(result.current.pendingMessages).toEqual([]);
|
||||||
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
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" });
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||||
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
|
||||||
@@ -1766,7 +1766,7 @@ describe("useChat", () => {
|
|||||||
result.current.sendMessage("Queued message");
|
result.current.sendMessage("Queued message");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.pendingMessage).toBe("Queued message");
|
expect(result.current.pendingMessages).toEqual(["Queued message"]);
|
||||||
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
||||||
expect(addToast).not.toHaveBeenCalledWith("Still waiting for previous response — message queued", "warning");
|
expect(addToast).not.toHaveBeenCalledWith("Still waiting for previous response — message queued", "warning");
|
||||||
});
|
});
|
||||||
@@ -1803,7 +1803,7 @@ describe("useChat", () => {
|
|||||||
result.current.sendMessage("Queued follow-up");
|
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 () => {
|
it("rehydrates queued message from localStorage after remount", async () => {
|
||||||
@@ -1840,7 +1840,7 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
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();
|
firstHook.unmount();
|
||||||
@@ -1856,7 +1856,38 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.pendingMessage).toBe("Queued follow-up");
|
expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -1910,7 +1941,7 @@ describe("useChat", () => {
|
|||||||
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
|
||||||
expect(mockStreamChatResponse.mock.calls[1]?.[0]).toBe("session-001");
|
expect(mockStreamChatResponse.mock.calls[1]?.[0]).toBe("session-001");
|
||||||
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued follow-up");
|
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);
|
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" });
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||||
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
|
||||||
@@ -1958,11 +1989,12 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
|
result.current.sendMessage("Queued A");
|
||||||
result.current.sendMessage("Queued B");
|
result.current.sendMessage("Queued B");
|
||||||
result.current.sendMessage("Queued C");
|
result.current.sendMessage("Queued C");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.pendingMessage).toBe("Queued C");
|
expect(result.current.pendingMessages).toEqual(["Queued A", "Queued B", "Queued C"]);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
handlers[0]?.onDone?.({ messageId: "msg-001" });
|
handlers[0]?.onDone?.({ messageId: "msg-001" });
|
||||||
@@ -1970,7 +2002,28 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
|
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(() => {
|
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];
|
const subscribeOptions = mockSubscribeSse.mock.calls.at(-1)?.[1];
|
||||||
@@ -2069,7 +2122,7 @@ describe("useChat", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
||||||
expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Queued follow-up");
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.pendingMessage).toBe("Queued follow-up");
|
expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -2116,12 +2169,12 @@ describe("useChat", () => {
|
|||||||
expect(mockFetchChatSession).toHaveBeenCalledWith("session-001", "proj-123");
|
expect(mockFetchChatSession).toHaveBeenCalledWith("session-001", "proj-123");
|
||||||
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
||||||
expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Queued follow-up");
|
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" });
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
@@ -2164,7 +2217,7 @@ describe("useChat", () => {
|
|||||||
expect(closeFn).toHaveBeenCalled();
|
expect(closeFn).toHaveBeenCalled();
|
||||||
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
|
||||||
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued follow-up");
|
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued follow-up");
|
||||||
expect(result.current.pendingMessage).toBe("");
|
expect(result.current.pendingMessages).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -2206,8 +2259,8 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.pendingMessage).toBe("Queued follow-up");
|
expect(result.current.pendingMessages).toEqual(["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"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -2216,11 +2269,11 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.activeSession).toBeNull();
|
expect(result.current.activeSession).toBeNull();
|
||||||
expect(result.current.pendingMessage).toBe("");
|
expect(result.current.pendingMessages).toEqual([]);
|
||||||
expect(result.current.isStreaming).toBe(false);
|
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(() => {
|
act(() => {
|
||||||
result.current.selectSession("session-001");
|
result.current.selectSession("session-001");
|
||||||
@@ -2228,7 +2281,7 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.activeSession?.id).toBe("session-001");
|
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);
|
expect(result.current.isStreaming).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2260,7 +2313,7 @@ describe("useChat", () => {
|
|||||||
return { close: vi.fn(), isConnected: () => true };
|
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"));
|
const { result } = renderHook(() => useChat("proj-123"));
|
||||||
|
|
||||||
@@ -2276,12 +2329,12 @@ describe("useChat", () => {
|
|||||||
// reveals the in-flight generation, so the hook attaches instead of
|
// reveals the in-flight generation, so the hook attaches instead of
|
||||||
// flushing.
|
// flushing.
|
||||||
await waitFor(() => {
|
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(result.current.isStreaming).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockStreamChatResponse).not.toHaveBeenCalled();
|
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.
|
// Once the attached generation completes, the queued message flushes.
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -2292,21 +2345,34 @@ describe("useChat", () => {
|
|||||||
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
||||||
expect(mockStreamChatResponse.mock.calls[0]?.[0]).toBe("session-001");
|
expect(mockStreamChatResponse.mock.calls[0]?.[0]).toBe("session-001");
|
||||||
expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Queued follow-up");
|
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();
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a restored queued message un-flushed while the server validation fetch is pending", async () => {
|
it("keeps a restored queued message un-flushed when an attached stream errors but the server is still generating", 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" });
|
const sessionA = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||||
mockFetchChatSessions.mockResolvedValue({ sessions: [sessionA] });
|
mockFetchChatSessions.mockResolvedValue({ sessions: [sessionA] });
|
||||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
// Server check never resolves within the test — simulates in-flight RTT.
|
mockFetchChatSession.mockResolvedValue({
|
||||||
mockFetchChatSession.mockReturnValue(new Promise(() => {}) as never);
|
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"));
|
const { result } = renderHook(() => useChat("proj-123"));
|
||||||
|
|
||||||
@@ -2319,14 +2385,54 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
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(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(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 () => {
|
it("preserves queued messages across session switches and rehydrates them when returning", async () => {
|
||||||
@@ -2363,7 +2469,7 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.pendingMessage).toBe("Queued follow-up");
|
expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -2372,11 +2478,11 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.activeSession?.id).toBe("session-002");
|
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(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(() => {
|
act(() => {
|
||||||
result.current.selectSession("session-001");
|
result.current.selectSession("session-001");
|
||||||
@@ -2384,7 +2490,7 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.activeSession?.id).toBe("session-001");
|
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);
|
expect(result.current.isStreaming).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2408,6 +2514,53 @@ describe("useChat", () => {
|
|||||||
expect(localStorage.getItem("fusion:chat-pending:undefined")).toBeNull();
|
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 () => {
|
it("clearPendingMessage clears pending message and removes persisted queue entry", async () => {
|
||||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||||
@@ -2442,18 +2595,18 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.pendingMessage).toBe("Queued follow-up");
|
expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.clearPendingMessage();
|
result.current.clearPendingMessage();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.pendingMessage).toBe("");
|
expect(result.current.pendingMessages).toEqual([]);
|
||||||
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
|
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 = {
|
const existingSession = {
|
||||||
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
||||||
isGenerating: true,
|
isGenerating: true,
|
||||||
@@ -2489,7 +2642,7 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
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 () => {
|
await act(async () => {
|
||||||
@@ -2503,7 +2656,7 @@ describe("useChat", () => {
|
|||||||
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
|
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 = {
|
const session = {
|
||||||
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
||||||
isGenerating: true,
|
isGenerating: true,
|
||||||
@@ -2537,7 +2690,7 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
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 () => {
|
await act(async () => {
|
||||||
@@ -2547,7 +2700,7 @@ describe("useChat", () => {
|
|||||||
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
|
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 = {
|
const session = {
|
||||||
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
||||||
isGenerating: true,
|
isGenerating: true,
|
||||||
@@ -2581,7 +2734,7 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
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 () => {
|
await act(async () => {
|
||||||
@@ -2595,7 +2748,7 @@ describe("useChat", () => {
|
|||||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
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"));
|
const { result } = renderHook(() => useChat("proj-123"));
|
||||||
|
|
||||||
@@ -2615,7 +2768,7 @@ describe("useChat", () => {
|
|||||||
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
|
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" });
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
@@ -2652,7 +2805,7 @@ describe("useChat", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
|
||||||
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued follow-up");
|
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();
|
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}`;
|
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);
|
const key = getChatPendingMessageKey(sessionId);
|
||||||
if (!key || typeof window === "undefined") {
|
if (!key || typeof window === "undefined") {
|
||||||
return "";
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 {
|
} 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);
|
const key = getChatPendingMessageKey(sessionId);
|
||||||
if (!key || typeof window === "undefined") {
|
if (!key || typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 {
|
} catch {
|
||||||
// Ignore localStorage failures so chat queuing still works in-memory.
|
// 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);
|
const key = getChatPendingMessageKey(sessionId);
|
||||||
if (!key || typeof window === "undefined") {
|
if (!key || typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ export type { ChatMessageInfo, FailureInfo, FallbackInfo, ToolCallInfo } from ".
|
|||||||
import type { ChatMessageInfo, FailureInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
import type { ChatMessageInfo, FailureInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
||||||
import { createChatStreamHandlers } from "./createChatStreamHandlers";
|
import { createChatStreamHandlers } from "./createChatStreamHandlers";
|
||||||
import {
|
import {
|
||||||
getPersistedPendingChatMessage,
|
getPersistedPendingChatMessages,
|
||||||
removePersistedPendingChatMessage,
|
removePersistedPendingChatMessages,
|
||||||
setPersistedPendingChatMessage,
|
setPersistedPendingChatMessages,
|
||||||
} from "./chatPendingMessageStorage";
|
} from "./chatPendingMessageStorage";
|
||||||
import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "./visibilitySuspension";
|
import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "./visibilitySuspension";
|
||||||
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache";
|
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache";
|
||||||
@@ -69,7 +69,7 @@ export interface UseChatReturn {
|
|||||||
streamingText: string;
|
streamingText: string;
|
||||||
streamingThinking: string;
|
streamingThinking: string;
|
||||||
streamingToolCalls: ToolCallInfo[];
|
streamingToolCalls: ToolCallInfo[];
|
||||||
pendingMessage: string;
|
pendingMessages: string[];
|
||||||
|
|
||||||
// Session operations
|
// Session operations
|
||||||
selectSession: (id: string, sessionOverride?: ChatSessionInfo) => void;
|
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. */
|
/** Send a message, optionally with file attachments to upload with the prompt. */
|
||||||
sendMessage: (content: string, attachments?: File[]) => void;
|
sendMessage: (content: string, attachments?: File[]) => void;
|
||||||
stopStreaming: () => void;
|
stopStreaming: () => void;
|
||||||
clearPendingMessage: () => void;
|
clearPendingMessage: (index?: number) => void;
|
||||||
loadMoreMessages: () => Promise<void>;
|
loadMoreMessages: () => Promise<void>;
|
||||||
hasMoreMessages: boolean;
|
hasMoreMessages: boolean;
|
||||||
|
|
||||||
@@ -294,7 +294,7 @@ export function useChat(
|
|||||||
const [streamingText, setStreamingText] = useState("");
|
const [streamingText, setStreamingText] = useState("");
|
||||||
const [streamingThinking, setStreamingThinking] = useState("");
|
const [streamingThinking, setStreamingThinking] = useState("");
|
||||||
const [streamingToolCalls, setStreamingToolCalls] = useState<ToolCallInfo[]>([]);
|
const [streamingToolCalls, setStreamingToolCalls] = useState<ToolCallInfo[]>([]);
|
||||||
const [pendingMessage, setPendingMessage] = useState("");
|
const [pendingMessages, setPendingMessages] = useState<string[]>([]);
|
||||||
|
|
||||||
// Search/filter
|
// Search/filter
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
@@ -309,7 +309,12 @@ export function useChat(
|
|||||||
const streamRef = useRef<{ close: () => void } | null>(null);
|
const streamRef = useRef<{ close: () => void } | null>(null);
|
||||||
const lastAttachedGenerationRef = useRef<{ sessionId: string; replayFromEventId: number | null } | null>(null);
|
const lastAttachedGenerationRef = useRef<{ sessionId: string; replayFromEventId: number | null } | null>(null);
|
||||||
const cancelledByUserRef = useRef(false);
|
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.
|
// Cancel any pending requestAnimationFrame flushes from the active stream.
|
||||||
// Set when sendMessage starts, cleared on done/error. Called from stopStreaming
|
// 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.
|
// so a clear-then-rAF-fires sequence doesn't flash stale text back in.
|
||||||
@@ -326,8 +331,8 @@ export function useChat(
|
|||||||
isStreamingRef.current = isStreaming;
|
isStreamingRef.current = isStreaming;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pendingMessageRef.current = pendingMessage;
|
pendingMessagesRef.current = pendingMessages;
|
||||||
}, [pendingMessage]);
|
}, [pendingMessages]);
|
||||||
|
|
||||||
// Tracks message IDs that were added via streaming completion.
|
// Tracks message IDs that were added via streaming completion.
|
||||||
// Used to prevent duplicate messages when SSE event arrives before streaming state clears.
|
// Used to prevent duplicate messages when SSE event arrives before streaming state clears.
|
||||||
@@ -503,32 +508,71 @@ export function useChat(
|
|||||||
const resetTransientComposerState = useCallback(() => {
|
const resetTransientComposerState = useCallback(() => {
|
||||||
cancelStreamingFlushesRef.current?.();
|
cancelStreamingFlushesRef.current?.();
|
||||||
cancelStreamingFlushesRef.current = null;
|
cancelStreamingFlushesRef.current = null;
|
||||||
pendingMessageRef.current = "";
|
pendingMessagesRef.current = [];
|
||||||
setPendingMessage("");
|
setPendingMessages([]);
|
||||||
setStreamingText("");
|
setStreamingText("");
|
||||||
setStreamingThinking("");
|
setStreamingThinking("");
|
||||||
setStreamingToolCalls([]);
|
setStreamingToolCalls([]);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearPendingMessage = useCallback(() => {
|
const clearPendingMessage = useCallback((index?: number) => {
|
||||||
removePersistedPendingChatMessage(activeSessionRef.current?.id);
|
const sessionId = activeSessionRef.current?.id;
|
||||||
pendingMessageRef.current = "";
|
if (typeof index === "number") {
|
||||||
setPendingMessage("");
|
const nextMessages = pendingMessagesRef.current.filter((_, messageIndex) => messageIndex !== index);
|
||||||
}, []);
|
pendingMessagesRef.current = nextMessages;
|
||||||
|
setPendingMessages(nextMessages);
|
||||||
const flushPendingMessage = useCallback(() => {
|
setPersistedPendingChatMessages(sessionId, nextMessages);
|
||||||
const queuedMessage = pendingMessageRef.current.trim();
|
|
||||||
if (!queuedMessage) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
removePersistedPendingChatMessage(activeSessionRef.current?.id);
|
removePersistedPendingChatMessages(sessionId);
|
||||||
pendingMessageRef.current = "";
|
pendingMessagesRef.current = [];
|
||||||
setPendingMessage("");
|
setPendingMessages([]);
|
||||||
sendMessageRef.current(queuedMessage);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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((
|
const attachIfGenerating = useCallback((
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
inFlightGeneration?: ChatInFlightGenerationState | null,
|
inFlightGeneration?: ChatInFlightGenerationState | null,
|
||||||
@@ -609,7 +653,7 @@ export function useChat(
|
|||||||
addToast?.(failureInfo.summary, "error");
|
addToast?.(failureInfo.summary, "error");
|
||||||
}
|
}
|
||||||
void loadMessages(sessionId);
|
void loadMessages(sessionId);
|
||||||
flushPendingMessage();
|
void flushPendingMessageAfterAttachedError(sessionId, { silent: options?.silent });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -628,7 +672,8 @@ export function useChat(
|
|||||||
});
|
});
|
||||||
streamRef.current = stream;
|
streamRef.current = stream;
|
||||||
return true;
|
return true;
|
||||||
}, [addToast, hydrateMessagesFromCache, loadMessages, projectId, flushPendingMessage]);
|
}, [addToast, flushPendingMessage, flushPendingMessageAfterAttachedError, hydrateMessagesFromCache, loadMessages, projectId]);
|
||||||
|
attachIfGeneratingRef.current = attachIfGenerating;
|
||||||
|
|
||||||
// Select a session
|
// Select a session
|
||||||
const selectSession = useCallback(
|
const selectSession = useCallback(
|
||||||
@@ -710,13 +755,17 @@ export function useChat(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoredPendingMessage = getPersistedPendingChatMessage(sessionId);
|
const restoredPendingMessages = getPersistedPendingChatMessages(sessionId);
|
||||||
if (!restoredPendingMessage) {
|
if (restoredPendingMessages.length === 0) {
|
||||||
return;
|
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
|
// Flush only once the server confirms no generation is in flight. The
|
||||||
// local sessions list can hold a stale falsy `isGenerating` (it is a
|
// local sessions list can hold a stale falsy `isGenerating` (it is a
|
||||||
@@ -729,7 +778,7 @@ export function useChat(
|
|||||||
if (
|
if (
|
||||||
cancelled ||
|
cancelled ||
|
||||||
activeSessionRef.current?.id !== sessionId ||
|
activeSessionRef.current?.id !== sessionId ||
|
||||||
pendingMessageRef.current.trim().length === 0
|
pendingMessagesRef.current.length === 0
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -784,7 +833,7 @@ export function useChat(
|
|||||||
return [newSession, ...prev];
|
return [newSession, ...prev];
|
||||||
});
|
});
|
||||||
|
|
||||||
removePersistedPendingChatMessage(previousSessionId);
|
removePersistedPendingChatMessages(previousSessionId);
|
||||||
resetTransientComposerState();
|
resetTransientComposerState();
|
||||||
selectSession(newSession.id, newSession);
|
selectSession(newSession.id, newSession);
|
||||||
|
|
||||||
@@ -796,7 +845,7 @@ export function useChat(
|
|||||||
// Archive a session
|
// Archive a session
|
||||||
const archiveSession = useCallback(
|
const archiveSession = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
removePersistedPendingChatMessage(id);
|
removePersistedPendingChatMessages(id);
|
||||||
await updateChatSession(id, { status: "archived" }, projectId);
|
await updateChatSession(id, { status: "archived" }, projectId);
|
||||||
// Remove from sessions list
|
// Remove from sessions list
|
||||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||||
@@ -859,7 +908,7 @@ export function useChat(
|
|||||||
// Delete a session
|
// Delete a session
|
||||||
const deleteSession = useCallback(
|
const deleteSession = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
removePersistedPendingChatMessage(id);
|
removePersistedPendingChatMessages(id);
|
||||||
// Close stream if active
|
// Close stream if active
|
||||||
if (activeSession?.id === id && streamRef.current) {
|
if (activeSession?.id === id && streamRef.current) {
|
||||||
streamRef.current.close();
|
streamRef.current.close();
|
||||||
@@ -971,9 +1020,14 @@ export function useChat(
|
|||||||
if (!activeSession) return;
|
if (!activeSession) return;
|
||||||
|
|
||||||
if (isStreamingRef.current) {
|
if (isStreamingRef.current) {
|
||||||
pendingMessageRef.current = content;
|
const trimmedContent = content.trim();
|
||||||
setPendingMessage(content);
|
if (!trimmedContent) {
|
||||||
setPersistedPendingChatMessage(activeSession.id, content);
|
return;
|
||||||
|
}
|
||||||
|
const nextMessages = [...pendingMessagesRef.current, trimmedContent];
|
||||||
|
pendingMessagesRef.current = nextMessages;
|
||||||
|
setPendingMessages(nextMessages);
|
||||||
|
setPersistedPendingChatMessages(activeSession.id, nextMessages);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1002,6 +1056,7 @@ export function useChat(
|
|||||||
setStreamingThinking("");
|
setStreamingThinking("");
|
||||||
setStreamingToolCalls([]);
|
setStreamingToolCalls([]);
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
|
isStreamingRef.current = true;
|
||||||
|
|
||||||
const { handlers } = createChatStreamHandlers({
|
const { handlers } = createChatStreamHandlers({
|
||||||
sessionId: activeSession.id,
|
sessionId: activeSession.id,
|
||||||
@@ -1375,7 +1430,7 @@ export function useChat(
|
|||||||
streamingText,
|
streamingText,
|
||||||
streamingThinking,
|
streamingThinking,
|
||||||
streamingToolCalls,
|
streamingToolCalls,
|
||||||
pendingMessage,
|
pendingMessages,
|
||||||
selectSession,
|
selectSession,
|
||||||
createSession,
|
createSession,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
|
|||||||
Reference in New Issue
Block a user