Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .claude/agent-memory/code-reviewer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,22 @@
- Pre-existing since bad96ad4 (protocol unification). Does not cause visible breakage because WS subscription pushes snapshot.
- Full details in `create-workspace-command-return.md`

## Connection State Machine (features/connection)

- Zustand store in `connectionStore.ts`: CONNECTED → GRACE_PERIOD (2s) → RECONNECTING (30s total) → DISCONNECTED
- `onDisconnected` guard checks `current !== "connected"` — must allow re-entry from `disconnected`
to handle Retry button flow (forceReconnect fires notifyConnectionChange(false) before new socket opens)
- `forceReconnect()` in platform/ws immediately calls `notifyConnectionChange(false)` — if guard blocks,
the banner freezes at DISCONNECTED with no recovery until socket connects
- `emitSendAttemptFailed` in `connectionEvents.ts` is a module-level Set-based event bus (no React deps)
- WS error messages that trigger `emitSendAttemptFailed`: both `"not connected"` AND `"disconnected"`
must be matched — `"WebSocket disconnected"` (from onclose pending-command rejection) will be missed
if only checking `"not connected"`
- `platform/ws → connectionStore` is safe (no circular import). `session.queries → connectionEvents` is safe.
- `ConnectionBanner` animates `height` (violates project guidelines) — causes panel jitter on 32→44 change
- `ConnectionOrb` color crossfade: CSS `transition-colors` on a class swap is better than Framer Motion
`backgroundColor` interpolation (avoids paint-per-frame)

## See Also

- `patterns-deep.md` — overflow notes: error classification, chat virtualization, PRStatus, border radius, sidecar resume
Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/app/layouts/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { native } from "@/platform";
import { BROWSER_WORKSPACE_CHANGE } from "@shared/events";
import { useBrowserWindowStore } from "@/features/browser/store";
import { track } from "@/platform/analytics";
import { ConnectionBanner, useConnectionState } from "@/features/connection";
import { ChatArea } from "./ChatArea";
import { RightSidePanel } from "./RightSidePanel";
import { CollapsedChatStrip, CollapsedContentStrip } from "./CollapsedPanelStrips";
Expand Down Expand Up @@ -84,6 +85,8 @@ export function MainContent({
: "code";

const isBrowserDetached = useBrowserWindowStore((s) => s.detachedWindowOpen);
const connectionState = useConnectionState().state;
const isDisconnected = connectionState === "disconnected";

// --- Workspace actions (PR bridge, archive, retry, manifest) ---
const {
Expand Down Expand Up @@ -246,13 +249,17 @@ export function MainContent({

return (
<SidebarInset className="min-w-0">
{/* Connection banner — appears at top of content area when WS is down */}
<ConnectionBanner />

<div
data-slot="main-content"
className={cn(
"bg-bg-surface flex h-full min-w-0 flex-1 overflow-hidden border transition-[border-radius,border-color] duration-[280ms] ease-[cubic-bezier(.19,1,.22,1)]",
"bg-bg-surface flex h-full min-w-0 flex-1 overflow-hidden border transition-[border-radius,border-color,opacity] duration-[280ms] ease-[cubic-bezier(.19,1,.22,1)]",
sidebarOpen
? "border-border-subtle rounded-l-xl border-r-0"
: "rounded-none border-transparent"
: "rounded-none border-transparent",
isDisconnected && "opacity-60"
)}
>
{/* Sidebar toggle -- visible when sidebar collapsed and no workspace */}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/app/layouts/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { native } from "@/platform";
import { CHAT_INSERT } from "@shared/events";
import { CommandPalette } from "@/features/command-palette";
import { GitHubPickerModal } from "@/features/sidebar/ui/GitHubPickerModal";
import { useConnectionStateInit } from "@/features/connection";
import { MainContent } from "./MainContent";
import { useRepoActions } from "./hooks/useRepoActions";
import { useSystemPrompt, useUpdateSystemPrompt } from "@/features/workspace/api";
Expand Down Expand Up @@ -265,6 +266,9 @@ export function MainLayout() {

// --- Global hooks ---

// Connection state machine — subscribes to WS changes + send-attempt-failed events
useConnectionStateInit();

// Zoom (Cmd+=/Cmd+-/Cmd+0)
useZoom();

Expand Down
23 changes: 3 additions & 20 deletions apps/web/src/app/shells/DesktopShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import { Toaster } from "@/components/ui/sonner";
import { OnboardingOverlay } from "@/features/onboarding";
import { useSettings } from "@/features/settings";
import { useAuth, PairGatePage } from "@/features/auth";
import { native, capabilities } from "@/platform";
import { native } from "@/platform";
import { ServerOfflinePage } from "@/features/connection";
import { initNotifications } from "@/platform/notifications";
import { useGlobalSessionNotifications } from "@/features/session/hooks/useGlobalSessionNotifications";
import { useWorkspaceInitEvents } from "@/features/workspace/hooks/useWorkspaceInitEvents";
Expand Down Expand Up @@ -100,25 +101,7 @@ export function DesktopShell({ reset }: { reset: () => void }) {

// Backend unreachable -- show error instead of white screen
if (settingsQuery.isError && !settingsQuery.data) {
return (
<div className="bg-background flex h-screen items-center justify-center">
<div className="max-w-md space-y-4 text-center">
<h1 className="text-foreground text-xl font-semibold">Cannot connect to backend</h1>
<p className="text-muted-foreground text-sm">
{capabilities.windowLifecycle
? "The backend server failed to start. Check the terminal for errors."
: "Run `bun run dev:web` for browser development, or use the Electron desktop app (`bun run dev`)."}
</p>
<button
type="button"
onClick={() => settingsQuery.refetch()}
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg px-4 py-2 text-sm font-medium"
>
Retry
</button>
</div>
</div>
);
return <ServerOfflinePage onRetry={() => settingsQuery.refetch()} variant="desktop" />;
}

if (showOnboarding) {
Expand Down
20 changes: 5 additions & 15 deletions apps/web/src/app/shells/ServerLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useBackendRestart } from "@/shared/hooks/useBackendRestart";
import { useAnalyticsConsent } from "@/platform/analytics";
import { useSettings } from "@/features/settings";
import { useAuth, PairGatePage } from "@/features/auth";
import { ServerOfflinePage } from "@/features/connection";
import { isRelayMode } from "@/shared/config/backend.config";

export function ServerLayout() {
Expand Down Expand Up @@ -89,21 +90,10 @@ function ServerContent({ serverId }: { serverId: string }) {
// Backend unreachable
if (settingsQuery.isError && !settingsQuery.data) {
return (
<div className="bg-background flex h-screen items-center justify-center">
<div className="max-w-md space-y-4 text-center">
<h1 className="text-foreground text-xl font-semibold">Cannot connect to server</h1>
<p className="text-muted-foreground text-sm">
Make sure the OpenDevs desktop app is running and the backend server is started.
</p>
<button
type="button"
onClick={() => settingsQuery.refetch()}
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg px-4 py-2 text-sm font-medium"
>
Retry
</button>
</div>
</div>
<ServerOfflinePage
onRetry={() => settingsQuery.refetch()}
variant={isRelayMode() ? "relay" : "desktop"}
/>
);
}

Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/features/connection/hooks/useConnectionState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Facade hook over the connection Zustand store.
*
* Only exposes fields that UI consumers actually use.
* `disconnectedAt` and `markSendAttemptFailed` are internal —
* used only by useConnectionStateInit via getState().
*/

import { useConnectionStore, type ConnectionState } from "../store/connectionStore";

export { type ConnectionState };

export function useConnectionState() {
const state = useConnectionStore((s) => s.state);
const sendAttemptFailed = useConnectionStore((s) => s.sendAttemptFailed);
const retry = useConnectionStore((s) => s.retry);

return { state, sendAttemptFailed, retry };
}
37 changes: 37 additions & 0 deletions apps/web/src/features/connection/hooks/useConnectionStateInit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* One-time initialization hook for the connection state machine.
*
* Call this once in MainLayout (or equivalent top-level component).
* Subscribes to WS connection changes and send-attempt-failed events,
* and drives the Zustand store transitions.
*/

import { useEffect } from "react";
import { isConnected, onConnectionChange } from "@/platform/ws";
import { onSendAttemptFailed } from "../lib/connectionEvents";
import { useConnectionStore } from "../store/connectionStore";

export function useConnectionStateInit() {
useEffect(() => {
if (isConnected() && useConnectionStore.getState().state !== "connected") {
useConnectionStore.getState().onConnected();
}

const unsubConnection = onConnectionChange((connected) => {
if (connected) {
useConnectionStore.getState().onConnected();
} else {
useConnectionStore.getState().onDisconnected();
}
});

const unsubSendFailed = onSendAttemptFailed(() => {
useConnectionStore.getState().markSendAttemptFailed();
});

return () => {
unsubConnection();
unsubSendFailed();
};
}, []);
}
8 changes: 8 additions & 0 deletions apps/web/src/features/connection/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export { useConnectionState } from "./hooks/useConnectionState";
export type { ConnectionState } from "./hooks/useConnectionState";
export { useConnectionStateInit } from "./hooks/useConnectionStateInit";
export { ConnectionOrb } from "./ui/ConnectionOrb";
export { ConnectionBanner } from "./ui/ConnectionBanner";
export { ServerOfflinePage } from "./ui/ServerOfflinePage";
export { ConnectionIllustration } from "./ui/ConnectionIllustration";
export { emitSendAttemptFailed } from "./lib/connectionEvents";
22 changes: 22 additions & 0 deletions apps/web/src/features/connection/lib/connectionEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Tiny event emitter for cross-feature connection signals.
*
* Session actions call emitSendAttemptFailed() when a command rejects
* because the WebSocket is down. The connection store listens and
* immediately escalates to DISCONNECTED state.
*/

type Listener = () => void;

const listeners = new Set<Listener>();

export function emitSendAttemptFailed(): void {
for (const fn of listeners) fn();
}

export function onSendAttemptFailed(cb: Listener): () => void {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}
111 changes: 111 additions & 0 deletions apps/web/src/features/connection/store/connectionStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Connection state machine — Zustand store.
*
* States:
* CONNECTED → healthy, all systems go
* GRACE_PERIOD → WS just dropped, wait 2s before showing anything
* RECONNECTING → 2-30s, show thin reconnecting bar
* DISCONNECTED → 30s+, show full banner, dim content
*
* Escalation shortcuts:
* - If a sendCommand fails while in GRACE_PERIOD or RECONNECTING,
* immediately jump to DISCONNECTED (user tried to act).
*/

import { create } from "zustand";
import { forceReconnect } from "@/platform/ws";

const GRACE_MS = 2_000;
const ESCALATE_MS = 30_000;

export type ConnectionState = "connected" | "grace_period" | "reconnecting" | "disconnected";

interface ConnectionStore {
state: ConnectionState;
disconnectedAt: number | null;
sendAttemptFailed: boolean;
onConnected: () => void;
onDisconnected: () => void;
markSendAttemptFailed: () => void;
retry: () => void;
}

// Module-level timer refs (not serializable, don't belong in store state)
let graceTimer: ReturnType<typeof setTimeout> | null = null;
let escalateTimer: ReturnType<typeof setTimeout> | null = null;

function clearTimers() {
if (graceTimer) {
clearTimeout(graceTimer);
graceTimer = null;
}
if (escalateTimer) {
clearTimeout(escalateTimer);
escalateTimer = null;
}
}

export const useConnectionStore = create<ConnectionStore>((set, get) => ({
state: "connected",
disconnectedAt: null,
sendAttemptFailed: false,

onConnected: () => {
clearTimers();
set({ state: "connected", disconnectedAt: null, sendAttemptFailed: false });
},

onDisconnected: () => {
const current = get().state;
// Don't re-enter if already in the active disconnection flow (grace/reconnecting).
// Allow re-entry from "disconnected" so that Retry → forceReconnect → onclose
// can restart the grace period.
if (current === "grace_period" || current === "reconnecting") return;

clearTimers();
set({ state: "grace_period", disconnectedAt: Date.now(), sendAttemptFailed: false });

graceTimer = setTimeout(() => {
if (get().state !== "grace_period") return;

set({ state: "reconnecting" });

// Escalate to DISCONNECTED after ESCALATE_MS total from initial disconnect
// (not from entering RECONNECTING). Accounts for time already in GRACE_PERIOD.
const disconnectedAt = get().disconnectedAt;
if (!disconnectedAt) return;

const remaining = Math.max(0, ESCALATE_MS - (Date.now() - disconnectedAt));
escalateTimer = setTimeout(() => {
if (get().state === "reconnecting") {
set({ state: "disconnected" });
}
}, remaining);
}, GRACE_MS);
},

markSendAttemptFailed: () => {
const current = get().state;
if (current === "grace_period" || current === "reconnecting") {
clearTimers();
set({ state: "disconnected", sendAttemptFailed: true });
}
},

retry: () => {
// Reset to grace_period for immediate visual feedback (banner shows
// "Reconnecting..." instead of staying stuck on "Connection lost").
// When ws is null after 30s+, forceReconnect() skips notifyConnectionChange
// because its if(ws) guard fails — so we must transition the store ourselves.
clearTimers();
set({ state: "grace_period", disconnectedAt: Date.now(), sendAttemptFailed: false });

graceTimer = setTimeout(() => {
if (get().state === "grace_period") {
set({ state: "reconnecting" });
}
}, GRACE_MS);

forceReconnect();
},
}));
Loading
Loading