diff --git a/.claude/agent-memory/code-reviewer/MEMORY.md b/.claude/agent-memory/code-reviewer/MEMORY.md index 2861f658f..49261b4dc 100644 --- a/.claude/agent-memory/code-reviewer/MEMORY.md +++ b/.claude/agent-memory/code-reviewer/MEMORY.md @@ -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 diff --git a/apps/web/src/app/layouts/MainContent.tsx b/apps/web/src/app/layouts/MainContent.tsx index e0a1c93a4..76bc3d96b 100644 --- a/apps/web/src/app/layouts/MainContent.tsx +++ b/apps/web/src/app/layouts/MainContent.tsx @@ -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"; @@ -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 { @@ -246,13 +249,17 @@ export function MainContent({ return ( + {/* Connection banner — appears at top of content area when WS is down */} + +
{/* Sidebar toggle -- visible when sidebar collapsed and no workspace */} diff --git a/apps/web/src/app/layouts/MainLayout.tsx b/apps/web/src/app/layouts/MainLayout.tsx index a2594c29f..315951ed4 100644 --- a/apps/web/src/app/layouts/MainLayout.tsx +++ b/apps/web/src/app/layouts/MainLayout.tsx @@ -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"; @@ -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(); diff --git a/apps/web/src/app/shells/DesktopShell.tsx b/apps/web/src/app/shells/DesktopShell.tsx index 10501d01a..a9cca91dd 100644 --- a/apps/web/src/app/shells/DesktopShell.tsx +++ b/apps/web/src/app/shells/DesktopShell.tsx @@ -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"; @@ -100,25 +101,7 @@ export function DesktopShell({ reset }: { reset: () => void }) { // Backend unreachable -- show error instead of white screen if (settingsQuery.isError && !settingsQuery.data) { - return ( -
-
-

Cannot connect to backend

-

- {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`)."} -

- -
-
- ); + return settingsQuery.refetch()} variant="desktop" />; } if (showOnboarding) { diff --git a/apps/web/src/app/shells/ServerLayout.tsx b/apps/web/src/app/shells/ServerLayout.tsx index 424f51531..b9bd27a91 100644 --- a/apps/web/src/app/shells/ServerLayout.tsx +++ b/apps/web/src/app/shells/ServerLayout.tsx @@ -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() { @@ -89,21 +90,10 @@ function ServerContent({ serverId }: { serverId: string }) { // Backend unreachable if (settingsQuery.isError && !settingsQuery.data) { return ( -
-
-

Cannot connect to server

-

- Make sure the OpenDevs desktop app is running and the backend server is started. -

- -
-
+ settingsQuery.refetch()} + variant={isRelayMode() ? "relay" : "desktop"} + /> ); } diff --git a/apps/web/src/features/connection/hooks/useConnectionState.ts b/apps/web/src/features/connection/hooks/useConnectionState.ts new file mode 100644 index 000000000..675d7d3bd --- /dev/null +++ b/apps/web/src/features/connection/hooks/useConnectionState.ts @@ -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 }; +} diff --git a/apps/web/src/features/connection/hooks/useConnectionStateInit.ts b/apps/web/src/features/connection/hooks/useConnectionStateInit.ts new file mode 100644 index 000000000..e856a032c --- /dev/null +++ b/apps/web/src/features/connection/hooks/useConnectionStateInit.ts @@ -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(); + }; + }, []); +} diff --git a/apps/web/src/features/connection/index.ts b/apps/web/src/features/connection/index.ts new file mode 100644 index 000000000..65cf56e54 --- /dev/null +++ b/apps/web/src/features/connection/index.ts @@ -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"; diff --git a/apps/web/src/features/connection/lib/connectionEvents.ts b/apps/web/src/features/connection/lib/connectionEvents.ts new file mode 100644 index 000000000..1aaf3ff63 --- /dev/null +++ b/apps/web/src/features/connection/lib/connectionEvents.ts @@ -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(); + +export function emitSendAttemptFailed(): void { + for (const fn of listeners) fn(); +} + +export function onSendAttemptFailed(cb: Listener): () => void { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; +} diff --git a/apps/web/src/features/connection/store/connectionStore.ts b/apps/web/src/features/connection/store/connectionStore.ts new file mode 100644 index 000000000..c0552ff7f --- /dev/null +++ b/apps/web/src/features/connection/store/connectionStore.ts @@ -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 | null = null; +let escalateTimer: ReturnType | null = null; + +function clearTimers() { + if (graceTimer) { + clearTimeout(graceTimer); + graceTimer = null; + } + if (escalateTimer) { + clearTimeout(escalateTimer); + escalateTimer = null; + } +} + +export const useConnectionStore = create((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(); + }, +})); diff --git a/apps/web/src/features/connection/ui/ConnectionBanner.tsx b/apps/web/src/features/connection/ui/ConnectionBanner.tsx new file mode 100644 index 000000000..38a0f63cc --- /dev/null +++ b/apps/web/src/features/connection/ui/ConnectionBanner.tsx @@ -0,0 +1,110 @@ +/** + * ConnectionBanner — top-of-content-area bar for connection issues. + * + * Two stages: + * RECONNECTING (2-30s): "Reconnecting..." with amber accent + * DISCONNECTED (30s+): "Connection lost" + Retry button + * + * Fixed 40px height — only animates transform + opacity (GPU-composited). + * Content cross-fades between stages via AnimatePresence. + */ + +import { AnimatePresence, m, useReducedMotion } from "framer-motion"; +import { useConnectionState } from "../hooks/useConnectionState"; + +const EASE_OUT_QUART = [0.165, 0.84, 0.44, 1] as const; + +export function ConnectionBanner() { + const { state, sendAttemptFailed, retry } = useConnectionState(); + const reduceMotion = useReducedMotion(); + + const showBanner = state === "reconnecting" || state === "disconnected"; + const isEscalated = state === "disconnected"; + + return ( + + {showBanner && ( + + {/* Amber bottom accent line */} +
+ +
+
+ {/* Amber dot — suppress ping animation for reduced motion */} + + {!reduceMotion && ( + + )} + + + + {/* Copy — cross-fades between reconnecting and disconnected */} + + {isEscalated ? ( + + + {sendAttemptFailed ? "Message queued" : "Connection lost"} + + + {sendAttemptFailed ? "— reconnecting" : "— Your agents are still running"} + + + ) : ( + + Reconnecting... + + )} + +
+ + {/* Retry button — only in escalated state */} + + {isEscalated && ( + + Retry now + + )} + +
+ + )} + + ); +} diff --git a/apps/web/src/features/connection/ui/ConnectionIllustration.tsx b/apps/web/src/features/connection/ui/ConnectionIllustration.tsx new file mode 100644 index 000000000..badf9b109 --- /dev/null +++ b/apps/web/src/features/connection/ui/ConnectionIllustration.tsx @@ -0,0 +1,103 @@ +/** + * Warm SVG illustration — laptop and phone connected by a tangled cord. + * Used on ServerOfflinePage and PairGatePage for friendly empty states. + */ + +import { cn } from "@/shared/lib/utils"; + +interface ConnectionIllustrationProps { + className?: string; +} + +export function ConnectionIllustration({ className }: ConnectionIllustrationProps) { + return ( + + ); +} diff --git a/apps/web/src/features/connection/ui/ConnectionOrb.tsx b/apps/web/src/features/connection/ui/ConnectionOrb.tsx new file mode 100644 index 000000000..a3c68d720 --- /dev/null +++ b/apps/web/src/features/connection/ui/ConnectionOrb.tsx @@ -0,0 +1,72 @@ +/** + * ConnectionOrb — ambient connection health indicator for the sidebar footer. + * + * Always renders (unlike the old BackendStatusIndicator which only showed when disconnected). + * Visual states: + * connected → green dot, static + * grace_period → green dot (unchanged — 2s buffer, user sees nothing) + * reconnecting → amber dot with breathing pulse + * disconnected → amber dot with breathing pulse + "Offline" label + */ + +import { AnimatePresence, m, useReducedMotion } from "framer-motion"; +import { match } from "ts-pattern"; +import { cn } from "@/shared/lib/utils"; +import { useConnectionState, type ConnectionState } from "../hooks/useConnectionState"; + +const EASE_OUT_QUART = [0.165, 0.84, 0.44, 1] as const; + +function getAriaLabel(state: ConnectionState): string { + return match(state) + .with("connected", "grace_period", () => "Connected") + .with("reconnecting", () => "Reconnecting") + .with("disconnected", () => "Server disconnected") + .exhaustive(); +} + +export function ConnectionOrb() { + const { state } = useConnectionState(); + const reduceMotion = useReducedMotion(); + + const isAmber = state === "reconnecting" || state === "disconnected"; + const showLabel = state === "disconnected"; + const showPulse = isAmber && !reduceMotion; + + return ( +
+ + {/* Breathing pulse ring */} + {showPulse && ( + + )} + {/* Solid dot — CSS transition for color (compositor-friendly) */} + + + + {/* "Offline" label — only in disconnected state */} + + {showLabel && ( + + Offline + + )} + +
+ ); +} diff --git a/apps/web/src/features/connection/ui/ServerOfflinePage.tsx b/apps/web/src/features/connection/ui/ServerOfflinePage.tsx new file mode 100644 index 000000000..ab10659e1 --- /dev/null +++ b/apps/web/src/features/connection/ui/ServerOfflinePage.tsx @@ -0,0 +1,122 @@ +/** + * ServerOfflinePage — full-page state when the backend is unreachable. + * + * Replaces the plain "Cannot connect to server" error pages in both + * DesktopShell and ServerLayout. Features the warm illustration, + * clear copy, and a "Waiting for connection" indicator. + * + * Two variants: + * "desktop" — "Desktop app not detected", shows retry button + * "relay" — "Your computer isn't connected", no retry (relay handles it) + */ + +import { m, useReducedMotion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import { ConnectionIllustration } from "./ConnectionIllustration"; + +const EASE_OUT_QUART = [0.165, 0.84, 0.44, 1] as const; + +interface ServerOfflinePageProps { + onRetry: () => void; + variant: "desktop" | "relay"; +} + +const COPY = { + desktop: { + heading: "Desktop app not detected", + body: "Open the OpenDevs desktop app to connect this browser session to your agents and workspaces.", + }, + relay: { + heading: "Your computer isn't connected", + body: "Make sure the OpenDevs desktop app is running on your computer. This page will connect automatically.", + }, +} as const; + +// Staggered fade-in variants +const containerVariants = { + hidden: {}, + visible: { + transition: { staggerChildren: 0.08 }, + }, +}; + +const itemVariants = { + hidden: { opacity: 0, y: 12 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.35, ease: EASE_OUT_QUART }, + }, +}; + +export function ServerOfflinePage({ onRetry, variant }: ServerOfflinePageProps) { + const copy = COPY[variant]; + const reduceMotion = useReducedMotion(); + const variants = reduceMotion ? undefined : itemVariants; + + return ( +
+ + + + + + + {copy.heading} + + + + {copy.body} + + + {variant === "desktop" && ( + + + + )} + + + + Waiting for connection + + +
+ ); +} + +/** Three pulsing dots — communicates "I'm alive and checking" */ +function WaitingDots({ reduced }: { reduced: boolean | null }) { + return ( +
+ {[0, 1, 2].map((i) => + reduced ? ( + + ) : ( + + ) + )} +
+ ); +} diff --git a/apps/web/src/features/session/api/session.queries.ts b/apps/web/src/features/session/api/session.queries.ts index 7951360a0..46d9b23a9 100644 --- a/apps/web/src/features/session/api/session.queries.ts +++ b/apps/web/src/features/session/api/session.queries.ts @@ -21,6 +21,7 @@ import { useMemo, useCallback } from "react"; import { track } from "@/platform/analytics"; import { parseContentBlocks } from "../lib/contentParser"; import { sendCommand, connect, isConnected } from "@/platform/ws"; +import { emitSendAttemptFailed } from "@/features/connection"; import type { RuntimeAgentType } from "../lib/agentRuntime"; /** @@ -355,6 +356,21 @@ export function useSendMessage() { }, onError: (_err, variables, context) => { + // If the error is a WS connectivity issue, escalate the connection state + // immediately. The WS client produces three distinct error messages: + // "WebSocket not connected" — socket already down before send + // "WebSocket disconnected" — connection dropped mid-flight + // "WebSocket connection failed" — connect() rejected (initial open failed) + if (_err instanceof Error) { + const msg = _err.message.toLowerCase(); + if ( + msg.includes("not connected") || + msg.includes("disconnected") || + msg.includes("connection failed") + ) { + emitSendAttemptFailed(); + } + } // Roll back optimistic message if (context?.previousMessages) { queryClient.setQueryData( diff --git a/apps/web/src/features/sidebar/ui/SidebarFooter.tsx b/apps/web/src/features/sidebar/ui/SidebarFooter.tsx index 85a170e0a..05eb1ef69 100644 --- a/apps/web/src/features/sidebar/ui/SidebarFooter.tsx +++ b/apps/web/src/features/sidebar/ui/SidebarFooter.tsx @@ -3,7 +3,7 @@ import { FolderPlus, Github, Plus } from "lucide-react"; import { SidebarFooter as SidebarFooterUI } from "@/components/ui/sidebar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { AIStatusIndicator } from "@/features/ai-status/ui/AIStatusIndicator"; -import { BackendStatusIndicator } from "@/features/ai-status/ui/BackendStatusIndicator"; +import { ConnectionOrb } from "@/features/connection"; import { capabilities } from "@/platform/capabilities"; import type { SidebarFooterProps } from "../model/types"; @@ -61,7 +61,7 @@ export function SidebarFooter({ onAddRepository, onCloneRepository }: SidebarFoo
- +