FN-5709: queue initial quick chat send until session is ready
Ensure a user's first quick-chat message is preserved and sent once session initialization finishes. - queue pre-session text sends as promise-backed pending work instead of returning early - flush queued text after session activation and wire completion/rejection to the original send promise - clear queued completion state on stream reset paths and add regression coverage for first-send-before-init flow Files changed: .../app/hooks/__tests__/useQuickChat.test.ts | 34 ++++++++++++++++++++ packages/dashboard/app/hooks/useQuickChat.ts | 37 ++++++++++++++++++++-- 2 files changed, 69 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-5709 Fusion-Task-Lineage: 7598aa93-d127-4602-9571-02bc2a0cc5da
This commit is contained in:
@@ -68,6 +68,40 @@ describe("useQuickChat", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("queues first send made before session init completes and streams once ready", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchResumeChatSession.mockResolvedValue({ session });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
|
||||
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||
|
||||
let onDone: ((data: { messageId: string }) => void) | undefined;
|
||||
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||
onDone = handlers.onDone as typeof onDone;
|
||||
return { close: vi.fn(), isConnected: () => true };
|
||||
});
|
||||
|
||||
const initPromise = act(async () => {
|
||||
await result.current.switchSession("agent-001");
|
||||
});
|
||||
|
||||
const firstSend = result.current.sendMessage("Hello");
|
||||
|
||||
await initPromise;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
||||
expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Hello");
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
onDone?.({ messageId: "msg-001" });
|
||||
});
|
||||
|
||||
await expect(firstSend).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("sendMessage returns a promise that resolves on stream completion", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchResumeChatSession.mockResolvedValue({ session });
|
||||
|
||||
@@ -214,6 +214,7 @@ export function useQuickChat(
|
||||
const isStreamingRef = useRef(isStreaming);
|
||||
isStreamingRef.current = isStreaming;
|
||||
const sendCompletionRef = useRef<{ resolve: () => void; reject: (error?: unknown) => void } | null>(null);
|
||||
const queuedPreSessionCompletionRef = useRef<{ resolve: () => void; reject: (error?: unknown) => void } | null>(null);
|
||||
|
||||
// Track the current selected chat target for session management
|
||||
const currentSessionKeyRef = useRef<string>("");
|
||||
@@ -287,7 +288,12 @@ export function useQuickChat(
|
||||
|
||||
pendingMessageRef.current = "";
|
||||
setPendingMessage("");
|
||||
void sendMessageRef.current(queuedMessage);
|
||||
const queuedCompletion = queuedPreSessionCompletionRef.current;
|
||||
queuedPreSessionCompletionRef.current = null;
|
||||
const sendPromise = sendMessageRef.current(queuedMessage);
|
||||
if (queuedCompletion) {
|
||||
void sendPromise.then(queuedCompletion.resolve).catch(queuedCompletion.reject);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const attachIfGenerating = useCallback((
|
||||
@@ -510,6 +516,8 @@ export function useQuickChat(
|
||||
cancelStreamingFlushesRef.current = null;
|
||||
pendingMessageRef.current = "";
|
||||
setPendingMessage("");
|
||||
queuedPreSessionCompletionRef.current?.resolve();
|
||||
queuedPreSessionCompletionRef.current = null;
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
@@ -721,10 +729,23 @@ export function useQuickChat(
|
||||
*/
|
||||
const sendMessage = useCallback(
|
||||
(content: string, attachments?: File[]) => {
|
||||
if (!activeSession || (!content.trim() && (!attachments || attachments.length === 0))) {
|
||||
if (!content.trim() && (!attachments || attachments.length === 0)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (!activeSession) {
|
||||
if (attachments && attachments.length > 0) {
|
||||
return Promise.reject(new Error("Cannot send attachments before chat session is ready"));
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
queuedPreSessionCompletionRef.current?.resolve();
|
||||
queuedPreSessionCompletionRef.current = { resolve, reject };
|
||||
pendingMessageRef.current = content;
|
||||
setPendingMessage(content);
|
||||
});
|
||||
}
|
||||
|
||||
if (isStreamingRef.current) {
|
||||
if (attachments && attachments.length > 0) {
|
||||
return Promise.reject(new Error("Cannot send attachments while a response is streaming"));
|
||||
@@ -926,6 +947,18 @@ export function useQuickChat(
|
||||
return unsubscribe;
|
||||
}, [attachIfGenerating, projectId, reloadMessages, visibilitySuspension, flushPendingMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession || isStreamingRef.current || streamRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingMessageRef.current.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
flushPendingMessage();
|
||||
}, [activeSession, flushPendingMessage]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
Reference in New Issue
Block a user