From 7ddea000e10ffedc13fb347e14f1300fcde01859 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 5 Feb 2026 00:11:30 -0500 Subject: [PATCH 1/3] Add SSE reconnection before critical actions and handle Cloudflare 524 errors --- frontend/src/contexts/EventContext.tsx | 17 ++++++++- frontend/src/hooks/useOpenCode.ts | 14 ++++++++ frontend/src/lib/sseManager.ts | 49 ++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index 6707aa1c..6b2816f9 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -6,7 +6,7 @@ import { OpenCodeClient } from '@/api/opencode' import { listRepos } from '@/api/repos' import type { PermissionRequest, PermissionResponse, QuestionRequest, SSEEvent } from '@/api/types' import { showToast } from '@/lib/toast' -import { subscribeToSSE, addSSEDirectory } from '@/lib/sseManager' +import { subscribeToSSE, addSSEDirectory, ensureSSEConnected } from '@/lib/sseManager' import { OPENCODE_API_ENDPOINT } from '@/config' import { addToSessionKeyedState, removeFromSessionKeyedState } from '@/lib/sessionKeyedState' @@ -156,6 +156,11 @@ export function EventProvider({ children }: { children: React.ReactNode }) { }, [allPermissions.length, showPermissionDialog]) const respondToPermission = useCallback(async (permissionID: string, sessionID: string, response: PermissionResponse) => { + const connected = await ensureSSEConnected() + if (!connected) { + showToast.error('Unable to connect. Please try again.') + throw new Error('SSE connection failed') + } const client = getClient(sessionID) if (!client) throw new Error('No client found for session') await client.respondToPermission(sessionID, permissionID, response) @@ -163,6 +168,11 @@ export function EventProvider({ children }: { children: React.ReactNode }) { }, [getClient, removePermission]) const replyToQuestion = useCallback(async (requestID: string, answers: string[][]) => { + const connected = await ensureSSEConnected() + if (!connected) { + showToast.error('Unable to connect. Please try again.') + throw new Error('SSE connection failed') + } const question = Object.values(questionsBySession).flat().find(q => q.id === requestID) if (!question) throw new Error('Question not found') const client = getClient(question.sessionID) @@ -172,6 +182,11 @@ export function EventProvider({ children }: { children: React.ReactNode }) { }, [getClient, removeQuestion, questionsBySession]) const rejectQuestion = useCallback(async (requestID: string) => { + const connected = await ensureSSEConnected() + if (!connected) { + showToast.error('Unable to connect. Please try again.') + throw new Error('SSE connection failed') + } const question = Object.values(questionsBySession).flat().find(q => q.id === requestID) if (!question) throw new Error('Question not found') const client = getClient(question.sessionID) diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index 2b989e56..10bb5284 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -11,6 +11,7 @@ import type { paths, components } from "../api/opencode-types"; import { parseNetworkError } from "../lib/opencode-errors"; import { showToast } from "../lib/toast"; import { useSessionStatus } from "../stores/sessionStatusStore"; +import { ensureSSEConnected, reconnectSSE } from "../lib/sseManager"; const titleGeneratingSessionsState = new Set(); const titleGeneratingListeners = new Set<() => void>(); @@ -267,6 +268,12 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?: }) => { if (!client) throw new Error("No client available"); + const connected = await ensureSSEConnected(); + if (!connected) { + showToast.error("Unable to connect. Please try again."); + throw new Error("SSE connection failed"); + } + setSessionStatus(sessionID, { type: "busy" }); const optimisticUserID = `optimistic_user_${Date.now()}_${Math.random()}`; @@ -344,6 +351,13 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?: if (isNetworkError) { return; } + + const fetchError = error as { statusCode?: number }; + const isCloudflareTimeout = fetchError.statusCode === 524; + if (isCloudflareTimeout) { + reconnectSSE(); + return; + } setSessionStatus(sessionID, { type: "idle" }); queryClient.setQueryData( diff --git a/frontend/src/lib/sseManager.ts b/frontend/src/lib/sseManager.ts index 59007af0..fe86427e 100644 --- a/frontend/src/lib/sseManager.ts +++ b/frontend/src/lib/sseManager.ts @@ -265,6 +265,51 @@ class SSEManager { getConnectionStatus(): boolean { return this.isConnected } + + async ensureConnected(timeoutMs: number = 5000): Promise { + if (this.isConnected && this.clientId) { + return true + } + + if (this.subscribers.size === 0) { + return false + } + + this.reconnectDelay = RECONNECT_DELAY_MS + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null + } + + if (this.eventSource) { + this.eventSource.close() + this.eventSource = null + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(false) + }, timeoutMs) + + const checkConnection = () => { + if (this.isConnected && this.clientId) { + clearTimeout(timeout) + resolve(true) + } + } + + const originalNotify = this.notifyStatusChange.bind(this) + this.notifyStatusChange = (connected: boolean) => { + originalNotify(connected) + if (connected) { + this.notifyStatusChange = originalNotify + checkConnection() + } + } + + this.connect() + }) + } } export const sseManager = SSEManager.getInstance() @@ -291,3 +336,7 @@ export function reconnectSSE(): void { export function isSSEConnected(): boolean { return sseManager.getConnectionStatus() } + +export async function ensureSSEConnected(timeoutMs?: number): Promise { + return sseManager.ensureConnected(timeoutMs) +} From ff0698a7a9001393eb1a65d2304db79bf6d8bf2d Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:42:32 -0500 Subject: [PATCH 2/3] refactor: improve responsive layout in QuestionPrompt component --- .../src/components/message/PromptInput.tsx | 2 +- .../src/components/session/QuestionPrompt.tsx | 90 +++++++++---------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index e2d44cc2..680fe783 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -762,7 +762,7 @@ return ( )} - + {isConfirmStep ? 'Review' : ( totalSteps > 1 ? `Question ${currentIndex + 1}/${questions.length}` @@ -203,22 +203,22 @@ export function QuestionPrompt({ question, onReply, onReject }: QuestionPromptPr )} -
+
{isConfirmStep ? ( {totalSteps > 1 && ( -
+
{Array.from({ length: totalSteps }).map((_, i) => ( @@ -280,10 +279,10 @@ export function QuestionPrompt({ question, onReply, onReject }: QuestionPromptPr size="sm" onClick={handleSubmit} disabled={isSubmitting || !allQuestionsAnswered} - className="flex-1 h-10 bg-blue-600 hover:bg-blue-700 text-white" + className="flex-1 h-8 sm:h-10 text-xs sm:text-sm bg-emerald-600 hover:bg-emerald-700 text-white" > {isSubmitting ? ( - + ) : ( 'Submit' )} @@ -293,10 +292,11 @@ export function QuestionPrompt({ question, onReply, onReject }: QuestionPromptPr size="sm" onClick={handleNext} disabled={currentIndex === totalSteps - 1 || (expandedOther === currentIndex && !customInputs[currentIndex]?.trim())} - className="flex-1 h-10 bg-blue-600 hover:bg-blue-700 text-white" + className="flex-1 h-8 sm:h-10 text-xs sm:text-sm bg-emerald-600 hover:bg-emerald-700 text-white" > - {expandedOther === currentIndex ? 'Confirm' : (currentIndex === questions.length - 1 ? 'Review' : 'Next')} - + {expandedOther === currentIndex ? 'Confirm' : (currentIndex === questions.length - 1 ? 'Review' : 'Next')} + {expandedOther === currentIndex ? 'OK' : (currentIndex === questions.length - 1 ? 'Review' : '→')} + ) )} @@ -346,15 +346,15 @@ function QuestionStep({ const isCustomSelected = confirmedCustom && answers.includes(confirmedCustom) return ( -
-

+

+

{question.question} {isMultiSelect && ( (select all that apply) )}

-
+
{question.options.map((option, i) => { const isSelected = answers.includes(option.label) return ( @@ -362,24 +362,24 @@ function QuestionStep({ key={i} onClick={() => onSelectOption(option.label)} className={cn( - "w-full text-left p-3 rounded-lg border-2 transition-all duration-200 active:scale-[0.98]", + "w-full text-left p-2 sm:p-3 rounded-lg border-2 transition-all duration-200 active:scale-[0.98]", isSelected ? "border-blue-500 bg-blue-500/10" : "border-border hover:border-blue-500/50 hover:bg-blue-500/5" )} > -
-
+
+
- {isSelected && } + {isSelected && }
{option.label} @@ -387,7 +387,7 @@ function QuestionStep({
{option.description && ( -

+

{option.description}

)} @@ -404,23 +404,23 @@ function QuestionStep({ } }} className={cn( - "w-full text-left p-3 rounded-lg border-2 transition-all duration-200", + "w-full text-left p-2 sm:p-3 rounded-lg border-2 transition-all duration-200", expandedOther || isCustomSelected ? "border-blue-500 bg-blue-500/10" : "border-border hover:border-blue-500/50 hover:bg-blue-500/5" )} > -
+
- {isCustomSelected && } + {isCustomSelected && }
Other... @@ -429,13 +429,13 @@ function QuestionStep({ {expandedOther && ( -
+