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:
gsxdsm
2026-05-29 21:56:53 -07:00
parent 4a60c2ac41
commit b78736ce58
2 changed files with 69 additions and 2 deletions

View File

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

View File

@@ -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 () => {