Skip to content
Open
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
26 changes: 25 additions & 1 deletion mcpjam-inspector/client/src/components/mcp-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { MCPIcon } from "@/components/ui/mcp-icon";
import { SidebarUser } from "@/components/sidebar/sidebar-user";
import { SidebarContextSwitcher } from "@/components/sidebar/sidebar-context-switcher";
import { SidebarCreditUsage } from "@/components/sidebar/sidebar-credit-usage";
import { SidebarTrialCountdown } from "@/components/sidebar/sidebar-trial-countdown";
import { ShareProjectDialog } from "@/components/project/ShareProjectDialog";
import { useUpdateNotification } from "@/hooks/useUpdateNotification";
import { Badge } from "@mcpjam/design-system/badge";
Expand All @@ -69,7 +70,10 @@ import { withTestingSurface } from "@/lib/testing-surface";
import { HOSTED_LOCAL_ONLY_TOOLTIP } from "@/lib/hosted-ui";
import { useLearnMore } from "@/hooks/use-learn-more";
import { LearnMoreExpandedPanel } from "@/components/learn-more/LearnMoreExpandedPanel";
import type { BillingFeatureName } from "@/hooks/useOrganizationBilling";
import {
useOrganizationBillingStatus,
type BillingFeatureName,
} from "@/hooks/useOrganizationBilling";
import type { Project } from "@/state/app-types";
import type { OrganizationRouteSection } from "@/lib/hosted-navigation";

Expand Down Expand Up @@ -569,6 +573,18 @@ export function MCPSidebar({
);
}, [activeProject?.organizationId, projects]);
const shouldShowInviteCta = isAuthenticated && !!user && !!activeProject;
const trialBilling = useOrganizationBillingStatus(
activeProject?.organizationId ?? null,
{ enabled: billingUiEnabled && !!activeProject?.organizationId },
);
const trialActive =
billingUiEnabled &&
trialBilling?.trialStatus === "active" &&
!!trialBilling.trialEndsAt;
const handleTrialUpgradeClick = () => {
if (!activeProject?.organizationId) return;
window.location.hash = `#organizations/${activeProject.organizationId}/billing`;
};

const handleNavClick = (url: string) => {
if (onNavigate && url.startsWith("#")) {
Expand Down Expand Up @@ -758,6 +774,14 @@ export function MCPSidebar({
</SidebarMenuItem>
</SidebarMenu>
) : null}
{shouldShowInviteCta && trialActive && trialBilling?.trialEndsAt ? (
<SidebarTrialCountdown
trialEndsAt={trialBilling.trialEndsAt}
trialStartedAt={trialBilling.trialStartedAt}
onUpgradeClick={handleTrialUpgradeClick}
className="mt-1"
/>
) : null}
{!user ? (
<SidebarCreditUsage className="px-1" includeGuests />
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const mockFeatureFlags: Record<string, boolean | undefined> = {};

vi.mock("convex/react", () => ({
useConvexAuth: (...args: unknown[]) => mockUseConvexAuth(...args),
useQuery: () => undefined,
}));

vi.mock("@workos-inc/authkit-react", () => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";

interface SidebarTrialCountdownProps {
trialEndsAt: number;
trialStartedAt: number | null;
onUpgradeClick?: () => void;
className?: string;
}

export function SidebarTrialCountdown({
trialEndsAt,
trialStartedAt,
onUpgradeClick,
className,
}: SidebarTrialCountdownProps) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const remaining = trialEndsAt - Date.now();
const interval = remaining < 60 * 60 * 1000 ? 1_000 : 60_000;
const id = window.setInterval(() => setNow(Date.now()), interval);
return () => window.clearInterval(id);
}, [trialEndsAt]);
Comment on lines +18 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Countdown cadence never tightens in the final hour.

Line 20 computes the interval only once per trialEndsAt, so a trial that starts with >1h left keeps a 60s tick even when the UI shows seconds. Re-schedule based on current remaining time each tick.

Suggested fix
-  useEffect(() => {
-    const remaining = trialEndsAt - Date.now();
-    const interval = remaining < 60 * 60 * 1000 ? 1_000 : 60_000;
-    const id = window.setInterval(() => setNow(Date.now()), interval);
-    return () => window.clearInterval(id);
-  }, [trialEndsAt]);
+  useEffect(() => {
+    const remaining = trialEndsAt - now;
+    if (remaining <= 0) return;
+
+    const delay = remaining < 60 * 60 * 1000 ? 1_000 : 60_000;
+    const id = window.setTimeout(() => setNow(Date.now()), delay);
+    return () => window.clearTimeout(id);
+  }, [now, trialEndsAt]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
const remaining = trialEndsAt - Date.now();
const interval = remaining < 60 * 60 * 1000 ? 1_000 : 60_000;
const id = window.setInterval(() => setNow(Date.now()), interval);
return () => window.clearInterval(id);
}, [trialEndsAt]);
useEffect(() => {
const remaining = trialEndsAt - now;
if (remaining <= 0) return;
const delay = remaining < 60 * 60 * 1000 ? 1_000 : 60_000;
const id = window.setTimeout(() => setNow(Date.now()), delay);
return () => window.clearTimeout(id);
}, [now, trialEndsAt]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcpjam-inspector/client/src/components/sidebar/sidebar-trial-countdown.tsx`
around lines 18 - 23, In the sidebar-trial-countdown component useEffect, the
interval is computed once from trialEndsAt so the tick cadence never tightens
when remaining time drops below an hour; change the logic to schedule/reschedule
on each tick (for example replace the fixed setInterval with a self-rescheduling
timer or clear-and-set a new timer inside the callback) so you compute remaining
= trialEndsAt - Date.now() on every tick and choose 1000ms or 60000ms
accordingly; ensure you still call setNow(Date.now()) each tick and properly
clear the timeout/interval in the cleanup to avoid leaks (refer to useEffect,
setNow, the interval id variable).


const remainingMs = Math.max(0, trialEndsAt - now);
const totalMs =
trialStartedAt && trialEndsAt > trialStartedAt
? trialEndsAt - trialStartedAt
: remainingMs || 1;
const elapsedPct = Math.min(
100,
Math.max(0, ((totalMs - remainingMs) / totalMs) * 100),
);

const Wrapper: "button" | "div" = onUpgradeClick ? "button" : "div";

return (
<div
data-testid="sidebar-trial-countdown"
aria-label="Trial countdown"
className={cn("group-data-[collapsible=icon]:hidden", className)}
>
<Wrapper
{...(onUpgradeClick
? {
type: "button" as const,
onClick: onUpgradeClick,
"aria-label": "Trial — upgrade",
}
: {})}
className={cn(
"flex w-full flex-col gap-1 rounded-md px-2 py-1 text-left",
onUpgradeClick &&
"transition-colors hover:bg-sidebar-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
)}
>
<div className="flex items-baseline justify-between gap-2 leading-none">
<span className="text-[11px] text-foreground">Trial</span>
<span className="shrink-0 text-[11px] text-foreground">
{formatRemaining(remainingMs)}
</span>
</div>
<div className="relative h-[3px] w-full overflow-hidden rounded-full bg-primary/15">
<div
className="h-full rounded-full bg-gradient-to-r from-primary/80 to-primary transition-[width] duration-500"
style={{ width: `${elapsedPct}%` }}
/>
</div>
</Wrapper>
</div>
);
}

function formatRemaining(ms: number): string {
if (ms <= 0) return "expired";
const totalSec = Math.floor(ms / 1000);
const days = Math.floor(totalSec / 86_400);
const hours = Math.floor((totalSec % 86_400) / 3_600);
const minutes = Math.floor((totalSec % 3_600) / 60);
const seconds = totalSec % 60;

if (days >= 1) return `${days}d ${hours}h ${minutes}m`;
if (hours >= 1) return `${hours}h ${minutes}m ${seconds}s`;
if (minutes >= 1) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
Loading