fix(chat): add delivery watchdog for stranded quick chat messages

Follow-up to the quick chat send fix. The send-time stale-stream recovery
only fires when a message is queued while the streaming flag is already
stuck, and the pre-session flush effect fires once on session activation and
bails permanently if a stream ref is lingering. Either gap leaves a queued
message stranded idle in the composer — shown locally but never sent to the
agent or persisted (so also absent from regular chat).

Add a delivery watchdog: whenever a message stays pending under an active
session, re-confirm after a short delay and force-deliver it if the server
reports no generation in flight and no live stream is connected. This is a
catch-all backstop independent of which targeted flush trigger bailed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-14 00:06:34 -07:00
parent f54100b67e
commit 8cf32d5b2b
3 changed files with 105 additions and 1 deletions

View File

@@ -2,4 +2,4 @@
"@fusion/dashboard": patch
---
Fix two mobile chat send failures. The regular chat send button was dead to touch because the action only ran on `onClick`, which iOS suppresses after `preventDefault()` in the touch sequence — it now fires from pointerdown/touchstart with a dedupe latch. Quick chat messages could strand in the composer (shown locally but never sent to the agent or persisted) when a dropped stream left the streaming flag stuck `true`; a queued send now detects the stale flag via the stream's connection state and the server's generation status, then recovers and flushes.
Fix two mobile chat send failures. The regular chat send button was dead to touch because the action only ran on `onClick`, which iOS suppresses after `preventDefault()` in the touch sequence — it now fires from pointerdown/touchstart with a dedupe latch. Quick chat messages could strand in the composer (shown locally but never sent to the agent or persisted) when a queued message's delivery trigger bailed — a dropped stream leaving the streaming flag stuck `true`, or a stream that looked healthy when queued but then stalled. A queued send now detects a stale flag at send time via the stream's connection state and the server's generation status, and a delivery watchdog re-confirms any message that stays pending and force-delivers it once no generation is actually in flight.

View File

@@ -195,6 +195,54 @@ describe("useQuickChat", () => {
expect(result.current.pendingMessage).toBe("");
});
it("delivers a queued message via the watchdog when a stream stalls after the send was queued", async () => {
vi.useFakeTimers();
try {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchResumeChatSession.mockResolvedValue({ session });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
// Server confirms nothing is generating: the stream died after we queued.
mockFetchChatSession.mockResolvedValue({
session: { ...session, isGenerating: false },
});
const { result } = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await result.current.switchSession("agent-001");
});
// First send attaches a stream that is connected at send time but never
// completes (onDone/onError never fire).
let connected = true;
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => connected });
await act(async () => {
void result.current.sendMessage("First");
});
// Second send while streaming: queued. The send-time recovery sees the
// stream still connected, so it correctly leaves the message queued to be
// flushed by the stream's onDone — which never arrives.
await act(async () => {
void result.current.sendMessage("Second");
});
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
// The stream goes dead. The watchdog re-confirms after its delay and, since
// the server reports no generation in flight, delivers the queued message.
connected = false;
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Second");
expect(result.current.pendingMessage).toBe("");
} finally {
vi.useRealTimers();
}
});
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

@@ -190,6 +190,12 @@ function mapChatMessageToInfo(message: ChatMessage): ChatMessageInfo {
};
}
// Backstop delay before a still-pending queued message is re-confirmed and
// force-delivered. Long enough to let the targeted flush paths (pre-session
// activation, stream completion) deliver first; short enough that a stranded
// message reaches the agent quickly.
const QUEUED_MESSAGE_DELIVERY_WATCHDOG_MS = 1500;
/**
* Hook for the QuickChatFAB component.
* Provides chat session management and SSE streaming for real-time AI responses.
@@ -1116,6 +1122,56 @@ export function useQuickChat(
flushPendingMessage();
}, [activeSession, flushPendingMessage]);
// Delivery backstop for queued messages. The targeted flush triggers
// (pre-session activation, stream onDone/onError, send-time stale recovery)
// each fire once on a specific transition. If the relevant one bails — a
// lingering stream ref at session activation, or a stream that looked healthy
// when we chose to wait for its onDone but then died without firing it — the
// queued message strands in the composer forever: shown locally but never
// sent to the agent or persisted (so also absent from regular chat). Whenever
// a message stays pending under an active session, re-confirm after a short
// delay and deliver it if no generation is actually in flight server-side.
useEffect(() => {
const sessionId = activeSession?.id;
if (!sessionId || pendingMessage.trim().length === 0) {
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
if (cancelled || pendingMessageRef.current.trim().length === 0) {
return;
}
void fetchChatSession(sessionId, projectId)
.then(({ session: refreshed }) => {
if (
cancelled ||
// A real generation is in flight: its stream will flush the queue.
refreshed.isGenerating ||
// A live stream reconnected while we waited: defer to it.
streamRef.current?.isConnected() ||
activeSessionRef.current?.id !== sessionId ||
pendingMessageRef.current.trim().length === 0
) {
return;
}
if (streamRef.current) {
streamRef.current.close();
streamRef.current = null;
}
setIsStreaming(false);
isStreamingRef.current = false;
flushPendingMessage();
})
.catch(() => {
// Keep the queued message; a later trigger can still deliver it.
});
}, QUEUED_MESSAGE_DELIVERY_WATCHDOG_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [activeSession?.id, pendingMessage, projectId, flushPendingMessage]);
// Cleanup on unmount
useEffect(() => {
return () => {