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
7 changes: 6 additions & 1 deletion packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -958,11 +958,16 @@ export interface ChannelsSpaceViewedProperties {

// Subscription / billing events

export type UpgradePromptShownSurface = "usage_limit_modal" | "upgrade_dialog";
export type UpgradePromptShownSurface =
| "usage_limit_modal"
| "upgrade_dialog"
| "titlebar_card";

export type UpgradePromptClickedSurface =
| "usage_limit_modal"
| "sidebar"
| "titlebar"
| "titlebar_card"
| "plan_page_card"
| "upgrade_dialog";

Expand Down
23 changes: 21 additions & 2 deletions packages/shared/src/backoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,31 @@ export function getBackoffDelay(
}

/**
* Sleep with exponential backoff delay
* Sleep with exponential backoff delay.
*
* Pass an AbortSignal to make the sleep cancelable: on abort the timer is
* cleared and the promise resolves immediately (it never rejects), so a
* retry loop can bail out on its own `signal.aborted` check.
*/
export function sleepWithBackoff(
attempt: number,
options: BackoffOptions,
signal?: AbortSignal,
): Promise<void> {
const delay = getBackoffDelay(attempt, options);
return new Promise((resolve) => setTimeout(resolve, delay));
return new Promise((resolve) => {
if (signal?.aborted) {
resolve();
return;
}
const onAbort = () => {
clearTimeout(timer);
resolve();
};
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, delay);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
77 changes: 0 additions & 77 deletions packages/ui/src/features/billing/SidebarUsageBar.tsx

This file was deleted.

153 changes: 153 additions & 0 deletions packages/ui/src/features/billing/UsageButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { Circle } from "@phosphor-icons/react";
import {
formatResetTime,
isUsageExceeded,
} from "@posthog/core/billing/usageDisplay";
import {
Button,
Popover,
PopoverContent,
PopoverTrigger,
Progress,
} from "@posthog/quill";
import { BILLING_FLAG } from "@posthog/shared";
import {
ANALYTICS_EVENTS,
type UpgradePromptClickedSurface,
} from "@posthog/shared/analytics-events";
import { Link } from "@tanstack/react-router";
import { useState } from "react";
import { track } from "../../shell/analytics";
import { useFeatureFlag } from "../feature-flags/useFeatureFlag";
import {
openSettings,
prepareSettingsPage,
} from "../settings/hooks/useOpenSettings";
import { useFreeUsage } from "./useFreeUsage";

// Title-bar usage entry point (replaces the old sidebar usage bar): a compact
// "Usage: N%" button whose hover card carries the full plan card — plan name,
// progress bar, reset time, and the Upgrade action. Built on quill's Popover
// with `openOnHover` on the trigger, so it behaves as a hover card (Base UI
// keeps it open while the pointer travels into the card to click Upgrade).
// The card body styles with quill tokens (foreground/muted-foreground/primary,
// quill Progress) — radix scale classes don't resolve inside the data-quill
// popover portal.
export function UsageButton() {
const billingEnabled = useFeatureFlag(BILLING_FLAG);
const { usage, isLoading } = useFreeUsage(billingEnabled);
// Controlled so the trigger click can close the card before navigating to
// settings — uncontrolled, the same click would also toggle the popover open
// over the settings view. Hover open/close still flows through onOpenChange.
const [open, setOpen] = useState(false);

if (!billingEnabled) return null;

// Same-size placeholder while usage loads, so the button doesn't pop in and
// shift the PostHog Web button after boot.
if (!usage) {
if (!isLoading) return null;
return (
<Button variant="outline" size="sm" disabled aria-hidden>
<span className="animate-pulse">Usage: --%</span>
</Button>
);
}

const exceeded = isUsageExceeded(usage);
const dominant =
usage.sustained.used_percent >= usage.burst.used_percent
? usage.sustained
: usage.burst;
const usagePercent = Math.min(Math.round(dominant.used_percent), 100);
const resetLabel = formatResetTime(dominant.reset_at);

const handleOpenChange = (nextOpen: boolean) => {
// The hover card has real impressions now (the old sidebar bar was
// always-visible); count each open as a shown upgrade prompt.
if (nextOpen && !open) {
track(ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN, {
surface: "titlebar_card",
});
}
setOpen(nextOpen);
};

const handleOpenPlan = (surface: UpgradePromptClickedSurface) => {
track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { surface });
setOpen(false);
openSettings("plan-usage");
};

// The trigger is a real <Link> (render={<Link/>} per convention), so the
// router owns the navigation; this click handler carries the side effects
// openSettings would have done — tracking, closing the card, and resetting
// the settings-page store so no stale context/one-shot action leaks in.
const handleTriggerClick = () => {
track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { surface: "titlebar" });
setOpen(false);
prepareSettingsPage();
};

return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
openOnHover
delay={300}
closeDelay={150}
render={
<Button
variant="outline"
size="sm"
render={
<Link
to="/settings/$category"
params={{ category: "plan-usage" }}
onClick={handleTriggerClick}
/>
}
>
{exceeded ? "Usage: limit reached" : `Usage: ${usagePercent}%`}
</Button>
}
/>
{/* no-drag: the popover opens under the title bar's drag region; without
the opt-out a click near its top edge is swallowed as a window drag. */}
<PopoverContent
side="bottom"
align="end"
sideOffset={6}
className="no-drag gap-2"
>
<div className="flex items-center justify-between">
<span className="font-medium text-foreground text-xs">
Free plan
<Circle
size={4}
weight="fill"
className="mx-1.5 inline text-muted-foreground"
/>
<span className="font-normal text-muted-foreground">
{exceeded ? "Limit reached" : `${usagePercent}% used`}
</span>
</span>
<Button
variant="link"
size="sm"
className="h-auto p-0"
onClick={() => handleOpenPlan("titlebar_card")}
>
Upgrade
</Button>
</div>
<Progress
value={usagePercent}
variant={exceeded ? "destructive" : "default"}
/>
<div className="font-normal text-[11px] text-muted-foreground">
{resetLabel}
</div>
</PopoverContent>
</Popover>
);
}
54 changes: 48 additions & 6 deletions packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
SquaresFourIcon,
TrayIcon,
} from "@phosphor-icons/react";
import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger";
import { useService } from "@posthog/di/react";
import { useHostTRPC } from "@posthog/host-router/react";
import {
closeTab as closeTabLocal,
Expand Down Expand Up @@ -59,7 +61,12 @@ import { usePinnedTabsStore } from "./pinnedTabsStore";
import { TabStrip, type TabView } from "./TabStrip";
import { TaskTabIcon } from "./TaskTabIcon";
import { useTabReorderStore } from "./tabReorderStore";
import { applyLocalTransform, persistWrite, readMirror } from "./tabsSync";
import {
applyLocalTransform,
persistWrite,
readMirror,
reseedMirror,
} from "./tabsSync";
import { useTabsSnapshot } from "./useBrowserTabs";

/** The active tab id is carried in router history state so back/forward replay
Expand Down Expand Up @@ -153,6 +160,7 @@ function isAppView(value: string): value is AppView {
}

export function BrowserTabStrip() {
const logger = useService<RootLogger>(ROOT_LOGGER);
const snapshot = useTabsSnapshot();
const navigate = useNavigate();
const router = useRouter();
Expand Down Expand Up @@ -750,16 +758,50 @@ export function BrowserTabStrip() {
// mirror and navigate in the same tick (no IPC wait), then persist with the
// same id so the durable state matches. The service is idempotent on the
// minted id, so a replay can't append a duplicate.
const handleNewTab = () => {
if (!windowId) return;
const createBlankTab = (targetWindowId: string) => {
const tabId = crypto.randomUUID();
applyLocalTransform(
(s) =>
newBlankTabLocal(s, { windowId, makeId: () => tabId, now: Date.now })
.snapshot,
newBlankTabLocal(s, {
windowId: targetWindowId,
makeId: () => tabId,
now: Date.now,
}).snapshot,
);
landOnDefault(tabId);
void persistWrite(() => newBlankTab.mutateAsync({ windowId, tabId }));
void persistWrite(() =>
newBlankTab.mutateAsync({ windowId: targetWindowId, tabId }),
);
};

const handleNewTab = () => {
if (windowId) {
createBlankTab(windowId);
return;
}
// No window means the mirror never seeded (the boot fetch raced or
// failed) — the click must not die. Re-pull the authoritative snapshot
// (the server always has a primary window) and append into it. Resolve
// the window from the FETCHED snapshot, not the mirror: reseedMirror
// skips the store apply when a local write or newer remote push raced
// the fetch, and the mirror could still be windowless then.
void reseedMirror()
.then((server) => {
const win = server
? primaryWindow(server)
: primaryWindow(readMirror());
if (win) {
createBlankTab(win.id);
return;
}
// Should be unreachable (the server always mints a primary window),
// but a silent skip here reproduces the dead-"+" this path exists to
// fix — make it loud instead.
logger.error("browser-tabs: new-tab found no window after reseed");
})
.catch((error) => {
logger.error("browser-tabs: new-tab reseed failed", { error });
});
};

// Cmd/Ctrl+T opens a new browser tab. Bound here (not globally) so it only
Expand Down
Loading
Loading