Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit d7de367

Browse files
adamleithpclaude
andauthored
fix(tabs): repair stale browser-tabs schema, never-empty strip; move usage to title bar (#3345)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e687cf6 commit d7de367

18 files changed

Lines changed: 1638 additions & 121 deletions

File tree

packages/shared/src/analytics-events.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -963,11 +963,16 @@ export interface ChannelsSpaceViewedProperties {
963963

964964
// Subscription / billing events
965965

966-
export type UpgradePromptShownSurface = "usage_limit_modal" | "upgrade_dialog";
966+
export type UpgradePromptShownSurface =
967+
| "usage_limit_modal"
968+
| "upgrade_dialog"
969+
| "titlebar_card";
967970

968971
export type UpgradePromptClickedSurface =
969972
| "usage_limit_modal"
970973
| "sidebar"
974+
| "titlebar"
975+
| "titlebar_card"
971976
| "plan_page_card"
972977
| "upgrade_dialog";
973978

packages/shared/src/backoff.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,31 @@ export function getBackoffDelay(
2020
}
2121

2222
/**
23-
* Sleep with exponential backoff delay
23+
* Sleep with exponential backoff delay.
24+
*
25+
* Pass an AbortSignal to make the sleep cancelable: on abort the timer is
26+
* cleared and the promise resolves immediately (it never rejects), so a
27+
* retry loop can bail out on its own `signal.aborted` check.
2428
*/
2529
export function sleepWithBackoff(
2630
attempt: number,
2731
options: BackoffOptions,
32+
signal?: AbortSignal,
2833
): Promise<void> {
2934
const delay = getBackoffDelay(attempt, options);
30-
return new Promise((resolve) => setTimeout(resolve, delay));
35+
return new Promise((resolve) => {
36+
if (signal?.aborted) {
37+
resolve();
38+
return;
39+
}
40+
const onAbort = () => {
41+
clearTimeout(timer);
42+
resolve();
43+
};
44+
const timer = setTimeout(() => {
45+
signal?.removeEventListener("abort", onAbort);
46+
resolve();
47+
}, delay);
48+
signal?.addEventListener("abort", onAbort, { once: true });
49+
});
3150
}

packages/ui/src/features/billing/SidebarUsageBar.tsx

Lines changed: 0 additions & 77 deletions
This file was deleted.
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { Circle } from "@phosphor-icons/react";
2+
import {
3+
formatResetTime,
4+
isUsageExceeded,
5+
} from "@posthog/core/billing/usageDisplay";
6+
import {
7+
Button,
8+
Popover,
9+
PopoverContent,
10+
PopoverTrigger,
11+
Progress,
12+
} from "@posthog/quill";
13+
import { BILLING_FLAG } from "@posthog/shared";
14+
import {
15+
ANALYTICS_EVENTS,
16+
type UpgradePromptClickedSurface,
17+
} from "@posthog/shared/analytics-events";
18+
import { Link } from "@tanstack/react-router";
19+
import { useState } from "react";
20+
import { track } from "../../shell/analytics";
21+
import { useFeatureFlag } from "../feature-flags/useFeatureFlag";
22+
import {
23+
openSettings,
24+
prepareSettingsPage,
25+
} from "../settings/hooks/useOpenSettings";
26+
import { useFreeUsage } from "./useFreeUsage";
27+
28+
// Title-bar usage entry point (replaces the old sidebar usage bar): a compact
29+
// "Usage: N%" button whose hover card carries the full plan card — plan name,
30+
// progress bar, reset time, and the Upgrade action. Built on quill's Popover
31+
// with `openOnHover` on the trigger, so it behaves as a hover card (Base UI
32+
// keeps it open while the pointer travels into the card to click Upgrade).
33+
// The card body styles with quill tokens (foreground/muted-foreground/primary,
34+
// quill Progress) — radix scale classes don't resolve inside the data-quill
35+
// popover portal.
36+
export function UsageButton() {
37+
const billingEnabled = useFeatureFlag(BILLING_FLAG);
38+
const { usage, isLoading } = useFreeUsage(billingEnabled);
39+
// Controlled so the trigger click can close the card before navigating to
40+
// settings — uncontrolled, the same click would also toggle the popover open
41+
// over the settings view. Hover open/close still flows through onOpenChange.
42+
const [open, setOpen] = useState(false);
43+
44+
if (!billingEnabled) return null;
45+
46+
// Same-size placeholder while usage loads, so the button doesn't pop in and
47+
// shift the PostHog Web button after boot.
48+
if (!usage) {
49+
if (!isLoading) return null;
50+
return (
51+
<Button variant="outline" size="sm" disabled aria-hidden>
52+
<span className="animate-pulse">Usage: --%</span>
53+
</Button>
54+
);
55+
}
56+
57+
const exceeded = isUsageExceeded(usage);
58+
const dominant =
59+
usage.sustained.used_percent >= usage.burst.used_percent
60+
? usage.sustained
61+
: usage.burst;
62+
const usagePercent = Math.min(Math.round(dominant.used_percent), 100);
63+
const resetLabel = formatResetTime(dominant.reset_at);
64+
65+
const handleOpenChange = (nextOpen: boolean) => {
66+
// The hover card has real impressions now (the old sidebar bar was
67+
// always-visible); count each open as a shown upgrade prompt.
68+
if (nextOpen && !open) {
69+
track(ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN, {
70+
surface: "titlebar_card",
71+
});
72+
}
73+
setOpen(nextOpen);
74+
};
75+
76+
const handleOpenPlan = (surface: UpgradePromptClickedSurface) => {
77+
track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { surface });
78+
setOpen(false);
79+
openSettings("plan-usage");
80+
};
81+
82+
// The trigger is a real <Link> (render={<Link/>} per convention), so the
83+
// router owns the navigation; this click handler carries the side effects
84+
// openSettings would have done — tracking, closing the card, and resetting
85+
// the settings-page store so no stale context/one-shot action leaks in.
86+
const handleTriggerClick = () => {
87+
track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { surface: "titlebar" });
88+
setOpen(false);
89+
prepareSettingsPage();
90+
};
91+
92+
return (
93+
<Popover open={open} onOpenChange={handleOpenChange}>
94+
<PopoverTrigger
95+
openOnHover
96+
delay={300}
97+
closeDelay={150}
98+
render={
99+
<Button
100+
variant="outline"
101+
size="sm"
102+
render={
103+
<Link
104+
to="/settings/$category"
105+
params={{ category: "plan-usage" }}
106+
onClick={handleTriggerClick}
107+
/>
108+
}
109+
>
110+
{exceeded ? "Usage: limit reached" : `Usage: ${usagePercent}%`}
111+
</Button>
112+
}
113+
/>
114+
{/* no-drag: the popover opens under the title bar's drag region; without
115+
the opt-out a click near its top edge is swallowed as a window drag. */}
116+
<PopoverContent
117+
side="bottom"
118+
align="end"
119+
sideOffset={6}
120+
className="no-drag gap-2"
121+
>
122+
<div className="flex items-center justify-between">
123+
<span className="font-medium text-foreground text-xs">
124+
Free plan
125+
<Circle
126+
size={4}
127+
weight="fill"
128+
className="mx-1.5 inline text-muted-foreground"
129+
/>
130+
<span className="font-normal text-muted-foreground">
131+
{exceeded ? "Limit reached" : `${usagePercent}% used`}
132+
</span>
133+
</span>
134+
<Button
135+
variant="link"
136+
size="sm"
137+
className="h-auto p-0"
138+
onClick={() => handleOpenPlan("titlebar_card")}
139+
>
140+
Upgrade
141+
</Button>
142+
</div>
143+
<Progress
144+
value={usagePercent}
145+
variant={exceeded ? "destructive" : "default"}
146+
/>
147+
<div className="font-normal text-[11px] text-muted-foreground">
148+
{resetLabel}
149+
</div>
150+
</PopoverContent>
151+
</Popover>
152+
);
153+
}

packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
SquaresFourIcon,
88
TrayIcon,
99
} from "@phosphor-icons/react";
10+
import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger";
11+
import { useService } from "@posthog/di/react";
1012
import { useHostTRPC } from "@posthog/host-router/react";
1113
import {
1214
closeTab as closeTabLocal,
@@ -59,7 +61,12 @@ import { usePinnedTabsStore } from "./pinnedTabsStore";
5961
import { TabStrip, type TabView } from "./TabStrip";
6062
import { TaskTabIcon } from "./TaskTabIcon";
6163
import { useTabReorderStore } from "./tabReorderStore";
62-
import { applyLocalTransform, persistWrite, readMirror } from "./tabsSync";
64+
import {
65+
applyLocalTransform,
66+
persistWrite,
67+
readMirror,
68+
reseedMirror,
69+
} from "./tabsSync";
6370
import { useTabsSnapshot } from "./useBrowserTabs";
6471

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

155162
export function BrowserTabStrip() {
163+
const logger = useService<RootLogger>(ROOT_LOGGER);
156164
const snapshot = useTabsSnapshot();
157165
const navigate = useNavigate();
158166
const router = useRouter();
@@ -750,16 +758,50 @@ export function BrowserTabStrip() {
750758
// mirror and navigate in the same tick (no IPC wait), then persist with the
751759
// same id so the durable state matches. The service is idempotent on the
752760
// minted id, so a replay can't append a duplicate.
753-
const handleNewTab = () => {
754-
if (!windowId) return;
761+
const createBlankTab = (targetWindowId: string) => {
755762
const tabId = crypto.randomUUID();
756763
applyLocalTransform(
757764
(s) =>
758-
newBlankTabLocal(s, { windowId, makeId: () => tabId, now: Date.now })
759-
.snapshot,
765+
newBlankTabLocal(s, {
766+
windowId: targetWindowId,
767+
makeId: () => tabId,
768+
now: Date.now,
769+
}).snapshot,
760770
);
761771
landOnDefault(tabId);
762-
void persistWrite(() => newBlankTab.mutateAsync({ windowId, tabId }));
772+
void persistWrite(() =>
773+
newBlankTab.mutateAsync({ windowId: targetWindowId, tabId }),
774+
);
775+
};
776+
777+
const handleNewTab = () => {
778+
if (windowId) {
779+
createBlankTab(windowId);
780+
return;
781+
}
782+
// No window means the mirror never seeded (the boot fetch raced or
783+
// failed) — the click must not die. Re-pull the authoritative snapshot
784+
// (the server always has a primary window) and append into it. Resolve
785+
// the window from the FETCHED snapshot, not the mirror: reseedMirror
786+
// skips the store apply when a local write or newer remote push raced
787+
// the fetch, and the mirror could still be windowless then.
788+
void reseedMirror()
789+
.then((server) => {
790+
const win = server
791+
? primaryWindow(server)
792+
: primaryWindow(readMirror());
793+
if (win) {
794+
createBlankTab(win.id);
795+
return;
796+
}
797+
// Should be unreachable (the server always mints a primary window),
798+
// but a silent skip here reproduces the dead-"+" this path exists to
799+
// fix — make it loud instead.
800+
logger.error("browser-tabs: new-tab found no window after reseed");
801+
})
802+
.catch((error) => {
803+
logger.error("browser-tabs: new-tab reseed failed", { error });
804+
});
763805
};
764806

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

0 commit comments

Comments
 (0)