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/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import { ReviewsBanner } from "./ReviewsBanner";
import type { ReviewNoteData } from "@/common/types/review";
import { PopoverError } from "./PopoverError";
import { ConnectionStatusIndicator } from "./ConnectionStatusIndicator";
import { SubscriptionStatusIndicator } from "./SubscriptionStatusIndicator";
import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext";

interface AIViewProps {
Expand Down Expand Up @@ -754,6 +755,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
/>
<ReviewsBanner workspaceId={workspaceId} />
<ConnectionStatusIndicator />
<SubscriptionStatusIndicator workspaceId={workspaceId} />
{isQueuedAgentTask && (
<div className="border-border-medium bg-background-secondary text-muted mb-2 rounded-md border px-3 py-2 text-xs">
This agent task is queued and will start automatically when a parallel slot is
Expand Down
3 changes: 2 additions & 1 deletion src/browser/components/AppLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ function AppLoaderInner() {
// Sync stores when metadata finishes loading
useEffect(() => {
if (api) {
workspaceStoreInstance.setClient(api);
workspaceStoreInstance.setClient(api, apiState.connectionEpoch);
gitStatusStore.setClient(api);
}

Expand All @@ -83,6 +83,7 @@ function AppLoaderInner() {
workspaceStoreInstance,
gitStatusStore,
api,
apiState.connectionEpoch,
]);

// If we're in browser mode and auth is required, show the token prompt before any data loads.
Expand Down
26 changes: 26 additions & 0 deletions src/browser/components/SubscriptionStatusIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from "react";
import { useWorkspaceState } from "@/browser/stores/WorkspaceStore";

interface SubscriptionStatusIndicatorProps {
workspaceId: string;
}

/**
* Displays workspace chat subscription status when reconnecting.
* Shows a subtle banner when the subscription watchdog detects a stall
* and is restarting the subscription.
*/
export const SubscriptionStatusIndicator: React.FC<SubscriptionStatusIndicatorProps> = (props) => {
const state = useWorkspaceState(props.workspaceId);

if (state.subscriptionStatus !== "reconnecting") {
return null;
}

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 chat stream…</span>
</div>
);
};
78 changes: 60 additions & 18 deletions src/browser/contexts/API.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ export type { APIClient };

// Discriminated union for type-safe state handling
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 };
| { status: "connecting"; api: null; error: null; connectionEpoch: number }
| { status: "connected"; api: APIClient; error: null; connectionEpoch: number }
| { status: "degraded"; api: APIClient; error: null; connectionEpoch: number } // Connected but pings failing
| { status: "reconnecting"; api: null; error: null; attempt: number; connectionEpoch: number }
| { status: "auth_required"; api: null; error: string | null; connectionEpoch: number }
| { status: "error"; api: null; error: string; connectionEpoch: number };

interface APIStateMethods {
authenticate: (token: string) => void;
Expand Down Expand Up @@ -107,6 +107,10 @@ function createBrowserClient(
}

export const APIProvider = (props: APIProviderProps) => {
// Connection epoch increments each time we establish a new connection.
// WorkspaceStore uses this to detect stale subscriptions and restart them.
const connectionEpochRef = useRef(0);

// If client is provided externally, start in connected state immediately
const [state, setState] = useState<ConnectionState>(() => {
if (props.client) {
Expand Down Expand Up @@ -159,6 +163,9 @@ export const APIProvider = (props: APIProviderProps) => {
.then(() => {
hasConnectedRef.current = true;
reconnectAttemptRef.current = 0;
consecutivePingFailuresRef.current = 0;
// Increment epoch on successful connection to signal subscriptions to restart
connectionEpochRef.current++;
window.__ORPC_CLIENT__ = client;
cleanupRef.current = cleanup;
setState({ status: "connected", client, cleanup });
Expand Down Expand Up @@ -192,6 +199,13 @@ export const APIProvider = (props: APIProviderProps) => {
ws.addEventListener("close", (event) => {
cleanup();

// Log close details for debugging stale connection issues
if (hasConnectedRef.current) {
console.warn(
`[API] WebSocket closed: code=${event.code}, reason=${event.reason || "(none)"}, wasClean=${event.wasClean}`
);
}

// Auth-specific close codes
if (event.code === 1008 || event.code === 4401) {
clearStoredAuthToken();
Expand Down Expand Up @@ -275,13 +289,15 @@ export const APIProvider = (props: APIProviderProps) => {
if (state.status === "degraded") {
setState({ status: "connected", client, cleanup });
}
} catch {
// Ping failed
} catch (err) {
// Ping failed - log for debugging stale connection issues
consecutivePingFailuresRef.current++;
if (
consecutivePingFailuresRef.current >= CONSECUTIVE_FAILURES_FOR_DEGRADED &&
state.status === "connected"
) {
const failCount = consecutivePingFailuresRef.current;
console.warn(
`[API] Liveness ping failed (${failCount}/${CONSECUTIVE_FAILURES_FOR_DEGRADED}):`,
err instanceof Error ? err.message : String(err)
);
if (failCount >= CONSECUTIVE_FAILURES_FOR_DEGRADED && state.status === "connected") {
setState({ status: "degraded", client, cleanup });
}
}
Expand All @@ -308,19 +324,45 @@ export const APIProvider = (props: APIProviderProps) => {
// Convert internal state to the discriminated union API
const value = useMemo((): UseAPIResult => {
const base = { authenticate, retry };
const epoch = connectionEpochRef.current;
switch (state.status) {
case "connecting":
return { status: "connecting", api: null, error: null, ...base };
return { status: "connecting", api: null, error: null, connectionEpoch: epoch, ...base };
case "connected":
return { status: "connected", api: state.client, error: null, ...base };
return {
status: "connected",
api: state.client,
error: null,
connectionEpoch: epoch,
...base,
};
case "degraded":
return { status: "degraded", api: state.client, error: null, ...base };
return {
status: "degraded",
api: state.client,
error: null,
connectionEpoch: epoch,
...base,
};
case "reconnecting":
return { status: "reconnecting", api: null, error: null, attempt: state.attempt, ...base };
return {
status: "reconnecting",
api: null,
error: null,
attempt: state.attempt,
connectionEpoch: epoch,
...base,
};
case "auth_required":
return { status: "auth_required", api: null, error: state.error ?? null, ...base };
return {
status: "auth_required",
api: null,
error: state.error ?? null,
connectionEpoch: epoch,
...base,
};
case "error":
return { status: "error", api: null, error: state.error, ...base };
return { status: "error", api: null, error: state.error, connectionEpoch: epoch, ...base };
}
}, [state, authenticate, retry]);

Expand Down
2 changes: 1 addition & 1 deletion src/browser/contexts/WorkspaceContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -785,7 +785,7 @@ async function setup() {
);

// Inject client immediately to handle race conditions where effects run before store update
getWorkspaceStoreRaw().setClient(currentClientMock as APIClient);
getWorkspaceStoreRaw().setClient(currentClientMock as APIClient, 1);

await waitFor(() => expect(contextRef.current).toBeTruthy());
return () => contextRef.current!;
Expand Down
2 changes: 1 addition & 1 deletion src/browser/stores/WorkspaceStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe("WorkspaceStore", () => {
mockOnModelUsed = mock(() => undefined);
store = new WorkspaceStore(mockOnModelUsed);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
store.setClient(mockClient as any);
store.setClient(mockClient as any, 1);
});

afterEach(() => {
Expand Down
Loading
Loading