diff --git a/surfsense_web/app/(home)/free/layout.tsx b/surfsense_web/app/(home)/free/layout.tsx
new file mode 100644
index 000000000..9447d34dd
--- /dev/null
+++ b/surfsense_web/app/(home)/free/layout.tsx
@@ -0,0 +1,18 @@
+import type { ReactNode } from "react";
+import { AdSenseScript } from "@/components/ads/adsense-script";
+
+/**
+ * Wraps the /free hub and all /free/[model_slug] subpages. Mounting
+ * here loads adsbygoogle.js across the entire /free route
+ * tree, which is what powers both the manual slots and AdSense
+ * Auto ads. Because the script lives here (not in the root layout), Auto ads
+ * is naturally scoped to /free and its subpages only.
+ */
+export default function FreeSectionLayout({ children }: { children: ReactNode }) {
+ return (
+ <>
+
+ {children}
+ >
+ );
+}
diff --git a/surfsense_web/app/(home)/free/page.tsx b/surfsense_web/app/(home)/free/page.tsx
index 5cea9b6d2..89a4735ae 100644
--- a/surfsense_web/app/(home)/free/page.tsx
+++ b/surfsense_web/app/(home)/free/page.tsx
@@ -3,7 +3,6 @@ import type { Metadata } from "next";
import Link from "next/link";
import { AdUnit } from "@/components/ads/ad-unit";
import { ADSENSE_SLOTS } from "@/components/ads/adsense-config";
-import { AdSenseScript } from "@/components/ads/adsense-script";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
import { Badge } from "@/components/ui/badge";
@@ -160,7 +159,6 @@ export default async function FreeHubPage() {
return (
- {
return (
-
-
- {greeting}
-
-
-
-
+
+
+
+ {greeting}
+
+
+
+
+
);
diff --git a/surfsense_web/components/free-chat/free-chat-page.tsx b/surfsense_web/components/free-chat/free-chat-page.tsx
index 2ee026cc3..b28b1e0a1 100644
--- a/surfsense_web/components/free-chat/free-chat-page.tsx
+++ b/surfsense_web/components/free-chat/free-chat-page.tsx
@@ -37,6 +37,7 @@ import { BACKEND_URL } from "@/lib/env-config";
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
import { FreeModelSelector } from "./free-model-selector";
import { FreeThread } from "./free-thread";
+import { RemoveAdsBanner } from "./remove-ads-banner";
// Render all tool calls via ToolFallback; backend keeps persisted
// payloads bounded by summarising / truncating outputs.
@@ -135,7 +136,7 @@ export function FreeChatPage() {
pendingRetryRef.current = null;
}, [resetKey, modelSlug, tokenUsageStore]);
- const cancelRun = useCallback(() => {
+ const cancelRun = useCallback(async () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
@@ -487,6 +488,8 @@ export function FreeChatPage() {
+
+
{captchaRequired && TURNSTILE_SITE_KEY && (
diff --git a/surfsense_web/components/free-chat/remove-ads-banner.tsx b/surfsense_web/components/free-chat/remove-ads-banner.tsx
new file mode 100644
index 000000000..143609c6b
--- /dev/null
+++ b/surfsense_web/components/free-chat/remove-ads-banner.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { Sparkles, X } from "lucide-react";
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+const ADSENSE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID;
+
+// Versioned key so the copy can change later without resurfacing for users who
+// already dismissed an older variant (bump the version to re-show).
+const DISMISS_KEY = "surfsense:remove-ads-banner-dismissed:v1";
+
+/**
+ * Dismissible promo shown on the free /free/[model_slug] chat pages, nudging
+ * anonymous users to sign up to remove ads. Dismissal is persisted in
+ * localStorage so it stays hidden across reloads and navigations. The free
+ * chat keeps working whether or not the banner is dismissed.
+ *
+ * Renders nothing when AdSense is not configured (dev/preview), since there are
+ * no ads to remove in that case.
+ */
+export function RemoveAdsBanner({ className }: { className?: string }) {
+ // Default hidden so dismissed users never see a flash before the stored
+ // value is read on the client (avoids a hydration/flicker mismatch).
+ const [dismissed, setDismissed] = useState(true);
+
+ useEffect(() => {
+ try {
+ setDismissed(localStorage.getItem(DISMISS_KEY) === "1");
+ } catch {
+ // localStorage can throw in private browsing / when disabled.
+ setDismissed(false);
+ }
+ }, []);
+
+ const handleDismiss = () => {
+ setDismissed(true);
+ try {
+ localStorage.setItem(DISMISS_KEY, "1");
+ } catch {
+ // Ignore: dismissal just won't persist across reloads.
+ }
+ };
+
+ if (!ADSENSE_CLIENT_ID || dismissed) return null;
+
+ return (
+
+
+
+ Go ad-free with a free account
+
+
+ Create a free SurfSense account to remove ads, unlock $5 of premium credit, and save
+ your chat history. You can keep chatting for free either way.
+