Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { SettingsProvider, useSettings } from "./contexts/SettingsContext";
import { SettingsModal } from "./components/Settings/SettingsModal";
import { SplashScreenProvider } from "./components/splashScreens/SplashScreenProvider";
import { TutorialProvider } from "./contexts/TutorialContext";
import { ConnectionStatusIndicator } from "./components/ConnectionStatusIndicator";
import { TooltipProvider } from "./components/ui/tooltip";
import { ExperimentsProvider } from "./contexts/ExperimentsContext";
import { getWorkspaceSidebarKey } from "./utils/workspace";
Expand Down Expand Up @@ -630,6 +631,7 @@ function AppInner() {
<ModeProvider projectPath={projectPath}>
<ProviderOptionsProvider>
<ThinkingProvider projectPath={projectPath}>
<ConnectionStatusIndicator />
<ChatInput
variant="creation"
projectPath={projectPath}
Expand Down
2 changes: 2 additions & 0 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import { useReviews } from "@/browser/hooks/useReviews";
import { ReviewsBanner } from "./ReviewsBanner";
import type { ReviewNoteData } from "@/common/types/review";
import { PopoverError } from "./PopoverError";
import { ConnectionStatusIndicator } from "./ConnectionStatusIndicator";

interface AIViewProps {
workspaceId: string;
Expand Down Expand Up @@ -756,6 +757,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
onTerminate={handleTerminateBackgroundBash}
/>
<ReviewsBanner workspaceId={workspaceId} />
<ConnectionStatusIndicator />
<ChatInput
variant="workspace"
workspaceId={workspaceId}
Expand Down
59 changes: 59 additions & 0 deletions src/browser/components/ConnectionStatusIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from "react";
import { useAPI } from "@/browser/contexts/API";

/**
* Displays connection status to the user when not fully connected.
* - degraded: pings failing but WebSocket open (yellow warning)
* - reconnecting: WebSocket closed, attempting reconnect (yellow warning with attempt count)
* - error: failed to reconnect (red error with retry button)
*
* Does not render when connected or connecting (initial load).
*/
export const ConnectionStatusIndicator: React.FC = () => {
const apiState = useAPI();

// Don't show anything when connected or during initial connection
if (apiState.status === "connected" || apiState.status === "connecting") {
return null;
}

// Auth required is handled by a separate modal flow
if (apiState.status === "auth_required") {
return null;
}

if (apiState.status === "degraded") {
return (
<div className="flex items-center justify-center gap-2 py-1 text-xs text-yellow-600">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-yellow-500" />
<span>Connection unstable — messages may be delayed</span>
</div>
);
}

if (apiState.status === "reconnecting") {
return (
<div className="flex items-center justify-center gap-2 py-1 text-xs text-yellow-600">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-yellow-500" />
<span>
Reconnecting to server
{apiState.attempt > 1 && ` (attempt ${apiState.attempt})`}…
</span>
</div>
);
}

if (apiState.status === "error") {
return (
<div className="flex items-center justify-center gap-2 py-1 text-xs text-red-500">
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
<span>Connection lost</span>
<button type="button" onClick={apiState.retry} className="underline hover:no-underline">
Retry
</button>
</div>
);
}

return null;
};
54 changes: 54 additions & 0 deletions src/browser/contexts/API.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type { APIClient };
export type APIState =
| { status: "connecting"; api: null; error: null }
| { status: "connected"; api: APIClient; error: null }
| { status: "degraded"; api: APIClient; error: null } // Connected but pings failing
| { status: "reconnecting"; api: null; error: null; attempt: number }
| { status: "auth_required"; api: null; error: string | null }
| { status: "error"; api: null; error: string };
Expand All @@ -36,6 +37,7 @@ export type UseAPIResult = APIState & APIStateMethods;
type ConnectionState =
| { status: "connecting" }
| { status: "connected"; client: APIClient; cleanup: () => void }
| { status: "degraded"; client: APIClient; cleanup: () => void } // Pings failing
| { status: "reconnecting"; attempt: number }
| { status: "auth_required"; error?: string }
| { status: "error"; error: string };
Expand All @@ -45,6 +47,11 @@ const MAX_RECONNECT_ATTEMPTS = 10;
const BASE_DELAY_MS = 100;
const MAX_DELAY_MS = 10000;

// Liveness check constants
const LIVENESS_INTERVAL_MS = 5000; // Check every 5 seconds
const LIVENESS_TIMEOUT_MS = 3000; // Ping must respond within 3 seconds
const CONSECUTIVE_FAILURES_FOR_DEGRADED = 2; // Mark degraded after N failures

const APIContext = createContext<UseAPIResult | null>(null);

interface APIProviderProps {
Expand Down Expand Up @@ -118,6 +125,7 @@ export const APIProvider = (props: APIProviderProps) => {
const reconnectAttemptRef = useRef(0);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scheduleReconnectRef = useRef<(() => void) | null>(null);
const consecutivePingFailuresRef = useRef(0);

const wsFactory = useMemo(
() => props.createWebSocket ?? ((url: string) => new WebSocket(url)),
Expand Down Expand Up @@ -241,6 +249,50 @@ export const APIProvider = (props: APIProviderProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// Liveness check: periodic ping to detect degraded connections
// Only runs for browser WebSocket connections (not Electron or test clients)
useEffect(() => {
// Only check liveness for connected/degraded browser connections
if (state.status !== "connected" && state.status !== "degraded") return;
// Skip for Electron (MessagePort) and test clients (externally provided)
if (props.client || (!props.createWebSocket && window.api)) return;

const client = state.client;
const cleanup = state.cleanup;

const checkLiveness = async () => {
try {
// Race ping against timeout
const pingPromise = client.general.ping("liveness");
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Ping timeout")), LIVENESS_TIMEOUT_MS)
);

await Promise.race([pingPromise, timeoutPromise]);

// Ping succeeded - reset failure count and restore connected state if degraded
consecutivePingFailuresRef.current = 0;
if (state.status === "degraded") {
setState({ status: "connected", client, cleanup });
}
} catch {
// Ping failed
consecutivePingFailuresRef.current++;
if (
consecutivePingFailuresRef.current >= CONSECUTIVE_FAILURES_FOR_DEGRADED &&
state.status === "connected"
) {
setState({ status: "degraded", client, cleanup });
}
}
};

const intervalId = setInterval(() => {
void checkLiveness();
}, LIVENESS_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [state, props.client, props.createWebSocket]);

const authenticate = useCallback(
(token: string) => {
setAuthToken(token);
Expand All @@ -261,6 +313,8 @@ export const APIProvider = (props: APIProviderProps) => {
return { status: "connecting", api: null, error: null, ...base };
case "connected":
return { status: "connected", api: state.client, error: null, ...base };
case "degraded":
return { status: "degraded", api: state.client, error: null, ...base };
case "reconnecting":
return { status: "reconnecting", api: null, error: null, attempt: state.attempt, ...base };
case "auth_required":
Expand Down