Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ef1ffab
fix(auth): treat transient code-access checks as indeterminate, not d…
k11kirky Jun 25, 2026
a0e9e89
feat(connectivity): graceful offline UX — banner, action gating, auto…
k11kirky Jun 25, 2026
fe728a0
style(auth): apply Biome formatting to code-access test helpers
k11kirky Jun 25, 2026
0c17c85
Merge branch 'posthog-code/auth-code-access-resilience' into posthog-…
k11kirky Jun 25, 2026
b2684d0
refactor(git-interaction): surface createPrDisabledReason; parameteri…
k11kirky Jun 25, 2026
2fc2cd4
docs(connectivity): trim verbose comments
k11kirky Jun 25, 2026
5189b37
refactor(auth): simplify code-access outcome and trim comments
k11kirky Jun 25, 2026
e78a64d
Merge branch 'posthog-code/auth-code-access-resilience' into posthog-…
k11kirky Jun 25, 2026
9719250
test(auth): parameterize code-access cases, cover retry recovery
k11kirky Jun 25, 2026
b4685ac
Merge branch 'posthog-code/auth-code-access-resilience' into posthog-…
k11kirky Jun 25, 2026
ef6d83f
test(auth): dedupe fetch stub and parameterize grant-preservation cases
k11kirky Jun 25, 2026
d8deabb
Merge branch 'posthog-code/auth-code-access-resilience' into posthog-…
k11kirky Jun 25, 2026
028388e
docs(connectivity): remove self-evident comments
k11kirky Jun 25, 2026
3e141c0
docs(auth): remove self-evident test comments
k11kirky Jun 25, 2026
20989c2
Merge branch 'posthog-code/auth-code-access-resilience' into posthog-…
k11kirky Jun 25, 2026
ca59ee4
Merge branch 'main' into posthog-code/connectivity-ux
k11kirky Jun 26, 2026
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
1 change: 1 addition & 0 deletions apps/code/src/renderer/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
Expand Down
55 changes: 55 additions & 0 deletions packages/core/src/git-interaction/gitInteractionLogic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function makeState(overrides: Partial<GitState> = {}): GitState {
ghStatus: { installed: true, authenticated: true },
repoInfo: { owner: "test", repo: "test" },
prStatus: null,
isOnline: true,
...overrides,
};
}
Expand Down Expand Up @@ -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<GitState>,
},
{
action: "create-pr",
field: "createPrDisabledReason" as const,
overrides: {
currentBranch: "feature/test",
hasChanges: true,
} satisfies Partial<GitState>,
},
])(
"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<GitState>,
},
{
action: "branch-here",
overrides: { currentBranch: null } satisfies Partial<GitState>,
},
])(
"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();
},
);
});
Comment thread
k11kirky marked this conversation as resolved.
});
11 changes: 11 additions & 0 deletions packages/core/src/git-interaction/gitInteractionLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.";
Expand All @@ -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`";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
127 changes: 127 additions & 0 deletions packages/ui/src/features/connectivity/ConnectivityBanner.tsx
Original file line number Diff line number Diff line change
@@ -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<ConnectivityClient>(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 (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="no-drag shrink-0 overflow-hidden"
>
<Box className="px-2 pt-2">
{isOnline ? (
<BackOnlineRow />
) : (
<OfflineRow isChecking={isChecking} onRetry={handleRetry} />
)}
</Box>
</motion.div>
)}
</AnimatePresence>
);
}

function OfflineRow({
isChecking,
onRetry,
}: {
isChecking: boolean;
onRetry: () => void;
}) {
return (
<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]">
<WifiSlash size={16} weight="duotone" className="shrink-0" />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="font-medium">You're offline</span>
<span className="text-(--amber-a11) text-[11px]">
{isChecking
? "Checking connection…"
: "Network actions are paused — reconnecting automatically."}
</span>
</div>
<button
type="button"
disabled={isChecking}
onClick={onRetry}
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"
>
<ArrowsClockwise
size={13}
className={isChecking ? "animate-spin" : undefined}
/>
{isChecking ? "Checking…" : "Retry"}
</button>
</div>
);
}

function BackOnlineRow() {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
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]"
>
<WifiHigh size={16} weight="duotone" className="shrink-0" />
<span className="font-medium">Back online</span>
</motion.div>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/features/connectivity/connectivity.module.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface ConnectivityStatusPayload {

export interface ConnectivityClient {
getStatus(): Promise<ConnectivityStatusPayload>;
checkNow(): Promise<ConnectivityStatusPayload>;
onStatusChange(sub: Subscriber<ConnectivityStatusPayload>): {
unsubscribe: () => void;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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();
}
});
}
}
Loading
Loading