From aed44f5f2c1456b3d9a8e3fcfedcfaf849c839f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:26:36 +0000 Subject: [PATCH 1/3] Initial plan From 8630a4bd31534a76c51b916b0703ae8a56cd2c20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:32:19 +0000 Subject: [PATCH 2/3] Fix: Check actual storage state instead of stale React state The bug was in `sendMessage` function which checked React state to see if a thread was streaming, but React state could be stale after a message completed. This caused rapid follow-up messages to be blocked. Fixed by: - Query threadStore.getById() directly instead of using threads from React state - Remove threads from useCallback dependency array (no longer needed) Co-authored-by: chrisreddington <791642+chrisreddington@users.noreply.github.com> --- src/hooks/use-learning-chat.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/hooks/use-learning-chat.ts b/src/hooks/use-learning-chat.ts index b5b7a7d..91f3dcd 100644 --- a/src/hooks/use-learning-chat.ts +++ b/src/hooks/use-learning-chat.ts @@ -460,8 +460,11 @@ export function useLearningChat(): UseLearningChatReturn { } } - // Check if THIS thread is already streaming (from storage) - const threadFromStorage = threads.find(t => t.id === targetThreadId); + // Check if THIS thread is already streaming (query storage directly to avoid race condition) + // We must check the actual storage, not React state, because the state may not have + // refreshed yet after the previous message completed. This was causing follow-up messages + // to be blocked when sent quickly after the first message. + const threadFromStorage = await threadStore.getById(targetThreadId); if (threadFromStorage?.isStreaming) { log.warn(`Thread ${targetThreadId} is already streaming`); return; @@ -537,7 +540,6 @@ export function useLearningChat(): UseLearningChatReturn { createThread, updateActiveThread, refreshThreads, - threads, ] ); From 79633682878031a9a40829bceb5f50e1118efbb0 Mon Sep 17 00:00:00 2001 From: Chris Reddington <791642+chrisreddington@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:56:34 +0000 Subject: [PATCH 3/3] Fix: Track expected message count to properly poll for multi-turn responses The previous fix to check storage directly still had a race condition for multi-turn conversations. The cleanup effect that removes threads from the pending list was incorrectly triggered when a thread already had assistant messages from previous turns. Root cause: The old logic checked for ANY completed assistant message, but for multi-turn chat, the thread already has completed responses from earlier turns. When sending a follow-up, the cleanup effect would immediately remove the thread from pending (since hasCompletedResponse was true), stopping the polling before the new response arrived. Fix: Track the expected message count when starting a job: - Store Map instead of Set - Only remove from pending once thread.messages.length >= expected count - This correctly handles multi-turn: after each message, we expect current count + 2 (user + assistant) Verified with Playwright: Can now send multiple follow-up messages and each receives a proper AI response. --- package-lock.json | 33 +++++++++++++--- src/hooks/use-learning-chat.ts | 70 ++++++++++++++++++---------------- 2 files changed, 65 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6877839..7c30a31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -139,6 +139,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -497,6 +498,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -540,6 +542,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2259,6 +2262,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -2845,7 +2849,8 @@ "version": "11.3.2", "resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-11.3.2.tgz", "integrity": "sha512-/8EDh3MmF9cbmrLETFmIuNFIdvpSCkvBlx6zzD8AZ4dZ5UYExQzFj8QAtIrRtCFJ2ZmW5QrtrPR3+JVb8KEDpg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@primer/react": { "version": "38.7.1", @@ -3562,6 +3567,7 @@ "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3578,6 +3584,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3588,6 +3595,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3597,8 +3605,7 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/unist": { "version": "3.0.3", @@ -3651,6 +3658,7 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -4305,6 +4313,7 @@ "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", @@ -4458,6 +4467,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4966,6 +4976,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5934,7 +5945,6 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -6282,6 +6292,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6467,6 +6478,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8353,6 +8365,7 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -8383,6 +8396,7 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -8833,7 +8847,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -10515,6 +10528,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -10619,6 +10633,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10752,6 +10767,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10770,6 +10786,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -12072,6 +12089,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12404,6 +12422,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12678,6 +12697,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12771,6 +12791,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12813,6 +12834,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -13244,6 +13266,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/hooks/use-learning-chat.ts b/src/hooks/use-learning-chat.ts index 91f3dcd..d6958a9 100644 --- a/src/hooks/use-learning-chat.ts +++ b/src/hooks/use-learning-chat.ts @@ -190,9 +190,15 @@ export function useLearningChat(): UseLearningChatReturn { const streamingThreadId = streamingThreadIds[streamingThreadIds.length - 1] ?? null; // Track threads where we've just started a job (before storage reflects isStreaming) - const [pendingStreamThreadIds, setPendingStreamThreadIds] = useState>(new Set()); + // Map of threadId -> expected message count after response completes + const [pendingStreamThreads, setPendingStreamThreads] = useState>(new Map()); // Combine storage-derived streaming IDs with pending ones + const pendingStreamThreadIds = useMemo(() => + Array.from(pendingStreamThreads.keys()), + [pendingStreamThreads] + ); + const allStreamingThreadIds = useMemo(() => { const combined = new Set([...streamingThreadIds, ...pendingStreamThreadIds]); return Array.from(combined); @@ -200,7 +206,7 @@ export function useLearningChat(): UseLearningChatReturn { // Check if active thread is streaming (from storage OR pending) const isStreaming = activeThread?.isStreaming === true || - (activeThreadId ? pendingStreamThreadIds.has(activeThreadId) : false); + (activeThreadId ? pendingStreamThreads.has(activeThreadId) : false); // Streaming content comes from the thread messages in storage // The streaming message has cursor ` ▊` which gives the typing effect @@ -227,37 +233,31 @@ export function useLearningChat(): UseLearningChatReturn { }; }, [allStreamingThreadIds.length, refreshThreads]); - // Clean up pending stream IDs once storage reflects the streaming state + // Clean up pending stream threads once storage reflects the completed response useEffect(() => { - if (pendingStreamThreadIds.size === 0) return; + if (pendingStreamThreads.size === 0) return; - // Check if any pending threads now have isStreaming in storage - const stillPending = new Set(); - for (const threadId of pendingStreamThreadIds) { + // Check if any pending threads now have the expected number of messages + const stillPending = new Map(); + for (const [threadId, expectedMessageCount] of pendingStreamThreads) { const thread = threads.find(t => t.id === threadId); - // Keep as pending if thread not found OR doesn't have isStreaming yet - // AND doesn't have a completed message (no cursor) if (!thread) { - stillPending.add(threadId); + // Thread not found yet - keep pending + stillPending.set(threadId, expectedMessageCount); } else if (thread.isStreaming) { - // Storage has caught up - no longer pending - } else { - // Check if there's a streaming message or completed assistant message - const hasStreamingMsg = thread.messages.some(m => m.id.startsWith('streaming-')); - const hasCompletedResponse = thread.messages.some(m => - m.role === 'assistant' && !m.id.startsWith('streaming-') - ); - if (!hasStreamingMsg && !hasCompletedResponse) { - // Still waiting for job to write first content - stillPending.add(threadId); - } + // Still streaming - keep pending (storage has caught up, streaming in progress) + stillPending.set(threadId, expectedMessageCount); + } else if (thread.messages.length < expectedMessageCount) { + // Haven't received the response yet - keep pending + stillPending.set(threadId, expectedMessageCount); } + // Otherwise: thread has expected messages and is not streaming - remove from pending } - if (stillPending.size !== pendingStreamThreadIds.size) { - setPendingStreamThreadIds(stillPending); + if (stillPending.size !== pendingStreamThreads.size) { + setPendingStreamThreads(stillPending); } - }, [threads, pendingStreamThreadIds]); + }, [threads, pendingStreamThreads]); useEffect(() => { if (isThreadsLoading) return; @@ -331,8 +331,8 @@ export function useLearningChat(): UseLearningChatReturn { log.debug('Stopping stream for thread:', threadId); // Remove from pending (stops showing as streaming immediately) - setPendingStreamThreadIds(prev => { - const next = new Set(prev); + setPendingStreamThreads(prev => { + const next = new Map(prev); next.delete(threadId); return next; }); @@ -485,13 +485,17 @@ export function useLearningChat(): UseLearningChatReturn { messages: [...thread.messages, userMessage], }, targetThreadId); + // Calculate expected message count: current messages + user message + assistant response + // Current thread messages count + 1 (user) + 1 (assistant) = expected total + const expectedMessageCount = thread.messages.length + 2; + // Start background job via POST /api/jobs // The job writes to storage, and our polling effect refreshes the UI - log.debug('Starting background job for chat response', { threadId: targetThreadId }); + log.debug('Starting background job for chat response', { threadId: targetThreadId, expectedMessageCount }); - // Mark this thread as pending streaming IMMEDIATELY + // Mark this thread as pending streaming with expected message count // This triggers polling before storage has isStreaming: true - setPendingStreamThreadIds(prev => new Set([...prev, targetThreadId])); + setPendingStreamThreads(prev => new Map(prev).set(targetThreadId, expectedMessageCount)); try { const jobRes = await fetch('/api/jobs', { @@ -512,8 +516,8 @@ export function useLearningChat(): UseLearningChatReturn { if (!jobRes.ok) { const err = await jobRes.json(); // Remove from pending on error - setPendingStreamThreadIds(prev => { - const next = new Set(prev); + setPendingStreamThreads(prev => { + const next = new Map(prev); next.delete(targetThreadId); return next; }); @@ -528,8 +532,8 @@ export function useLearningChat(): UseLearningChatReturn { } catch (err) { log.error('Failed to start chat response job:', err); // Remove from pending on error - setPendingStreamThreadIds(prev => { - const next = new Set(prev); + setPendingStreamThreads(prev => { + const next = new Map(prev); next.delete(targetThreadId); return next; });