Skip to content

Commit 5fd5d63

Browse files
authored
feat(connectivity): graceful offline UX — banner, action gating, auto-recovery (#2932)
1 parent 53c87c7 commit 5fd5d63

13 files changed

Lines changed: 320 additions & 0 deletions

File tree

apps/code/src/renderer/di/container.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ container.bind(UPDATES_CLIENT).toConstantValue(updatesClient);
160160
// connectivity client — passthrough over the renderer host client
161161
const connectivityClient: ConnectivityClient = {
162162
getStatus: () => trpcClient.connectivity.getStatus.query(),
163+
checkNow: () => trpcClient.connectivity.checkNow.mutate(),
163164
onStatusChange: (sub) =>
164165
trpcClient.connectivity.onStatusChange.subscribe(undefined, sub),
165166
};

packages/core/src/git-interaction/gitInteractionLogic.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ function makeState(overrides: Partial<GitState> = {}): GitState {
1919
ghStatus: { installed: true, authenticated: true },
2020
repoInfo: { owner: "test", repo: "test" },
2121
prStatus: null,
22+
isOnline: true,
2223
...overrides,
2324
};
2425
}
@@ -249,4 +250,58 @@ describe("computeGitInteractionState", () => {
249250
expect(result.primaryAction.enabled).toBe(false);
250251
});
251252
});
253+
254+
describe("offline", () => {
255+
it.each([
256+
{
257+
action: "push",
258+
field: "pushDisabledReason" as const,
259+
overrides: {
260+
currentBranch: "feature/test",
261+
hasChanges: false,
262+
aheadOfRemote: 2,
263+
} satisfies Partial<GitState>,
264+
},
265+
{
266+
action: "create-pr",
267+
field: "createPrDisabledReason" as const,
268+
overrides: {
269+
currentBranch: "feature/test",
270+
hasChanges: true,
271+
} satisfies Partial<GitState>,
272+
},
273+
])(
274+
"gates $action with a no-internet reason while offline",
275+
({ field, overrides }) => {
276+
const result = computeGitInteractionState(
277+
makeState({ ...overrides, isOnline: false }),
278+
);
279+
expect(result[field]).toBe("No internet connection");
280+
},
281+
);
282+
283+
it.each([
284+
{
285+
action: "commit",
286+
overrides: {
287+
currentBranch: "feature/test",
288+
hasChanges: true,
289+
} satisfies Partial<GitState>,
290+
},
291+
{
292+
action: "branch-here",
293+
overrides: { currentBranch: null } satisfies Partial<GitState>,
294+
},
295+
])(
296+
"still allows the local $action action while offline",
297+
({ action, overrides }) => {
298+
const result = computeGitInteractionState(
299+
makeState({ ...overrides, isOnline: false }),
300+
);
301+
const found = result.actions.find((a) => a.id === action);
302+
expect(found?.enabled).toBe(true);
303+
expect(found?.disabledReason).toBeNull();
304+
},
305+
);
306+
});
252307
});

packages/core/src/git-interaction/gitInteractionLogic.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,18 @@ interface GitState {
2020
headBranch: string | null;
2121
prUrl: string | null;
2222
} | null;
23+
isOnline: boolean;
2324
}
2425

26+
const OFFLINE_REASON = "No internet connection";
27+
2528
interface GitComputed {
2629
actions: GitMenuAction[];
2730
primaryAction: GitMenuAction;
2831
pushDisabledReason: string | null;
32+
// Named like pushDisabledReason: a disabled create-pr is dropped from
33+
// `actions`, so its reason is only readable here.
34+
createPrDisabledReason: string | null;
2935
prBaseBranch: string | null;
3036
prHeadBranch: string | null;
3137
prUrl: string | null;
@@ -74,6 +80,7 @@ function getPushDisabledReason(
7480
opts?: { assumeWillHaveCommits?: boolean },
7581
): string | null {
7682
if (repoReason) return repoReason;
83+
if (!s.isOnline) return OFFLINE_REASON;
7784

7885
if (s.behind > 0) {
7986
return "Sync branch with remote first.";
@@ -96,6 +103,7 @@ function getCreatePrDisabledReason(
96103
repoReason: string | null,
97104
): string | null {
98105
if (repoReason) return repoReason;
106+
if (!s.isOnline) return OFFLINE_REASON;
99107

100108
if (!s.ghStatus) return "Checking GitHub CLI status...";
101109
if (!s.ghStatus.installed) return "Install GitHub CLI: `brew install gh`";
@@ -172,6 +180,7 @@ export function computeGitInteractionState(input: GitState): GitComputed {
172180
actions: [branchAction],
173181
primaryAction: branchAction,
174182
pushDisabledReason: "Create a branch first.",
183+
createPrDisabledReason: "Create a branch first.",
175184
prBaseBranch: input.defaultBranch,
176185
prHeadBranch: null,
177186
prUrl: null,
@@ -200,6 +209,7 @@ export function computeGitInteractionState(input: GitState): GitComputed {
200209
actions,
201210
primaryAction,
202211
pushDisabledReason: "Create a feature branch first.",
212+
createPrDisabledReason,
203213
prBaseBranch: input.defaultBranch,
204214
prHeadBranch: input.currentBranch,
205215
prUrl: input.prStatus?.prUrl ?? null,
@@ -231,6 +241,7 @@ export function computeGitInteractionState(input: GitState): GitComputed {
231241
pushDisabledReason: getPushDisabledReason(input, repoReason, {
232242
assumeWillHaveCommits: true,
233243
}),
244+
createPrDisabledReason,
234245
prBaseBranch: input.prStatus?.baseBranch ?? input.defaultBranch,
235246
prHeadBranch: input.prStatus?.headBranch ?? input.currentBranch,
236247
prUrl: input.prStatus?.prUrl ?? null,

packages/core/src/sessions/sessionService.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3900,6 +3900,16 @@ export class SessionService {
39003900
}
39013901
}
39023902

3903+
/**
3904+
* Recovers cloud sessions after reconnect: retries errored streams and
3905+
* flushes stranded queues (same steps as the window-focus and auth-restored
3906+
* paths). Local sessions recover on their own via `reconcileLocalConnection`.
3907+
*/
3908+
public recoverAfterReconnect(): void {
3909+
this.retryUnhealthyCloudSessions();
3910+
this.flushQueuedCloudMessagesAfterAuthRestored();
3911+
}
3912+
39033913
public flushQueuedCloudMessagesAfterAuthRestored(): void {
39043914
const sessions = this.d.store.getSessions();
39053915
for (const session of Object.values(sessions)) {
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { ArrowsClockwise, WifiHigh, WifiSlash } from "@phosphor-icons/react";
2+
import { useService } from "@posthog/di/react";
3+
import { useConnectivity } from "@posthog/ui/hooks/useConnectivity";
4+
import { Box } from "@radix-ui/themes";
5+
import { AnimatePresence, motion } from "framer-motion";
6+
import { useEffect, useRef, useState } from "react";
7+
import {
8+
CONNECTIVITY_CLIENT,
9+
type ConnectivityClient,
10+
} from "./connectivityClient";
11+
12+
const BACK_ONLINE_VISIBLE_MS = 2_500;
13+
14+
/**
15+
* Shell banner for the global connectivity state: while offline, offers a Retry
16+
* that forces an immediate probe; briefly shows "Back online" on recovery.
17+
*/
18+
export function ConnectivityBanner() {
19+
const { isOnline } = useConnectivity();
20+
const client = useService<ConnectivityClient>(CONNECTIVITY_CLIENT);
21+
const [isChecking, setIsChecking] = useState(false);
22+
const [showBackOnline, setShowBackOnline] = useState(false);
23+
const wasOnlineRef = useRef(isOnline);
24+
25+
useEffect(() => {
26+
const wasOnline = wasOnlineRef.current;
27+
wasOnlineRef.current = isOnline;
28+
29+
if (!wasOnline && isOnline) {
30+
setIsChecking(false);
31+
setShowBackOnline(true);
32+
const timer = setTimeout(
33+
() => setShowBackOnline(false),
34+
BACK_ONLINE_VISIBLE_MS,
35+
);
36+
return () => clearTimeout(timer);
37+
}
38+
39+
if (!isOnline) {
40+
setShowBackOnline(false);
41+
}
42+
43+
return undefined;
44+
}, [isOnline]);
45+
46+
const handleRetry = () => {
47+
if (isChecking) return;
48+
setIsChecking(true);
49+
void client
50+
.checkNow()
51+
.catch(() => undefined)
52+
.finally(() => setIsChecking(false));
53+
};
54+
55+
const isVisible = !isOnline || showBackOnline;
56+
57+
return (
58+
<AnimatePresence>
59+
{isVisible && (
60+
<motion.div
61+
initial={{ height: 0, opacity: 0 }}
62+
animate={{ height: "auto", opacity: 1 }}
63+
exit={{ height: 0, opacity: 0 }}
64+
transition={{ duration: 0.2, ease: "easeInOut" }}
65+
className="no-drag shrink-0 overflow-hidden"
66+
>
67+
<Box className="px-2 pt-2">
68+
{isOnline ? (
69+
<BackOnlineRow />
70+
) : (
71+
<OfflineRow isChecking={isChecking} onRetry={handleRetry} />
72+
)}
73+
</Box>
74+
</motion.div>
75+
)}
76+
</AnimatePresence>
77+
);
78+
}
79+
80+
function OfflineRow({
81+
isChecking,
82+
onRetry,
83+
}: {
84+
isChecking: boolean;
85+
onRetry: () => void;
86+
}) {
87+
return (
88+
<div className="flex w-full items-center gap-2.5 rounded-md border border-(--amber-6) bg-(--amber-3) px-3 py-2 text-(--amber-11) text-[13px]">
89+
<WifiSlash size={16} weight="duotone" className="shrink-0" />
90+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
91+
<span className="font-medium">You're offline</span>
92+
<span className="text-(--amber-a11) text-[11px]">
93+
{isChecking
94+
? "Checking connection…"
95+
: "Network actions are paused — reconnecting automatically."}
96+
</span>
97+
</div>
98+
<button
99+
type="button"
100+
disabled={isChecking}
101+
onClick={onRetry}
102+
className="flex shrink-0 items-center gap-1.5 rounded-2 bg-(--amber-a4) px-2 py-1 font-medium text-(--amber-11) text-[12px] transition-colors hover:bg-(--amber-a5) disabled:opacity-60"
103+
>
104+
<ArrowsClockwise
105+
size={13}
106+
className={isChecking ? "animate-spin" : undefined}
107+
/>
108+
{isChecking ? "Checking…" : "Retry"}
109+
</button>
110+
</div>
111+
);
112+
}
113+
114+
function BackOnlineRow() {
115+
return (
116+
<motion.div
117+
initial={{ opacity: 0 }}
118+
animate={{ opacity: 1 }}
119+
exit={{ opacity: 0 }}
120+
transition={{ duration: 0.15 }}
121+
className="flex w-full items-center gap-2.5 rounded-md border border-(--green-a5) bg-(--green-a3) px-3 py-2 text-(--green-11) text-[13px]"
122+
>
123+
<WifiHigh size={16} weight="duotone" className="shrink-0" />
124+
<span className="font-medium">Back online</span>
125+
</motion.div>
126+
);
127+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { CONTRIBUTION } from "@posthog/di/contribution";
22
import { ContainerModule } from "inversify";
33
import { ConnectivityEventsContribution } from "./connectivity-events.contribution";
4+
import { NetworkReconnectContribution } from "./network-reconnect.contribution";
45

56
export const connectivityUiModule = new ContainerModule(({ bind }) => {
67
bind(CONTRIBUTION).to(ConnectivityEventsContribution).inSingletonScope();
8+
bind(CONTRIBUTION).to(NetworkReconnectContribution).inSingletonScope();
79
});

packages/ui/src/features/connectivity/connectivityClient.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface ConnectivityStatusPayload {
99

1010
export interface ConnectivityClient {
1111
getStatus(): Promise<ConnectivityStatusPayload>;
12+
checkNow(): Promise<ConnectivityStatusPayload>;
1213
onStatusChange(sub: Subscriber<ConnectivityStatusPayload>): {
1314
unsubscribe: () => void;
1415
};
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { connectivityStore } from "@posthog/core/connectivity/connectivityStore";
2+
import type { SessionService } from "@posthog/core/sessions/sessionService";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
import { NetworkReconnectContribution } from "./network-reconnect.contribution";
5+
6+
function makeSessionService() {
7+
return { recoverAfterReconnect: vi.fn() } as unknown as SessionService;
8+
}
9+
10+
function setOnline(isOnline: boolean) {
11+
connectivityStore.getState().setOnline(isOnline);
12+
}
13+
14+
describe("NetworkReconnectContribution", () => {
15+
beforeEach(() => {
16+
// Reset the process-wide singleton store between cases.
17+
setOnline(true);
18+
});
19+
20+
it("recovers sessions on an offline -> online transition", () => {
21+
const sessionService = makeSessionService();
22+
new NetworkReconnectContribution(sessionService).start();
23+
24+
setOnline(false);
25+
expect(sessionService.recoverAfterReconnect).not.toHaveBeenCalled();
26+
27+
setOnline(true);
28+
expect(sessionService.recoverAfterReconnect).toHaveBeenCalledTimes(1);
29+
});
30+
31+
it("does not recover when going online -> offline", () => {
32+
const sessionService = makeSessionService();
33+
new NetworkReconnectContribution(sessionService).start();
34+
35+
setOnline(false);
36+
expect(sessionService.recoverAfterReconnect).not.toHaveBeenCalled();
37+
});
38+
39+
it("does not recover on a redundant online update", () => {
40+
const sessionService = makeSessionService();
41+
new NetworkReconnectContribution(sessionService).start();
42+
43+
setOnline(true);
44+
expect(sessionService.recoverAfterReconnect).not.toHaveBeenCalled();
45+
});
46+
47+
it("recovers again on each offline -> online cycle", () => {
48+
const sessionService = makeSessionService();
49+
new NetworkReconnectContribution(sessionService).start();
50+
51+
setOnline(false);
52+
setOnline(true);
53+
setOnline(false);
54+
setOnline(true);
55+
expect(sessionService.recoverAfterReconnect).toHaveBeenCalledTimes(2);
56+
});
57+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { connectivityStore } from "@posthog/core/connectivity/connectivityStore";
2+
import {
3+
SESSION_SERVICE,
4+
type SessionService,
5+
} from "@posthog/core/sessions/sessionService";
6+
import type { Contribution } from "@posthog/di/contribution";
7+
import { inject, injectable } from "inversify";
8+
9+
/**
10+
* On an offline→online transition, asks the session service to recover cloud
11+
* sessions. Window-focus and auth-restored already trigger the same recovery;
12+
* this covers the reconnect event, which fired neither. Local sessions recover
13+
* on their own via `reconcileLocalConnection`.
14+
*/
15+
@injectable()
16+
export class NetworkReconnectContribution implements Contribution {
17+
constructor(
18+
@inject(SESSION_SERVICE)
19+
private readonly sessionService: SessionService,
20+
) {}
21+
22+
start(): void {
23+
let wasOnline = connectivityStore.getState().isOnline;
24+
connectivityStore.subscribe((state) => {
25+
const justCameOnline = !wasOnline && state.isOnline;
26+
wasOnline = state.isOnline;
27+
if (justCameOnline) {
28+
this.sessionService.recoverAfterReconnect();
29+
}
30+
});
31+
}
32+
}

0 commit comments

Comments
 (0)