diff --git a/apps/code/src/renderer/di/container.ts b/apps/code/src/renderer/di/container.ts index 705ada1eba..8d7854cbe5 100644 --- a/apps/code/src/renderer/di/container.ts +++ b/apps/code/src/renderer/di/container.ts @@ -160,6 +160,7 @@ container.bind(UPDATES_CLIENT).toConstantValue(updatesClient); // connectivity client — passthrough over the renderer host client const connectivityClient: ConnectivityClient = { getStatus: () => trpcClient.connectivity.getStatus.query(), + checkNow: () => trpcClient.connectivity.checkNow.mutate(), onStatusChange: (sub) => trpcClient.connectivity.onStatusChange.subscribe(undefined, sub), }; diff --git a/packages/core/src/git-interaction/gitInteractionLogic.test.ts b/packages/core/src/git-interaction/gitInteractionLogic.test.ts index 9e6db75a0c..1f6fdf5108 100644 --- a/packages/core/src/git-interaction/gitInteractionLogic.test.ts +++ b/packages/core/src/git-interaction/gitInteractionLogic.test.ts @@ -19,6 +19,7 @@ function makeState(overrides: Partial = {}): GitState { ghStatus: { installed: true, authenticated: true }, repoInfo: { owner: "test", repo: "test" }, prStatus: null, + isOnline: true, ...overrides, }; } @@ -249,4 +250,58 @@ describe("computeGitInteractionState", () => { expect(result.primaryAction.enabled).toBe(false); }); }); + + describe("offline", () => { + it.each([ + { + action: "push", + field: "pushDisabledReason" as const, + overrides: { + currentBranch: "feature/test", + hasChanges: false, + aheadOfRemote: 2, + } satisfies Partial, + }, + { + action: "create-pr", + field: "createPrDisabledReason" as const, + overrides: { + currentBranch: "feature/test", + hasChanges: true, + } satisfies Partial, + }, + ])( + "gates $action with a no-internet reason while offline", + ({ field, overrides }) => { + const result = computeGitInteractionState( + makeState({ ...overrides, isOnline: false }), + ); + expect(result[field]).toBe("No internet connection"); + }, + ); + + it.each([ + { + action: "commit", + overrides: { + currentBranch: "feature/test", + hasChanges: true, + } satisfies Partial, + }, + { + action: "branch-here", + overrides: { currentBranch: null } satisfies Partial, + }, + ])( + "still allows the local $action action while offline", + ({ action, overrides }) => { + const result = computeGitInteractionState( + makeState({ ...overrides, isOnline: false }), + ); + const found = result.actions.find((a) => a.id === action); + expect(found?.enabled).toBe(true); + expect(found?.disabledReason).toBeNull(); + }, + ); + }); }); diff --git a/packages/core/src/git-interaction/gitInteractionLogic.ts b/packages/core/src/git-interaction/gitInteractionLogic.ts index 725ae2a7c0..8ac016848c 100644 --- a/packages/core/src/git-interaction/gitInteractionLogic.ts +++ b/packages/core/src/git-interaction/gitInteractionLogic.ts @@ -20,12 +20,18 @@ interface GitState { headBranch: string | null; prUrl: string | null; } | null; + isOnline: boolean; } +const OFFLINE_REASON = "No internet connection"; + interface GitComputed { actions: GitMenuAction[]; primaryAction: GitMenuAction; pushDisabledReason: string | null; + // Named like pushDisabledReason: a disabled create-pr is dropped from + // `actions`, so its reason is only readable here. + createPrDisabledReason: string | null; prBaseBranch: string | null; prHeadBranch: string | null; prUrl: string | null; @@ -74,6 +80,7 @@ function getPushDisabledReason( opts?: { assumeWillHaveCommits?: boolean }, ): string | null { if (repoReason) return repoReason; + if (!s.isOnline) return OFFLINE_REASON; if (s.behind > 0) { return "Sync branch with remote first."; @@ -96,6 +103,7 @@ function getCreatePrDisabledReason( repoReason: string | null, ): string | null { if (repoReason) return repoReason; + if (!s.isOnline) return OFFLINE_REASON; if (!s.ghStatus) return "Checking GitHub CLI status..."; if (!s.ghStatus.installed) return "Install GitHub CLI: `brew install gh`"; @@ -172,6 +180,7 @@ export function computeGitInteractionState(input: GitState): GitComputed { actions: [branchAction], primaryAction: branchAction, pushDisabledReason: "Create a branch first.", + createPrDisabledReason: "Create a branch first.", prBaseBranch: input.defaultBranch, prHeadBranch: null, prUrl: null, @@ -200,6 +209,7 @@ export function computeGitInteractionState(input: GitState): GitComputed { actions, primaryAction, pushDisabledReason: "Create a feature branch first.", + createPrDisabledReason, prBaseBranch: input.defaultBranch, prHeadBranch: input.currentBranch, prUrl: input.prStatus?.prUrl ?? null, @@ -231,6 +241,7 @@ export function computeGitInteractionState(input: GitState): GitComputed { pushDisabledReason: getPushDisabledReason(input, repoReason, { assumeWillHaveCommits: true, }), + createPrDisabledReason, prBaseBranch: input.prStatus?.baseBranch ?? input.defaultBranch, prHeadBranch: input.prStatus?.headBranch ?? input.currentBranch, prUrl: input.prStatus?.prUrl ?? null, diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 28993a584a..c1ab6c1e94 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -3872,6 +3872,16 @@ export class SessionService { } } + /** + * Recovers cloud sessions after reconnect: retries errored streams and + * flushes stranded queues (same steps as the window-focus and auth-restored + * paths). Local sessions recover on their own via `reconcileLocalConnection`. + */ + public recoverAfterReconnect(): void { + this.retryUnhealthyCloudSessions(); + this.flushQueuedCloudMessagesAfterAuthRestored(); + } + public flushQueuedCloudMessagesAfterAuthRestored(): void { const sessions = this.d.store.getSessions(); for (const session of Object.values(sessions)) { diff --git a/packages/ui/src/features/connectivity/ConnectivityBanner.tsx b/packages/ui/src/features/connectivity/ConnectivityBanner.tsx new file mode 100644 index 0000000000..9d3020bed6 --- /dev/null +++ b/packages/ui/src/features/connectivity/ConnectivityBanner.tsx @@ -0,0 +1,127 @@ +import { ArrowsClockwise, WifiHigh, WifiSlash } from "@phosphor-icons/react"; +import { useService } from "@posthog/di/react"; +import { useConnectivity } from "@posthog/ui/hooks/useConnectivity"; +import { Box } from "@radix-ui/themes"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; +import { + CONNECTIVITY_CLIENT, + type ConnectivityClient, +} from "./connectivityClient"; + +const BACK_ONLINE_VISIBLE_MS = 2_500; + +/** + * Shell banner for the global connectivity state: while offline, offers a Retry + * that forces an immediate probe; briefly shows "Back online" on recovery. + */ +export function ConnectivityBanner() { + const { isOnline } = useConnectivity(); + const client = useService(CONNECTIVITY_CLIENT); + const [isChecking, setIsChecking] = useState(false); + const [showBackOnline, setShowBackOnline] = useState(false); + const wasOnlineRef = useRef(isOnline); + + useEffect(() => { + const wasOnline = wasOnlineRef.current; + wasOnlineRef.current = isOnline; + + if (!wasOnline && isOnline) { + setIsChecking(false); + setShowBackOnline(true); + const timer = setTimeout( + () => setShowBackOnline(false), + BACK_ONLINE_VISIBLE_MS, + ); + return () => clearTimeout(timer); + } + + if (!isOnline) { + setShowBackOnline(false); + } + + return undefined; + }, [isOnline]); + + const handleRetry = () => { + if (isChecking) return; + setIsChecking(true); + void client + .checkNow() + .catch(() => undefined) + .finally(() => setIsChecking(false)); + }; + + const isVisible = !isOnline || showBackOnline; + + return ( + + {isVisible && ( + + + {isOnline ? ( + + ) : ( + + )} + + + )} + + ); +} + +function OfflineRow({ + isChecking, + onRetry, +}: { + isChecking: boolean; + onRetry: () => void; +}) { + return ( +
+ +
+ You're offline + + {isChecking + ? "Checking connection…" + : "Network actions are paused — reconnecting automatically."} + +
+ +
+ ); +} + +function BackOnlineRow() { + return ( + + + Back online + + ); +} diff --git a/packages/ui/src/features/connectivity/connectivity.module.ts b/packages/ui/src/features/connectivity/connectivity.module.ts index 7be5f45c9a..0b8fa40b71 100644 --- a/packages/ui/src/features/connectivity/connectivity.module.ts +++ b/packages/ui/src/features/connectivity/connectivity.module.ts @@ -1,7 +1,9 @@ import { CONTRIBUTION } from "@posthog/di/contribution"; import { ContainerModule } from "inversify"; import { ConnectivityEventsContribution } from "./connectivity-events.contribution"; +import { NetworkReconnectContribution } from "./network-reconnect.contribution"; export const connectivityUiModule = new ContainerModule(({ bind }) => { bind(CONTRIBUTION).to(ConnectivityEventsContribution).inSingletonScope(); + bind(CONTRIBUTION).to(NetworkReconnectContribution).inSingletonScope(); }); diff --git a/packages/ui/src/features/connectivity/connectivityClient.ts b/packages/ui/src/features/connectivity/connectivityClient.ts index 239d513d5b..ce98f47708 100644 --- a/packages/ui/src/features/connectivity/connectivityClient.ts +++ b/packages/ui/src/features/connectivity/connectivityClient.ts @@ -9,6 +9,7 @@ export interface ConnectivityStatusPayload { export interface ConnectivityClient { getStatus(): Promise; + checkNow(): Promise; onStatusChange(sub: Subscriber): { unsubscribe: () => void; }; diff --git a/packages/ui/src/features/connectivity/network-reconnect.contribution.test.ts b/packages/ui/src/features/connectivity/network-reconnect.contribution.test.ts new file mode 100644 index 0000000000..6e523c86df --- /dev/null +++ b/packages/ui/src/features/connectivity/network-reconnect.contribution.test.ts @@ -0,0 +1,57 @@ +import { connectivityStore } from "@posthog/core/connectivity/connectivityStore"; +import type { SessionService } from "@posthog/core/sessions/sessionService"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NetworkReconnectContribution } from "./network-reconnect.contribution"; + +function makeSessionService() { + return { recoverAfterReconnect: vi.fn() } as unknown as SessionService; +} + +function setOnline(isOnline: boolean) { + connectivityStore.getState().setOnline(isOnline); +} + +describe("NetworkReconnectContribution", () => { + beforeEach(() => { + // Reset the process-wide singleton store between cases. + setOnline(true); + }); + + it("recovers sessions on an offline -> online transition", () => { + const sessionService = makeSessionService(); + new NetworkReconnectContribution(sessionService).start(); + + setOnline(false); + expect(sessionService.recoverAfterReconnect).not.toHaveBeenCalled(); + + setOnline(true); + expect(sessionService.recoverAfterReconnect).toHaveBeenCalledTimes(1); + }); + + it("does not recover when going online -> offline", () => { + const sessionService = makeSessionService(); + new NetworkReconnectContribution(sessionService).start(); + + setOnline(false); + expect(sessionService.recoverAfterReconnect).not.toHaveBeenCalled(); + }); + + it("does not recover on a redundant online update", () => { + const sessionService = makeSessionService(); + new NetworkReconnectContribution(sessionService).start(); + + setOnline(true); + expect(sessionService.recoverAfterReconnect).not.toHaveBeenCalled(); + }); + + it("recovers again on each offline -> online cycle", () => { + const sessionService = makeSessionService(); + new NetworkReconnectContribution(sessionService).start(); + + setOnline(false); + setOnline(true); + setOnline(false); + setOnline(true); + expect(sessionService.recoverAfterReconnect).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/ui/src/features/connectivity/network-reconnect.contribution.ts b/packages/ui/src/features/connectivity/network-reconnect.contribution.ts new file mode 100644 index 0000000000..c7d1b0e7e9 --- /dev/null +++ b/packages/ui/src/features/connectivity/network-reconnect.contribution.ts @@ -0,0 +1,32 @@ +import { connectivityStore } from "@posthog/core/connectivity/connectivityStore"; +import { + SESSION_SERVICE, + type SessionService, +} from "@posthog/core/sessions/sessionService"; +import type { Contribution } from "@posthog/di/contribution"; +import { inject, injectable } from "inversify"; + +/** + * On an offline→online transition, asks the session service to recover cloud + * sessions. Window-focus and auth-restored already trigger the same recovery; + * this covers the reconnect event, which fired neither. Local sessions recover + * on their own via `reconcileLocalConnection`. + */ +@injectable() +export class NetworkReconnectContribution implements Contribution { + constructor( + @inject(SESSION_SERVICE) + private readonly sessionService: SessionService, + ) {} + + start(): void { + let wasOnline = connectivityStore.getState().isOnline; + connectivityStore.subscribe((state) => { + const justCameOnline = !wasOnline && state.isOnline; + wasOnline = state.isOnline; + if (justCameOnline) { + this.sessionService.recoverAfterReconnect(); + } + }); + } +} diff --git a/packages/ui/src/features/git-interaction/useGitInteraction.ts b/packages/ui/src/features/git-interaction/useGitInteraction.ts index 4bae9fb753..d54c5b3a9b 100644 --- a/packages/ui/src/features/git-interaction/useGitInteraction.ts +++ b/packages/ui/src/features/git-interaction/useGitInteraction.ts @@ -11,6 +11,7 @@ import { import { useService } from "@posthog/di/react"; import { useHostTRPC } from "@posthog/host-router/react"; import type { ChangedFile } from "@posthog/shared/domain-types"; +import { useConnectivity } from "@posthog/ui/hooks/useConnectivity"; import { useQueryClient } from "@tanstack/react-query"; import { useMemo, useRef } from "react"; import { WORKSPACE_QUERY_KEY } from "../workspace/identifiers"; @@ -94,6 +95,7 @@ export function useGitInteraction( const store = useGitInteractionStore(); const { actions: modal } = store; const pushAbortRef = useRef(null); + const { isOnline } = useConnectivity(); const git = useGitQueries(repoPath); @@ -114,6 +116,7 @@ export function useGitInteraction( ghStatus: git.ghStatus ?? null, repoInfo: git.repoInfo ?? null, prStatus: git.prStatus ?? null, + isOnline, }), [ repoPath, @@ -130,6 +133,7 @@ export function useGitInteraction( git.ghStatus, git.repoInfo, git.prStatus, + isOnline, ], ); diff --git a/packages/ui/src/features/git-interaction/usePrActions.ts b/packages/ui/src/features/git-interaction/usePrActions.ts index d11019beee..2b2932dc35 100644 --- a/packages/ui/src/features/git-interaction/usePrActions.ts +++ b/packages/ui/src/features/git-interaction/usePrActions.ts @@ -4,12 +4,15 @@ import { } from "@posthog/core/git-interaction/prStatus"; import { useHostTRPC } from "@posthog/host-router/react"; import type { PrActionType } from "@posthog/shared"; +import { showOfflineToast } from "@posthog/ui/features/connectivity/connectivityToast"; +import { useConnectivity } from "@posthog/ui/hooks/useConnectivity"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "../../primitives/toast"; export function usePrActions(prUrl: string | null) { const trpc = useHostTRPC(); const queryClient = useQueryClient(); + const { isOnline } = useConnectivity(); const mutation = useMutation({ ...trpc.git.updatePrByUrl.mutationOptions(), @@ -44,6 +47,10 @@ export function usePrActions(prUrl: string | null) { return { execute: (action: PrActionType) => { if (!prUrl) return; + if (!isOnline) { + showOfflineToast(); + return; + } mutation.mutate({ prUrl, action }); }, isPending: mutation.isPending, diff --git a/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts b/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts index 4e4a766fe0..ce2cb2ee48 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts @@ -11,10 +11,12 @@ import { import { useService } from "@posthog/di/react"; import { ANALYTICS_EVENTS, getCloudUrlFromRegion } from "@posthog/shared"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { showOfflineToast } from "@posthog/ui/features/connectivity/connectivityToast"; import { resolveDefaultModel } from "@posthog/ui/features/inbox/hooks/resolveDefaultModel"; import { useUserRepositoryIntegration } from "@posthog/ui/features/integrations/useIntegrations"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; +import { useConnectivity } from "@posthog/ui/hooks/useConnectivity"; import { toast } from "@posthog/ui/primitives/toast"; import { openTask } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; @@ -106,11 +108,17 @@ export function useInboxCloudTaskRunner({ const modelResolver = useService(REPORT_MODEL_RESOLVER); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const queryClient = useQueryClient(); + const { isOnline } = useConnectivity(); const run = useCallback(async () => { if (isRunning) return; const log = logger.scope(loggerScope); + if (!isOnline) { + showOfflineToast(); + return; + } + if (!cloudRepository) { toast.error(copy.errorTitle, { description: copy.missingRepository }); return; @@ -244,6 +252,7 @@ export function useInboxCloudTaskRunner({ } }, [ isRunning, + isOnline, loggerScope, cloudRepository, cloudRegion, diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index d5ecea67f8..8aa0265e6f 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -25,6 +25,7 @@ import { import { useCanvasDeepLink } from "@posthog/ui/features/canvas/hooks/useCanvasDeepLink"; import { CommandMenu } from "@posthog/ui/features/command/CommandMenu"; import { KeyboardShortcutsSheet } from "@posthog/ui/features/command/KeyboardShortcutsSheet"; +import { ConnectivityBanner } from "@posthog/ui/features/connectivity/ConnectivityBanner"; import { useNewTaskDeepLink } from "@posthog/ui/features/deep-links/useNewTaskDeepLink"; import { useTaskDeepLink } from "@posthog/ui/features/deep-links/useTaskDeepLink"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; @@ -351,6 +352,7 @@ function RootLayout() { + {/* Content sits in a bordered, rounded card inset from the window @@ -389,6 +391,7 @@ function RootLayout() { if (isSettingsRoute) { return ( + +