diff --git a/surfsense_backend/app/capabilities/core/access/rest.py b/surfsense_backend/app/capabilities/core/access/rest.py
index 328a7c5a1..2047b21ae 100644
--- a/surfsense_backend/app/capabilities/core/access/rest.py
+++ b/surfsense_backend/app/capabilities/core/access/rest.py
@@ -71,6 +71,7 @@ class CapabilitySummary(BaseModel):
name: str
description: str
+ docs_url: str | None = None
input_schema: dict
output_schema: dict
# Empty list = free (billing disabled or an unmetered verb).
@@ -145,6 +146,7 @@ def _register_capabilities_list(
CapabilitySummary(
name=capability.name,
description=capability.description,
+ docs_url=capability.docs_url,
input_schema=capability.input_schema.model_json_schema(),
output_schema=capability.output_schema.model_json_schema(),
),
diff --git a/surfsense_backend/app/capabilities/core/types.py b/surfsense_backend/app/capabilities/core/types.py
index ec44d29aa..c87601832 100644
--- a/surfsense_backend/app/capabilities/core/types.py
+++ b/surfsense_backend/app/capabilities/core/types.py
@@ -62,3 +62,4 @@ class Capability:
output_schema: type[BaseModel]
executor: Executor
billing_unit: BillingUnit | None
+ docs_url: str | None = None
diff --git a/surfsense_backend/app/capabilities/google_maps/reviews/definition.py b/surfsense_backend/app/capabilities/google_maps/reviews/definition.py
index e96c6f906..5366194d5 100644
--- a/surfsense_backend/app/capabilities/google_maps/reviews/definition.py
+++ b/surfsense_backend/app/capabilities/google_maps/reviews/definition.py
@@ -10,15 +10,14 @@ from app.capabilities.google_maps.reviews.schemas import ReviewsInput, ReviewsOu
GOOGLE_MAPS_REVIEWS = Capability(
name="google_maps.reviews",
description=(
- "Fetch public reviews for one or more Google Maps places. Give it place "
- "URLs or place IDs; returns structured review items with author, text, "
- "star rating, like count, owner response, and timestamps. Use it to "
- "gauge sentiment or pull recent feedback on specific places."
+ "Fetch public Google Maps reviews with authors, ratings, text, and "
+ "owner responses. Use urls or place IDs."
),
input_schema=ReviewsInput,
output_schema=ReviewsOutput,
executor=build_reviews_executor(),
billing_unit=BillingUnit.GOOGLE_MAPS_REVIEW,
+ docs_url="/docs/connectors/native/google-maps",
)
register_capability(GOOGLE_MAPS_REVIEWS)
diff --git a/surfsense_backend/app/capabilities/google_maps/scrape/definition.py b/surfsense_backend/app/capabilities/google_maps/scrape/definition.py
index 035b7e00d..db117a2ac 100644
--- a/surfsense_backend/app/capabilities/google_maps/scrape/definition.py
+++ b/surfsense_backend/app/capabilities/google_maps/scrape/definition.py
@@ -11,17 +11,14 @@ from app.capabilities.google_maps.scrape.schemas import ScrapeInput, ScrapeOutpu
GOOGLE_MAPS_SCRAPE = Capability(
name="google_maps.scrape",
description=(
- "Scrape public Google Maps places. Give it search queries (optionally "
- "scoped by location), Google Maps URLs, or place IDs, and it returns "
- "structured place items — name, address, category, phone, website, "
- "rating, review count, coordinates, and opening hours. Set "
- "include_details for richer detail-page fields, or max_reviews/"
- "max_images to attach reviews and photos per place."
+ "Scrape public Google Maps places, details, reviews, and photos. Use "
+ "search_queries, urls, or place IDs."
),
input_schema=ScrapeInput,
output_schema=ScrapeOutput,
executor=build_scrape_executor(),
billing_unit=BillingUnit.GOOGLE_MAPS_PLACE,
+ docs_url="/docs/connectors/native/google-maps",
)
register_capability(GOOGLE_MAPS_SCRAPE)
diff --git a/surfsense_backend/app/capabilities/google_search/scrape/definition.py b/surfsense_backend/app/capabilities/google_search/scrape/definition.py
index 2f2c9de03..2f62847d1 100644
--- a/surfsense_backend/app/capabilities/google_search/scrape/definition.py
+++ b/surfsense_backend/app/capabilities/google_search/scrape/definition.py
@@ -10,16 +10,14 @@ from app.capabilities.google_search.scrape.schemas import ScrapeInput, ScrapeOut
GOOGLE_SEARCH_SCRAPE = Capability(
name="google_search.scrape",
description=(
- "Search Google and return structured results. Give it search terms "
- "(optionally scoped by country/language or to a single site) or full "
- "Google Search URLs, and it returns SERP items — organic results "
- "(title, url, description), related queries, people-also-ask, and any "
- "AI overview. Use max_pages_per_query to page deeper."
+ "Search Google and return structured SERP results. Use search_queries "
+ "or Google Search URLs."
),
input_schema=ScrapeInput,
output_schema=ScrapeOutput,
executor=build_scrape_executor(),
billing_unit=BillingUnit.GOOGLE_SEARCH_SERP,
+ docs_url="/docs/connectors/native/google-search",
)
register_capability(GOOGLE_SEARCH_SCRAPE)
diff --git a/surfsense_backend/app/capabilities/reddit/scrape/definition.py b/surfsense_backend/app/capabilities/reddit/scrape/definition.py
index 01fb6db5f..fe00a77be 100644
--- a/surfsense_backend/app/capabilities/reddit/scrape/definition.py
+++ b/surfsense_backend/app/capabilities/reddit/scrape/definition.py
@@ -10,16 +10,14 @@ from app.capabilities.reddit.scrape.schemas import ScrapeInput, ScrapeOutput
REDDIT_SCRAPE = Capability(
name="reddit.scrape",
description=(
- "Scrape public Reddit data. Give it Reddit URLs (post, subreddit, or "
- "user) and/or search terms, and it returns structured items — posts "
- "(title, body, score, comment count, subreddit, author), their comments, "
- "and community/user metadata. Use search_queries (optionally scoped to a "
- "community) to discover posts, or urls to pull a known post/subreddit/user."
+ "Scrape public Reddit posts, comments, and metadata. Use urls or "
+ "search_queries."
),
input_schema=ScrapeInput,
output_schema=ScrapeOutput,
executor=build_scrape_executor(),
billing_unit=BillingUnit.REDDIT_ITEM,
+ docs_url="/docs/connectors/native/reddit",
)
register_capability(REDDIT_SCRAPE)
diff --git a/surfsense_backend/app/capabilities/web/crawl/definition.py b/surfsense_backend/app/capabilities/web/crawl/definition.py
index cd3db2306..362f4bcd0 100644
--- a/surfsense_backend/app/capabilities/web/crawl/definition.py
+++ b/surfsense_backend/app/capabilities/web/crawl/definition.py
@@ -9,29 +9,14 @@ from app.capabilities.web.crawl.schemas import CrawlInput, CrawlOutput
WEB_CRAWL = Capability(
name="web.crawl",
description=(
- "Scrape a single web page or crawl a whole website. Give it one or more "
- "startUrls. Set maxCrawlDepth=0 to fetch just those URLs, or higher to "
- "also follow the links on each page (depth 1 = the start pages plus the "
- "pages they link to, and so on) — staying on the same site and stopping "
- "at maxCrawlPages. On a deeper crawl, narrow which links are followed with "
- "includeUrlPatterns / excludeUrlPatterns (regexes). Returns one item per "
- "fetched page with clean markdown content, metadata (title, description), "
- "crawl provenance, every link with its anchor text and kind "
- "(internal/external/social/email/tel — use the text/context to tie a "
- "profile URL to a person or company), and contact signals (emails, phone "
- "numbers, social profiles). The site-wide contacts summary deduplicates "
- "them with provenance: siteWide=true marks footer/header values (the "
- "company's own contacts) vs page-local finds (e.g. team members' "
- "profiles). Useful for lead generation and competitive intelligence; "
- "contact details often live on about/contact/privacy pages, so crawl "
- "with maxCrawlDepth >= 1 to surface them. JS-rendered pages are loaded "
- "in a real browser and auto-scrolled, so lazy-loaded listings "
- "(directories, infinite-scroll feeds) are captured too."
+ "Scrape pages or crawl websites for clean markdown, links, metadata, "
+ "and contact signals. Use startUrls and crawl-depth controls."
),
input_schema=CrawlInput,
output_schema=CrawlOutput,
executor=build_crawl_executor(),
billing_unit=BillingUnit.WEB_CRAWL,
+ docs_url="/docs/connectors/native/web-crawl",
)
register_capability(WEB_CRAWL)
diff --git a/surfsense_backend/app/capabilities/youtube/comments/definition.py b/surfsense_backend/app/capabilities/youtube/comments/definition.py
index ec689a23c..c22145547 100644
--- a/surfsense_backend/app/capabilities/youtube/comments/definition.py
+++ b/surfsense_backend/app/capabilities/youtube/comments/definition.py
@@ -10,15 +10,14 @@ from app.capabilities.youtube.comments.schemas import CommentsInput, CommentsOut
YOUTUBE_COMMENTS = Capability(
name="youtube.comments",
description=(
- "Fetch public comments (and their replies) for one or more YouTube "
- "videos. Give it the video URLs; returns structured comment items with "
- "author, text, like count, reply relationships, and timestamps. Use it "
- "to gauge sentiment or pull discussion on specific videos."
+ "Fetch public YouTube comments and replies with authors, text, likes, "
+ "and timestamps. Use video URLs."
),
input_schema=CommentsInput,
output_schema=CommentsOutput,
executor=build_comments_executor(),
billing_unit=BillingUnit.YOUTUBE_COMMENT,
+ docs_url="/docs/connectors/native/youtube",
)
register_capability(YOUTUBE_COMMENTS)
diff --git a/surfsense_backend/app/capabilities/youtube/scrape/definition.py b/surfsense_backend/app/capabilities/youtube/scrape/definition.py
index 43874e254..d281ea7d6 100644
--- a/surfsense_backend/app/capabilities/youtube/scrape/definition.py
+++ b/surfsense_backend/app/capabilities/youtube/scrape/definition.py
@@ -10,16 +10,14 @@ from app.capabilities.youtube.scrape.schemas import ScrapeInput, ScrapeOutput
YOUTUBE_SCRAPE = Capability(
name="youtube.scrape",
description=(
- "Scrape public YouTube data. Give it YouTube URLs (video, channel, "
- "playlist, shorts, or hashtag) and/or search queries, and it returns "
- "structured video items — title, views, likes, publish date, channel "
- "info, description, and optionally subtitles. Use search_queries to "
- "discover videos, or urls to pull a known video/channel/playlist."
+ "Scrape public YouTube videos, channels, playlists, and subtitles. Use "
+ "urls or search_queries."
),
input_schema=ScrapeInput,
output_schema=ScrapeOutput,
executor=build_scrape_executor(),
billing_unit=BillingUnit.YOUTUBE_VIDEO,
+ docs_url="/docs/connectors/native/youtube",
)
register_capability(YOUTUBE_SCRAPE)
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/api-keys/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/api-keys/page.tsx
index 0dbd12818..2df3ec58e 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/api-keys/page.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/api-keys/page.tsx
@@ -1,4 +1,4 @@
-import { ApiKeysSection } from "../components/api-keys-section";
+import { redirect } from "next/navigation";
export default async function PlaygroundApiKeysPage({
params,
@@ -7,9 +7,5 @@ export default async function PlaygroundApiKeysPage({
}) {
const { workspace_id } = await params;
- return (
-
- );
+ redirect(`/dashboard/${workspace_id}/user-settings/api-key`);
}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-keys-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-keys-section.tsx
deleted file mode 100644
index e3df313f0..000000000
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-keys-section.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-"use client";
-
-import { useQuery } from "@tanstack/react-query";
-import { useAtomValue } from "jotai";
-import { useState } from "react";
-import { toast } from "sonner";
-import { updateWorkspaceApiAccessMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
-import { Label } from "@/components/ui/label";
-import { Skeleton } from "@/components/ui/skeleton";
-import { Switch } from "@/components/ui/switch";
-import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
-import { cacheKeys } from "@/lib/query-client/cache-keys";
-import { ApiKeyContent } from "../../user-settings/components/ApiKeyContent";
-
-/**
- * One-stop API key management for the playground: the workspace API-access
- * toggle (otherwise buried in workspace settings) plus the personal API key
- * manager (otherwise buried in user settings).
- */
-export function ApiKeysSection({ workspaceId }: { workspaceId: number }) {
- const {
- data: workspace,
- isLoading,
- refetch,
- } = useQuery({
- queryKey: cacheKeys.workspaces.detail(workspaceId.toString()),
- queryFn: () => workspacesApiService.getWorkspace({ id: workspaceId }),
- enabled: !!workspaceId,
- });
- const { mutateAsync: updateWorkspaceApiAccess } = useAtomValue(
- updateWorkspaceApiAccessMutationAtom
- );
- const [saving, setSaving] = useState(false);
-
- const handleToggle = async (enabled: boolean) => {
- try {
- setSaving(true);
- await updateWorkspaceApiAccess({ id: workspaceId, api_access_enabled: enabled });
- await refetch();
- } catch (error) {
- console.error("Error updating API access:", error);
- toast.error(error instanceof Error ? error.message : "Failed to update API access");
- } finally {
- setSaving(false);
- }
- };
-
- const apiAccessEnabled = !!workspace?.api_access_enabled;
-
- return (
-
-
-
API Keys
-
- Enable API access for this workspace and manage the keys that use it.
-
-
-
-
-
-
API key access
-
- Allow API keys to access this workspace.
- {!isLoading && !apiAccessEnabled && " Currently disabled — keys won't work here."}
-
-
- {isLoading ? (
-
- ) : (
-
- )}
-
-
-
-
- );
-}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx
index acdcddd6c..beed5d525 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Check, Copy } from "lucide-react";
+import { Check, ChevronRight, Copy } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -22,10 +22,10 @@ function CopyButton({ text }: { text: string }) {
variant="ghost"
size="sm"
onClick={copy}
- className="absolute right-2 top-2 h-7 gap-1.5 px-2 text-xs"
+ aria-label={copied ? "Copied" : "Copy"}
+ className="absolute right-2 top-2 h-7 w-7 p-0"
>
{copied ? : }
- {copied ? "Copied" : "Copy"}
);
}
@@ -45,8 +45,9 @@ function SchemaBlock({ title, schema }: { title: string; schema: Record JSON.stringify(schema, null, 2), [schema]);
return (
-
- {title}
+
+ {title}
+
@@ -90,11 +91,7 @@ export function ApiReference({
API reference
- Call this API from your own project. Create a key under{" "}
- API Keys (and enable API access for
- this workspace), then send it as a{" "}
- Authorization: Bearer{" "}
- header.
+ Create an API key, enable API access for this workspace, then use the examples below to call this endpoint.
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/output-viewer.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/output-viewer.tsx
index 606548e14..e692734d3 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/output-viewer.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/output-viewer.tsx
@@ -3,6 +3,7 @@
import { Check, Copy, Download } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableBody,
@@ -12,7 +13,6 @@ import {
TableRow,
} from "@/components/ui/table";
import { downloadCsv, rowsToCsv } from "@/lib/playground/csv";
-import { cn } from "@/lib/utils";
const MAX_TABLE_ROWS = 200;
@@ -109,34 +109,12 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa
return (
-
- {items && (
- setView("table")}
- className={cn(
- "rounded px-2.5 py-1 text-xs font-medium transition-colors",
- view === "table"
- ? "bg-accent text-accent-foreground"
- : "text-muted-foreground hover:text-foreground"
- )}
- >
- Table
-
- )}
- setView("json")}
- className={cn(
- "rounded px-2.5 py-1 text-xs font-medium transition-colors",
- view === "json"
- ? "bg-accent text-accent-foreground"
- : "text-muted-foreground hover:text-foreground"
- )}
- >
- JSON
-
-
+
setView(value as "table" | "json")}>
+
+ {items && Table }
+ JSON
+
+
{items && items.length > 0 && (
)}
-
+
{copied ? : }
- {copied ? "Copied" : "Copy JSON"}
@@ -168,7 +152,7 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa
{truncated && (
- Showing first {MAX_TABLE_ROWS} of {items.length} items. Use Copy JSON for the full
+ Showing first {MAX_TABLE_ROWS} of {items.length} items. Switch to JSON for the full
output.
)}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-index.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-index.tsx
index 741ed291f..7e9d45373 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-index.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-index.tsx
@@ -1,8 +1,9 @@
"use client";
-import { ArrowRight, History, KeyRound } from "lucide-react";
+import { ArrowRight, History, Info } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
+import { Alert, AlertDescription } from "@/components/ui/alert";
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
import { formatPricing } from "@/lib/playground/format";
@@ -20,13 +21,21 @@ export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) {
return (
-
-
API Playground
-
- Manually run SurfSense's platform-native APIs and inspect their output. Every run is
- captured under Runs.
-
-
+
+
+
+
+ Manually run SurfSense's platform-native APIs and inspect their output. To use these APIs outside SurfSense,{" "}
+
+ create an API key
+
+ .
+
+
+
-
Runs
+
API Runs
See every API run in this workspace
-
-
-
-
-
API Keys
-
- Enable workspace access and manage keys
-
-
-
-
-
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx
index 3b76a7d09..f62ffc312 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx
@@ -1,14 +1,23 @@
"use client";
-import { AlertTriangle, Coins, Hash, Loader2, Play, Timer, X } from "lucide-react";
+import {
+ Check,
+ Copy,
+ Hash,
+ Info,
+ Coins,
+ Timer,
+} from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { toast } from "sonner";
+import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useRunStream } from "@/hooks/use-run-stream";
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
-import { AbortedError, AppError } from "@/lib/error";
+import { AppError } from "@/lib/error";
import { findVerb } from "@/lib/playground/catalog";
import { formatCost, formatDuration, formatPricing } from "@/lib/playground/format";
import { buildPayload, initialFormValues, parseSchemaFields } from "@/lib/playground/json-schema";
@@ -58,36 +67,44 @@ function RunStat({
);
}
-function ErrorPanel({ error, workspaceId }: { error: unknown; workspaceId: number }) {
- if (error instanceof AbortedError) {
- return null;
- }
+function getRunErrorMessage(error: unknown): string {
const status = error instanceof AppError ? error.status : undefined;
if (status === 402) {
- return (
-
-
Insufficient credits
-
You don't have enough credits to run this API.
-
- Buy credits
-
-
- );
+ return "Insufficient credits. Add credits to run this API.";
}
- const message =
- status === 422
- ? "Invalid input. Check the fields above and try again."
- : error instanceof Error && error.message
- ? error.message
- : "Something went wrong running this API.";
+ if (status === 422) {
+ return "Invalid input. Check the fields above and try again.";
+ }
+
+ return error instanceof Error && error.message
+ ? error.message
+ : "Something went wrong running this API.";
+}
+
+function EndpointCopyButton({ endpoint }: { endpoint: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(endpoint).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ });
+ };
return (
-
+
+ {endpoint}
+ {copied ? : }
+ {copied ? "Copied endpoint" : "Copy endpoint"}
+
);
}
@@ -110,6 +127,8 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
const [values, setValues] = useState
>({});
const run = useRunStream(workspaceId);
const isRunning = run.status === "running";
+ const previousStatusRef = useRef(run.status);
+ const notifiedRunRef = useRef(null);
// Seed form defaults once the schema is available.
useEffect(() => {
@@ -158,6 +177,29 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
() => (run.detail ? parseJsonlOutput(run.detail.output_text) : null),
[run.detail]
);
+ const endpoint = `POST /workspaces/${workspaceId}/scrapers/${platform}/${verb}`;
+
+ useEffect(() => {
+ const previousStatus = previousStatusRef.current;
+ previousStatusRef.current = run.status;
+
+ if (previousStatus !== "running") return;
+
+ if (run.status === "success") {
+ const key = `${run.runId ?? "run"}:success`;
+ if (notifiedRunRef.current === key) return;
+ notifiedRunRef.current = key;
+ toast.success("API run completed.");
+ return;
+ }
+
+ if (run.status === "error") {
+ const key = `${run.runId ?? "run"}:error`;
+ if (notifiedRunRef.current === key) return;
+ notifiedRunRef.current = key;
+ toast.error(getRunErrorMessage(run.error));
+ }
+ }, [run.status, run.runId, run.error]);
if (isLoading) {
return (
@@ -186,26 +228,40 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
return (
-
+
+ {capability.description && (
+
+
+
+
+ {capability.description}
+ {capability.docs_url ? (
+ <>
+ {" "}
+
+ Read docs
+
+ {" "}for more info.
+ >
+ ) : null}
+
+
+
+ )}
+
-
-
- {catalogVerb.label} · {platform}
-
- {capability.description && (
-
{capability.description}
- )}
-
- POST /workspaces/{workspaceId}/scrapers/{platform}/{verb}
-
-
-
+
+
+
+ Pricing:
{formatPricing(capability.pricing)}
-
-
- {isRunning ? (
-
- ) : (
-
- )}
- Run
+
+ Run
+ {isRunning && }
{isRunning && (
-
-
+
Cancel
)}
- {run.status === "error" &&
}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/runs-table.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/runs-table.tsx
index f47bd4572..6368a1f76 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/runs-table.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/runs-table.tsx
@@ -1,8 +1,9 @@
"use client";
import { useInfiniteQuery } from "@tanstack/react-query";
-import { ChevronDown, ChevronRight, History } from "lucide-react";
+import { ChevronDown, ChevronRight, History, Info } from "lucide-react";
import { Fragment, useState } from "react";
+import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -67,13 +68,12 @@ export function RunsTable({ workspaceId }: { workspaceId: number }) {
return (
-
-
Runs
-
- Every platform-native API run in this workspace — from the playground, API keys, and
- agents. Newest first.
-
-
+
+
+
+ View all API runs for this workspace, including runs from the playground, API keys, and agents.
+
+
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/schema-form.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/schema-form.tsx
index 0b3b641ed..36aee26bb 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/schema-form.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/schema-form.tsx
@@ -2,6 +2,7 @@
import { ChevronDown } from "lucide-react";
import { useMemo, useState } from "react";
+import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -133,7 +134,12 @@ function FieldRow({
{field.title}
- {field.required && required }
+
+ {field.required ? "required" : "optional"}
+
{field.description && (
{field.description}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx
new file mode 100644
index 000000000..59b8ad50b
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { History, LayoutGrid } from "lucide-react";
+import { useSelectedLayoutSegments } from "next/navigation";
+import type React from "react";
+import { useMemo } from "react";
+import {
+ type RoutedSectionGroup,
+ type RoutedSectionItem,
+ RoutedSectionShell,
+} from "@/components/layout";
+import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
+
+interface PlaygroundLayoutShellProps {
+ workspaceId: string;
+ children: React.ReactNode;
+}
+
+export function PlaygroundLayoutShell({ workspaceId, children }: PlaygroundLayoutShellProps) {
+ const segments = useSelectedLayoutSegments();
+ const base = `/dashboard/${workspaceId}/playground`;
+
+ const topLevelItems = useMemo
(
+ () => [
+ {
+ value: "overview",
+ label: "Overview",
+ href: base,
+ icon: ,
+ },
+ {
+ value: "runs",
+ label: "API Runs",
+ href: `${base}/runs`,
+ icon: ,
+ },
+ ],
+ [base]
+ );
+
+ const providerGroups = useMemo(
+ () =>
+ PLAYGROUND_PLATFORMS.map((platform) => {
+ const Icon = platform.icon;
+ return {
+ value: platform.id,
+ label: platform.label,
+ icon: ,
+ items: platform.verbs.map((verb) => ({
+ value: `${platform.id}/${verb.verb}`,
+ label: verb.label,
+ href: `${base}/${platform.id}/${verb.verb}`,
+ })),
+ };
+ }),
+ [base]
+ );
+
+ const activeValue =
+ segments.length >= 2
+ ? `${segments[0]}/${segments[1]}`
+ : segments[0] && topLevelItems.some((item) => item.value === segments[0])
+ ? segments[0]
+ : "overview";
+
+ const selectedLabel = getSelectedLabel(activeValue, topLevelItems, providerGroups);
+
+ return (
+
+ {children}
+
+ );
+}
+
+function getSelectedLabel(
+ activeValue: string,
+ items: RoutedSectionItem[],
+ groups: RoutedSectionGroup[]
+): string {
+ const topLevelItem = items.find((item) => item.value === activeValue);
+ if (topLevelItem) return topLevelItem.label;
+
+ const group = groups.find((item) => item.items.some((child) => child.value === activeValue));
+ const child = group?.items.find((item) => item.value === activeValue);
+
+ if (group && child) return `${group.label}: ${child.label}`;
+ return "API Playground";
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/layout.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/layout.tsx
new file mode 100644
index 000000000..318886e31
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/layout.tsx
@@ -0,0 +1,15 @@
+import type React from "react";
+import { use } from "react";
+import { PlaygroundLayoutShell } from "./layout-shell";
+
+export default function PlaygroundLayout({
+ params,
+ children,
+}: {
+ params: Promise<{ workspace_id: string }>;
+ children: React.ReactNode;
+}) {
+ const { workspace_id } = use(params);
+
+ return {children} ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/PurchaseHistoryContent.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/PurchaseHistoryContent.tsx
index 263a286c1..0056b9374 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/PurchaseHistoryContent.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/PurchaseHistoryContent.tsx
@@ -108,9 +108,8 @@ function normalizeCreditPurchase(p: CreditPurchase): UnifiedPurchase {
function formatGranted(p: UnifiedPurchase): string {
if (p.kind === "credits") {
const dollars = p.granted / 1_000_000;
- // Credit packs are always whole dollars at the moment, but future
- // fractional grants (refunds, partial top-ups, auto-reload) shouldn't
- // silently round to "$0".
+ // Credit packs are always whole dollars today, but future fractional grants
+ // such as refunds or low-balance refills shouldn't silently round to "$0".
if (dollars >= 1) return `$${dollars.toFixed(2)} of credit`;
if (dollars > 0) return `$${dollars.toFixed(3)} of credit`;
return "$0 of credit";
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout-shell.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout-shell.tsx
index 5446c4167..aa73917ef 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout-shell.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout-shell.tsx
@@ -11,14 +11,12 @@ import {
ShieldCheck,
WandSparkles,
} from "lucide-react";
-import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
-import { useCallback, useMemo, useState } from "react";
-import { Separator } from "@/components/ui/separator";
+import { useMemo } from "react";
+import { type RoutedSectionItem, RoutedSectionShell } from "@/components/layout";
import { usePlatform } from "@/hooks/use-platform";
-import { cn } from "@/lib/utils";
export type UserSettingsTab =
| "profile"
@@ -42,50 +40,49 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
const t = useTranslations("userSettings");
const { isDesktop } = usePlatform();
const segment = useSelectedLayoutSegment();
- const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
- const handleTabScroll = useCallback((e: React.UIEvent) => {
- const el = e.currentTarget;
- const atStart = el.scrollLeft <= 2;
- const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
- setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
- }, []);
-
- const navItems = useMemo(
+ const navItems = useMemo(
() => [
{
value: "profile" as const,
label: t("profile_nav_label"),
+ href: `/dashboard/${workspaceId}/user-settings/profile`,
icon: ,
},
{
value: "api-key" as const,
label: t("api_key_nav_label"),
+ href: `/dashboard/${workspaceId}/user-settings/api-key`,
icon: ,
},
{
value: "prompts" as const,
label: "My Prompts",
+ href: `/dashboard/${workspaceId}/user-settings/prompts`,
icon: ,
},
{
value: "community-prompts" as const,
label: "Community Prompts",
+ href: `/dashboard/${workspaceId}/user-settings/community-prompts`,
icon: ,
},
{
value: "agent-permissions" as const,
label: "Agent Permissions",
+ href: `/dashboard/${workspaceId}/user-settings/agent-permissions`,
icon: ,
},
{
value: "messaging-channels" as const,
label: "Messaging Channels",
+ href: `/dashboard/${workspaceId}/user-settings/messaging-channels`,
icon: ,
},
{
value: "purchases" as const,
label: "Purchase History",
+ href: `/dashboard/${workspaceId}/user-settings/purchases`,
icon: ,
},
...(isDesktop
@@ -93,17 +90,19 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
{
value: "desktop" as const,
label: "App Preferences",
+ href: `/dashboard/${workspaceId}/user-settings/desktop`,
icon: ,
},
{
value: "hotkeys" as const,
label: "Hotkeys",
+ href: `/dashboard/${workspaceId}/user-settings/hotkeys`,
icon: ,
},
]
: []),
],
- [t, isDesktop]
+ [t, isDesktop, workspaceId]
);
const activeTab: UserSettingsTab =
@@ -112,70 +111,15 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
: DEFAULT_TAB;
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
- const hrefFor = (tab: UserSettingsTab) => `/dashboard/${workspaceId}/user-settings/${tab}`;
-
return (
-
-
-
{t("title")}
-
- {navItems.map((item) => (
-
- {item.icon}
- {item.label}
-
- ))}
-
-
-
- {navItems.map((item) => (
-
- {item.icon}
- {item.label}
-
- ))}
-
-
-
-
-
-
-
{selectedLabel}
-
-
-
{children}
-
-
+
+ {children}
+
);
}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/workspace-settings/layout-shell.tsx b/surfsense_web/app/dashboard/[workspace_id]/workspace-settings/layout-shell.tsx
index e23daecb9..80276d30a 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/workspace-settings/layout-shell.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/workspace-settings/layout-shell.tsx
@@ -1,13 +1,11 @@
"use client";
import { BookText, Cpu, Earth, Settings, UserKey } from "lucide-react";
-import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
-import { useCallback, useMemo, useState } from "react";
-import { Separator } from "@/components/ui/separator";
-import { cn } from "@/lib/utils";
+import { useMemo } from "react";
+import { type RoutedSectionItem, RoutedSectionShell } from "@/components/layout";
export type WorkspaceSettingsTab = "general" | "models" | "team-roles" | "prompts" | "public-links";
@@ -24,44 +22,41 @@ export function WorkspaceSettingsLayoutShell({
}: WorkspaceSettingsLayoutShellProps) {
const t = useTranslations("workspaceSettings");
const segment = useSelectedLayoutSegment();
- const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
- const handleTabScroll = useCallback((e: React.UIEvent) => {
- const el = e.currentTarget;
- const atStart = el.scrollLeft <= 2;
- const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
- setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
- }, []);
-
- const navItems = useMemo(
+ const navItems = useMemo(
() => [
{
value: "general" as const,
label: t("nav_general"),
+ href: `/dashboard/${workspaceId}/workspace-settings/general`,
icon: ,
},
{
value: "models" as const,
label: t("nav_models"),
+ href: `/dashboard/${workspaceId}/workspace-settings/models`,
icon: ,
},
{
value: "team-roles" as const,
label: t("nav_team_roles"),
+ href: `/dashboard/${workspaceId}/workspace-settings/team-roles`,
icon: ,
},
{
value: "prompts" as const,
label: t("nav_system_instructions"),
+ href: `/dashboard/${workspaceId}/workspace-settings/prompts`,
icon: ,
},
{
value: "public-links" as const,
label: t("nav_public_links"),
+ href: `/dashboard/${workspaceId}/workspace-settings/public-links`,
icon: ,
},
],
- [t]
+ [t, workspaceId]
);
const activeTab: WorkspaceSettingsTab =
@@ -70,71 +65,15 @@ export function WorkspaceSettingsLayoutShell({
: DEFAULT_TAB;
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
- const hrefFor = (tab: WorkspaceSettingsTab) =>
- `/dashboard/${workspaceId}/workspace-settings/${tab}`;
-
return (
-
-
-
{t("title")}
-
- {navItems.map((item) => (
-
- {item.icon}
- {item.label}
-
- ))}
-
-
-
- {navItems.map((item) => (
-
- {item.icon}
- {item.label}
-
- ))}
-
-
-
-
-
-
-
{selectedLabel}
-
-
-
{children}
-
-
+
+ {children}
+
);
}
diff --git a/surfsense_web/atoms/layout/playground.atom.ts b/surfsense_web/atoms/layout/playground.atom.ts
deleted file mode 100644
index 6cbe8d276..000000000
--- a/surfsense_web/atoms/layout/playground.atom.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { atom } from "jotai";
-
-/**
- * Whether the second-level API Playground sidebar is open. Toggled by the
- * Playground nav button and kept in memory for the session, so it survives
- * in-app navigation (opening a new chat won't close it) and only closes when
- * the user clicks the toggle. It defaults to open, so a fresh app load — a new
- * signup or a relogin — always starts with the playground visible.
- */
-export const playgroundSidebarOpenAtom = atom(true);
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx
index 1b8548e3d..0cf6ef296 100644
--- a/surfsense_web/components/assistant-ui/thread.tsx
+++ b/surfsense_web/components/assistant-ui/thread.tsx
@@ -22,7 +22,7 @@ import {
Wrench,
X,
} from "lucide-react";
-import { AnimatePresence, motion, useReducedMotion } from "motion/react";
+import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useParams } from "next/navigation";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -119,7 +119,7 @@ import {
} from "../new-chat/document-mention-picker";
const COMPOSER_PLACEHOLDER =
- "Track competitors, scrape platforms, automate briefs — / for prompts, @ for docs";
+ "Track competitors, scrape platforms, automate briefs. Use / for prompts, @ for docs";
type ComposerSuggestionAnchorPoint = {
left: number;
@@ -986,14 +986,12 @@ const Composer: FC = () => {
* Full-color brand marks for the platform-native scraper APIs (web, Google
* Search, Google Maps, Reddit, YouTube) available in this workspace, shown beside the
* composer "+" so the user can see these native endpoints are connected. Laid
- * out as a clean row (not stacked) after a hairline divider that separates them
+ * out as the same overlapping avatar group used by the connect-tools tray
* from the composer actions. The capability registry is the source of truth;
- * icons are display-only with a status tooltip. One-time staggered entrance,
- * reduced-motion aware.
+ * icons are display-only with a status tooltip.
*/
const ConnectedScraperIcons: FC<{ workspaceId: number }> = ({ workspaceId }) => {
const { data: capabilities } = useScraperCapabilities(workspaceId);
- const reduceMotion = useReducedMotion();
const platforms = useMemo(() => {
if (!capabilities?.length) return [];
@@ -1014,30 +1012,26 @@ const ConnectedScraperIcons: FC<{ workspaceId: number }> = ({ workspaceId }) =>
return (
-
+
{platforms.map((platform, i) => {
const Icon = platform.icon;
return (
-
-
-
+
+
+
+
- {platform.label} · Connected
+ {platform.label} scraper available
);
})}
-
+
);
};
diff --git a/surfsense_web/components/documents/FolderNode.tsx b/surfsense_web/components/documents/FolderNode.tsx
index b46af227a..1cf74f549 100644
--- a/surfsense_web/components/documents/FolderNode.tsx
+++ b/surfsense_web/components/documents/FolderNode.tsx
@@ -129,6 +129,7 @@ export const FolderNode = React.memo(function FolderNode({
const rowRef = useRef(null);
const [dropZone, setDropZone] = useState(null);
const [isRescanning, setIsRescanning] = useState(false);
+ const [dropdownOpen, setDropdownOpen] = useState(false);
const handleRescan = useCallback(async () => {
if (isRescanning) return;
@@ -349,12 +350,17 @@ export const FolderNode = React.memo(function FolderNode({
)}
{!isRenaming && (
-
+
e.stopPropagation()}
>
diff --git a/surfsense_web/components/homepage/hero-chat-demo.tsx b/surfsense_web/components/homepage/hero-chat-demo.tsx
index ef4b7cfc1..e617cd51e 100644
--- a/surfsense_web/components/homepage/hero-chat-demo.tsx
+++ b/surfsense_web/components/homepage/hero-chat-demo.tsx
@@ -27,7 +27,7 @@ export type HeroChatDemoScript = {
type Stage = "typing" | "steps" | "answer" | "done";
-const PLACEHOLDER = "Track competitors, scrape platforms, automate briefs — / for prompts, @ for docs";
+const PLACEHOLDER = "Track competitors, scrape platforms, automate briefs. Use / for prompts, @ for docs";
/** Blinking caret for the typewriter (overlay only, never inside the real input). */
function Caret() {
diff --git a/surfsense_web/components/layout/index.ts b/surfsense_web/components/layout/index.ts
index e6903c642..a4d4a88f8 100644
--- a/surfsense_web/components/layout/index.ts
+++ b/surfsense_web/components/layout/index.ts
@@ -9,6 +9,7 @@ export type {
User,
Workspace,
} from "./types/layout.types";
+export type { RoutedSectionGroup, RoutedSectionItem } from "./ui";
export {
ChatListItem,
CreateWorkspaceDialog,
@@ -20,6 +21,7 @@ export {
MobileSidebarTrigger,
NavIcon,
NavSection,
+ RoutedSectionShell,
Sidebar,
SidebarCollapseButton,
SidebarHeader,
diff --git a/surfsense_web/components/layout/providers/FreeLayoutDataProvider.tsx b/surfsense_web/components/layout/providers/FreeLayoutDataProvider.tsx
index 3a74d77b9..059872bac 100644
--- a/surfsense_web/components/layout/providers/FreeLayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/FreeLayoutDataProvider.tsx
@@ -1,14 +1,13 @@
"use client";
-import { Inbox } from "lucide-react";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate";
import { useAnnouncements } from "@/hooks/use-announcements";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
-import type { ChatItem, NavItem, PageUsage, Workspace } from "../types/layout.types";
+import type { ChatItem, PageUsage, Workspace } from "../types/layout.types";
import { LayoutShell } from "../ui/shell";
interface FreeLayoutDataProviderProps {
@@ -47,34 +46,12 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
const gatedAction = useCallback((feature: string) => () => gate(feature), [gate]);
- const navItems: NavItem[] = useMemo(
- () =>
- (
- [
- {
- title: "Inbox",
- url: "#inbox",
- icon: Inbox,
- isActive: false,
- },
- ] as (NavItem | null)[]
- ).filter((item): item is NavItem => item !== null),
- []
- );
-
const pageUsage: PageUsage | undefined = quota
? { pagesUsed: quota.used, pagesLimit: quota.limit }
: undefined;
const handleChatSelect = useCallback((_chat: ChatItem) => gate("view chat history"), [gate]);
- const handleNavItemClick = useCallback(
- (item: NavItem) => {
- if (item.title === "Inbox") gate("use the inbox");
- },
- [gate]
- );
-
const handleAnnouncements = useCallback(() => gate("see what's new"), [gate]);
const handleWorkspaceSelect = useCallback((_id: number) => gate("switch workspaces"), [gate]);
@@ -87,8 +64,7 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
onWorkspaceSettings={gatedAction("workspace settings")}
onAddWorkspace={gatedAction("create workspaces")}
workspace={GUEST_SPACE}
- navItems={navItems}
- onNavItemClick={handleNavItemClick}
+ navItems={[]}
chats={[]}
activeChatId={null}
onNewChat={resetChat}
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index 639965d38..9821a9f83 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -1,8 +1,8 @@
"use client";
import { useQuery } from "@tanstack/react-query";
-import { useAtom, useAtomValue, useSetAtom } from "jotai";
-import { AlarmClock, AlertTriangle, Boxes, Inbox, SquareTerminal } from "lucide-react";
+import { useAtomValue, useSetAtom } from "jotai";
+import { AlarmClock, AlertTriangle, Boxes, SquareTerminal } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@@ -11,7 +11,6 @@ import { toast } from "sonner";
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom";
-import { playgroundSidebarOpenAtom } from "@/atoms/layout/playground.atom";
import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { deleteWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
@@ -43,7 +42,6 @@ import { Spinner } from "@/components/ui/spinner";
import { useActivateChatThread } from "@/hooks/use-activate-chat-thread";
import { useAnnouncements } from "@/hooks/use-announcements";
import { useInbox } from "@/hooks/use-inbox";
-import { useIsMobile } from "@/hooks/use-mobile";
import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations";
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
@@ -60,17 +58,6 @@ interface LayoutDataProviderProps {
children: React.ReactNode;
}
-/**
- * Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
- */
-function formatInboxCount(count: number): string {
- if (count <= 999) {
- return count.toString();
- }
- const thousands = Math.floor(count / 1000);
- return `${thousands}k+`;
-}
-
export function LayoutDataProvider({ workspaceId, children }: LayoutDataProviderProps) {
const t = useTranslations("dashboard");
const tCommon = useTranslations("common");
@@ -79,8 +66,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
const params = useParams();
const pathname = usePathname();
const { theme, setTheme } = useTheme();
- const isMobile = useIsMobile();
- const [playgroundSidebarOpen, setPlaygroundSidebarOpen] = useAtom(playgroundSidebarOpenAtom);
// Announcements
const { unreadCount: announcementUnreadCount } = useAnnouncements();
@@ -125,12 +110,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
enabled: !!workspaceId,
});
- // Unified slide-out panel state (only one can be open at a time)
- type SlideoutPanel = "inbox" | null;
- const [activeSlideoutPanel, setActiveSlideoutPanel] = useState(null);
-
- const isInboxSidebarOpen = activeSlideoutPanel === "inbox";
-
// Search space dialog state
const [isCreateWorkspaceDialogOpen, setIsCreateWorkspaceDialogOpen] = useState(false);
@@ -228,17 +207,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
const [isDeletingWorkspace, setIsDeletingWorkspace] = useState(false);
const [isLeavingWorkspace, setIsLeavingWorkspace] = useState(false);
- // Reset transient slide-out panels when switching workspaces.
- // Tabs intentionally persist across spaces — opening tabs from multiple
- // workspaces is a supported flow (browser-tab semantics).
- const prevWorkspaceIdRef = useRef(workspaceId);
- useEffect(() => {
- if (prevWorkspaceIdRef.current !== workspaceId) {
- prevWorkspaceIdRef.current = workspaceId;
- setActiveSlideoutPanel(null);
- }
- }, [workspaceId]);
-
const workspaces: Workspace[] = useMemo(() => {
if (!workspacesData || !Array.isArray(workspacesData)) return [];
return workspacesData.map((space) => ({
@@ -318,9 +286,9 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
}, [threadsData, workspaceId]);
// Navigation items
- // Inbox, Automations, and Artifacts are rendered explicitly below "New chat"
- // in the sidebar (also surfaced in the icon rail's collapsed mode via this
- // list). Documents is embedded below Recents; announcements live in the avatar dropdown.
+ // Automations and Artifacts are rendered explicitly below "New chat"
+ // in the sidebar. Documents is embedded below Recents; notifications and
+ // announcements live in the avatar rail/dropdown.
const isAutomationsActive = pathname?.includes("/automations") === true;
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
const isPlaygroundRoute = pathname?.includes("/playground") === true;
@@ -328,13 +296,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
() =>
(
[
- {
- title: "Inbox",
- url: "#inbox",
- icon: Inbox,
- isActive: isInboxSidebarOpen,
- badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
- },
{
title: "Automations",
url: `/dashboard/${workspaceId}/automations`,
@@ -351,22 +312,11 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
title: "Playground",
url: `/dashboard/${workspaceId}/playground`,
icon: SquareTerminal,
- // Mobile has no second-level sidebar: Playground is a plain link
- // there, so highlight by route. Desktop highlights the toggle state.
- isActive: isMobile ? isPlaygroundRoute : playgroundSidebarOpen,
+ isActive: isPlaygroundRoute,
},
] as (NavItem | null)[]
).filter((item): item is NavItem => item !== null),
- [
- isInboxSidebarOpen,
- totalUnreadCount,
- workspaceId,
- isAutomationsActive,
- isArtifactsActive,
- isPlaygroundRoute,
- playgroundSidebarOpen,
- isMobile,
- ]
+ [workspaceId, isAutomationsActive, isArtifactsActive, isPlaygroundRoute]
);
// Handlers
@@ -503,22 +453,9 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
const handleNavItemClick = useCallback(
(item: NavItem) => {
- if (item.url === "#inbox") {
- setActiveSlideoutPanel((prev) => (prev === "inbox" ? null : "inbox"));
- return;
- }
- // Desktop: Playground is a persistent toggle, not a plain link — it just
- // opens the second-level sidebar (which holds the whole API playground)
- // and only closes on a second click, never navigating away from the
- // current page (e.g. a new chat). Mobile has no second-level sidebar,
- // so there it navigates to the playground index page instead.
- if (item.url.endsWith("/playground") && !isMobile) {
- setPlaygroundSidebarOpen((prev) => !prev);
- return;
- }
router.push(item.url);
},
- [router, setPlaygroundSidebarOpen, setActiveSlideoutPanel, isMobile]
+ [router]
);
const handleNewChat = useCallback(() => {
@@ -688,7 +625,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
const isPlaygroundPage = pathname?.includes("/playground") === true;
const isAllChatsPage = pathname?.endsWith("/chats") === true;
const handleViewAllChats = useCallback(() => {
- setActiveSlideoutPanel(null);
router.push(
isAllChatsPage ? `/dashboard/${workspaceId}/new-chat` : `/dashboard/${workspaceId}/chats`
);
@@ -741,7 +677,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
setTheme={setTheme}
isChatPage={isChatPage}
isAllChatsPage={isAllChatsPage}
- showPlaygroundSidebar={playgroundSidebarOpen}
useWorkspacePanel={useWorkspacePanel}
workspacePanelViewportClassName={
isUserSettingsPage ||
@@ -755,23 +690,15 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
: undefined
}
workspacePanelContentClassName={
- isAutomationsPage || isPlaygroundPage
- ? "max-w-none select-none"
- : isAllChatsPage
- ? "max-w-5xl"
- : isUserSettingsPage || isWorkspaceSettingsPage || isTeamPage || isArtifactsPage
- ? "max-w-5xl"
- : undefined
+ useWorkspacePanel ? "max-w-5xl select-none" : undefined
}
isLoadingChats={isLoadingThreads}
- activeSlideoutPanel={activeSlideoutPanel}
- onSlideoutPanelChange={setActiveSlideoutPanel}
- inbox={{
- isOpen: isInboxSidebarOpen,
+ notifications={{
totalUnreadCount,
comments: {
items: commentsInbox.inboxItems,
unreadCount: commentsInbox.unreadCount,
+ totalCount: commentsInbox.totalCount,
loading: commentsInbox.loading,
loadingMore: commentsInbox.loadingMore,
hasMore: commentsInbox.hasMore,
@@ -782,6 +709,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
status: {
items: statusInbox.inboxItems,
unreadCount: statusInbox.unreadCount,
+ totalCount: statusInbox.totalCount,
loading: statusInbox.loading,
loadingMore: statusInbox.loadingMore,
hasMore: statusInbox.hasMore,
diff --git a/surfsense_web/components/layout/ui/RoutedSectionShell.tsx b/surfsense_web/components/layout/ui/RoutedSectionShell.tsx
new file mode 100644
index 000000000..007244806
--- /dev/null
+++ b/surfsense_web/components/layout/ui/RoutedSectionShell.tsx
@@ -0,0 +1,267 @@
+"use client";
+
+import { ChevronRight } from "lucide-react";
+import Link from "next/link";
+import type React from "react";
+import { useCallback, useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Drawer,
+ DrawerContent,
+ DrawerHandle,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+
+export interface RoutedSectionItem {
+ value: string;
+ label: string;
+ href: string;
+ icon?: React.ReactNode;
+}
+
+export interface RoutedSectionGroup {
+ value: string;
+ label: string;
+ icon?: React.ReactNode;
+ items: RoutedSectionItem[];
+}
+
+interface RoutedSectionShellProps {
+ title: string;
+ items: RoutedSectionItem[];
+ activeValue: string;
+ selectedLabel: string;
+ children: React.ReactNode;
+ groups?: RoutedSectionGroup[];
+ mobileNav?: "scroll" | "drawer";
+ contentClassName?: string;
+}
+
+function findActiveGroupValue(groups: RoutedSectionGroup[], activeValue: string): string | null {
+ return (
+ groups.find((group) => group.items.some((item) => item.value === activeValue))?.value ?? null
+ );
+}
+
+function SectionNavLink({
+ item,
+ activeValue,
+ onNavigate,
+}: {
+ item: RoutedSectionItem;
+ activeValue: string;
+ onNavigate?: () => void;
+}) {
+ const isActive = activeValue === item.value;
+
+ return (
+
+ {item.icon}
+ {item.label}
+
+ );
+}
+
+function SectionNavGroup({
+ group,
+ activeValue,
+ isExpanded,
+ onToggle,
+ onNavigate,
+}: {
+ group: RoutedSectionGroup;
+ activeValue: string;
+ isExpanded: boolean;
+ onToggle: () => void;
+ onNavigate?: () => void;
+}) {
+ return (
+
+
+ {group.icon}
+ {group.label}
+
+
+
+
+
+ {group.items.map((item) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+export function RoutedSectionShell({
+ title,
+ items,
+ activeValue,
+ selectedLabel,
+ children,
+ groups = [],
+ mobileNav = "scroll",
+ contentClassName,
+}: RoutedSectionShellProps) {
+ const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [expandedGroup, setExpandedGroup] = useState(() =>
+ findActiveGroupValue(groups, activeValue)
+ );
+
+ useEffect(() => {
+ const activeGroup = findActiveGroupValue(groups, activeValue);
+ if (activeGroup) {
+ setExpandedGroup(activeGroup);
+ }
+ }, [activeValue, groups]);
+
+ const handleTabScroll = useCallback((e: React.UIEvent) => {
+ const el = e.currentTarget;
+ const atStart = el.scrollLeft <= 2;
+ const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
+ setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
+ }, []);
+
+ const renderNav = (onNavigate?: () => void) => (
+ <>
+ {items.map((item) => (
+
+ ))}
+ {groups.length > 0 && (
+ <>
+
+
+ {groups.map((group) => (
+
+ setExpandedGroup((current) => (current === group.value ? null : group.value))
+ }
+ onNavigate={onNavigate}
+ />
+ ))}
+
+ >
+ )}
+ >
+ );
+
+ return (
+
+
+
+ {title}
+
+
{renderNav()}
+ {mobileNav === "drawer" ? (
+
+
+
+ {selectedLabel}
+
+
+
+
+
+ {title} navigation
+
+ {renderNav(() => setDrawerOpen(false))}
+
+
+
+ ) : (
+
+
+ {items.map((item) => (
+
+ {item.icon}
+ {item.label}
+
+ ))}
+
+
+ )}
+
+
+
+
+
{selectedLabel}
+
+
+
{children}
+
+
+ );
+}
diff --git a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx
index 5170fa770..1f700c89f 100644
--- a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx
+++ b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx
@@ -6,6 +6,10 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { NavItem, User, Workspace } from "../../types/layout.types";
+import {
+ NotificationsDropdown,
+ type NotificationsDropdownData,
+} from "../sidebar/NotificationsDropdown";
import { SidebarUserProfile } from "../sidebar/SidebarUserProfile";
import { WorkspaceAvatar } from "./WorkspaceAvatar";
@@ -24,6 +28,7 @@ interface IconRailProps {
onUserSettings?: () => void;
onAnnouncements?: () => void;
announcementUnreadCount?: number;
+ notifications?: NotificationsDropdownData;
onLogout?: () => void;
theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void;
@@ -45,6 +50,7 @@ export function IconRail({
onUserSettings,
onAnnouncements,
announcementUnreadCount = 0,
+ notifications,
onLogout,
theme,
setTheme,
@@ -145,6 +151,9 @@ export function IconRail({
isCollapsed
theme={theme}
setTheme={setTheme}
+ topContent={
+ notifications ? : undefined
+ }
/>
);
diff --git a/surfsense_web/components/layout/ui/index.ts b/surfsense_web/components/layout/ui/index.ts
index 52507ba37..338a4f352 100644
--- a/surfsense_web/components/layout/ui/index.ts
+++ b/surfsense_web/components/layout/ui/index.ts
@@ -1,6 +1,8 @@
export { CreateWorkspaceDialog } from "./dialogs";
export { Header } from "./header";
export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail";
+export type { RoutedSectionGroup, RoutedSectionItem } from "./RoutedSectionShell";
+export { RoutedSectionShell } from "./RoutedSectionShell";
export { LayoutShell } from "./shell";
export {
ChatListItem,
diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
index 7adad0cc3..06ccaab80 100644
--- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
+++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
@@ -1,14 +1,12 @@
"use client";
import { useAtomValue } from "jotai";
-import { AnimatePresence, motion } from "motion/react";
import dynamic from "next/dynamic";
-import { useCallback, useMemo, useState } from "react";
+import { useMemo, useState } from "react";
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import { Logo } from "@/components/Logo";
import { Spinner } from "@/components/ui/spinner";
import { TooltipProvider } from "@/components/ui/tooltip";
-import type { InboxItem } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile";
import { useElectronAPI } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
@@ -27,14 +25,12 @@ import {
RightPanelToggleButton,
} from "../right-panel/RightPanel";
import {
- InboxSidebarContent,
MobileSidebar,
MobileSidebarTrigger,
- PlaygroundSidebar,
Sidebar,
SidebarCollapseButton,
} from "../sidebar";
-import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
+import type { NotificationsDropdownData } from "../sidebar/NotificationsDropdown";
import { TabBar } from "../tabs/TabBar";
import { WorkspacePanel } from "./WorkspacePanel";
@@ -80,28 +76,6 @@ function MacDesktopTitleBar({
);
}
-// Per-tab data source
-interface TabDataSource {
- items: InboxItem[];
- unreadCount: number;
- loading: boolean;
- loadingMore: boolean;
- hasMore: boolean;
- loadMore: () => void;
- markAsRead: (id: number) => Promise
;
- markAllAsRead: () => Promise;
-}
-
-export type ActiveSlideoutPanel = "inbox" | null;
-
-// Inbox-related props — per-tab data sources with independent loading/pagination
-interface InboxProps {
- isOpen: boolean;
- totalUnreadCount: number;
- comments: TabDataSource;
- status: TabDataSource;
-}
-
interface LayoutShellProps {
workspaces: Workspace[];
activeWorkspaceId: number | null;
@@ -134,17 +108,12 @@ interface LayoutShellProps {
defaultCollapsed?: boolean;
isChatPage?: boolean;
isAllChatsPage?: boolean;
- showPlaygroundSidebar?: boolean;
useWorkspacePanel?: boolean;
workspacePanelViewportClassName?: string;
workspacePanelContentClassName?: string;
children: React.ReactNode;
className?: string;
- // Unified slide-out panel state
- activeSlideoutPanel?: ActiveSlideoutPanel;
- onSlideoutPanelChange?: (panel: ActiveSlideoutPanel) => void;
- // Inbox props
- inbox?: InboxProps;
+ notifications?: NotificationsDropdownData;
isLoadingChats?: boolean;
onTabSwitch?: (tab: Tab) => void;
onTabPrefetch?: (tab: Tab) => void;
@@ -239,15 +208,12 @@ export function LayoutShell({
defaultCollapsed = false,
isChatPage = false,
isAllChatsPage = false,
- showPlaygroundSidebar = false,
useWorkspacePanel = false,
workspacePanelViewportClassName,
workspacePanelContentClassName,
children,
className,
- activeSlideoutPanel = null,
- onSlideoutPanelChange,
- inbox,
+ notifications,
isLoadingChats = false,
onTabSwitch,
onTabPrefetch,
@@ -269,17 +235,6 @@ export function LayoutShell({
[isCollapsed, setIsCollapsed, toggleCollapsed]
);
- const closeSlideout = useCallback(
- (open: boolean) => {
- if (!open) onSlideoutPanelChange?.(null);
- },
- [onSlideoutPanelChange]
- );
-
- const anySlideOutOpen = activeSlideoutPanel !== null;
-
- const panelAriaLabel = activeSlideoutPanel === "inbox" ? "Inbox" : "Panel";
-
// Mobile layout
if (isMobile) {
return (
@@ -316,6 +271,7 @@ export function LayoutShell({
onUserSettings={onUserSettings}
onAnnouncements={onAnnouncements}
announcementUnreadCount={announcementUnreadCount}
+ notifications={notifications}
onLogout={onLogout}
pageUsage={pageUsage}
theme={theme}
@@ -335,34 +291,6 @@ export function LayoutShell({
{children}
)}
-
- {/* Mobile unified slide-out panel */}
-
-
- {activeSlideoutPanel === "inbox" && inbox && (
-
- closeSlideout(open)}
- comments={inbox.comments}
- status={inbox.status}
- totalUnreadCount={inbox.totalUnreadCount}
- onCloseMobileSidebar={() => setMobileMenuOpen(false)}
- />
-
- )}
-
-
@@ -400,36 +328,18 @@ export function LayoutShell({
onUserSettings={onUserSettings}
onAnnouncements={onAnnouncements}
announcementUnreadCount={announcementUnreadCount}
+ notifications={notifications}
onLogout={onLogout}
theme={theme}
setTheme={setTheme}
/>
- {/* Playground second-level sidebar — contextual, desktop only. Sits
- between the icon rail and the main sidebar. On Mac it becomes the
- leftmost panel, so it takes the rounded-corner/left-border treatment. */}
- {showPlaygroundSidebar && activeWorkspaceId != null && (
-
- )}
-
{/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content. Negative right margin closes the flex gap so the sidebar sits flush against the main panel, separated only by a border. */}
)}
-
- {/* Unified slide-out panel — shell stays open, content cross-fades */}
-
-
- {activeSlideoutPanel === "inbox" && inbox && (
-
- closeSlideout(open)}
- comments={inbox.comments}
- status={inbox.status}
- totalUnreadCount={inbox.totalUnreadCount}
- />
-
- )}
-
-
diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
index e39075443..013714fcf 100644
--- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
@@ -47,6 +47,7 @@ export function ChatListItem({
const dropdownOpen = controlledOpen ?? internalOpen;
const setDropdownOpen = onDropdownOpenChange ?? setInternalOpen;
const animatedName = useTypewriter(name);
+ const isHighlighted = isActive || dropdownOpen;
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
useCallback(() => setDropdownOpen(true), [setDropdownOpen])
@@ -67,9 +68,9 @@ export function ChatListItem({
onFocus={onPrefetch}
{...(isMobile ? longPressHandlers : {})}
className={sidebarListItemClassName({
- active: isActive,
+ active: isHighlighted,
className:
- "justify-start gap-2 overflow-hidden px-2 py-1.5 font-normal focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
+ "justify-start gap-2 overflow-hidden px-2 py-1.5 font-normal group-hover/item:bg-accent group-hover/item:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
})}
>
{animatedName}
@@ -79,7 +80,7 @@ export function ChatListItem({
{formatUsd(balanceMicros)}
diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
index a5b67ae7d..10c4247eb 100644
--- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
@@ -3,6 +3,7 @@
import { useQuery } from "@rocicorp/zero/react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
+ Check,
FolderInput,
FolderPlus,
FolderSync,
@@ -50,6 +51,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
+import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -69,6 +71,7 @@ import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
+import { useIsMobile } from "@/hooks/use-mobile";
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { foldersApiService } from "@/lib/apis/folders-api.service";
@@ -127,60 +130,111 @@ export function EmbeddedDocumentsMenu({
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
onCreateFolder: () => void;
}) {
+ const isMobile = useIsMobile();
+ const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const documentTypes = useMemo(
() => Object.keys(typeCounts).sort() as DocumentTypeEnum[],
[typeCounts]
);
return (
-
-
-
+
+
+
+
+ {activeTypes.length > 0 ? (
+
+ ) : null}
+
+
+
+
+
+ New folder
+
+ {isMobile ? (
+ setFilterDrawerOpen(true)}>
+
+ Filter by type
+
+ ) : (
+
+
+
+ Filter by type
+
+
+ {documentTypes.length > 0 ? (
+ documentTypes.map((type) => (
+ onToggleType(type, checked === true)}
+ onSelect={(event) => event.preventDefault()}
+ >
+ {getDocumentTypeIcon(type, "h-4 w-4")}
+ {getDocumentTypeLabel(type)}
+
+ {typeCounts[type] ?? 0}
+
+
+ ))
+ ) : (
+ No document types
+ )}
+
+
+ )}
+
+
+
+
+
-
- {activeTypes.length > 0 ? (
-
- ) : null}
-
-
-
-
-
- New folder
-
-
-
-
+
+
Filter by type
-
-
+
+
{documentTypes.length > 0 ? (
- documentTypes.map((type) => (
-
onToggleType(type, checked === true)}
- onSelect={(event) => event.preventDefault()}
- >
- {getDocumentTypeIcon(type, "h-4 w-4")}
- {getDocumentTypeLabel(type)}
-
- {typeCounts[type] ?? 0}
-
-
- ))
+ documentTypes.map((type, index) => {
+ const isActive = activeTypes.includes(type);
+ return (
+
+ {index > 0 &&
}
+
onToggleType(type, !isActive)}
+ >
+ {getDocumentTypeIcon(type, "h-4 w-4")}
+ {getDocumentTypeLabel(type)}
+ {typeCounts[type] ?? 0}
+ {isActive && }
+
+
+ );
+ })
) : (
-
No document types
+
No document types
)}
-
-
-
-
+
+
+
+ >
);
}
@@ -230,10 +284,10 @@ export function EmbeddedImportMenu({
type="button"
variant="ghost"
size="icon"
- className="h-7 w-7 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
+ className="h-6 w-6 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Import documents"
>
-
+
@@ -1213,7 +1267,7 @@ function AuthenticatedDocumentsSidebarBase({
defaultOpen={true}
contentClassName="px-0"
persistentAction={
-
+
+
n[0])
- .join("")
- .toUpperCase()
- .slice(0, 2);
- }
- if (email) {
- const localPart = email.split("@")[0];
- return localPart.slice(0, 2).toUpperCase();
- }
- return "U";
-}
-
-function formatInboxCount(count: number): string {
- if (count <= 999) {
- return count.toString();
- }
- const thousands = Math.floor(count / 1000);
- return `${thousands}k+`;
-}
-
-function getConnectorTypeDisplayName(connectorType: string): string {
- const displayNames: Record = {
- GITHUB_CONNECTOR: "GitHub",
- GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
- GOOGLE_GMAIL_CONNECTOR: "Gmail",
- GOOGLE_DRIVE_CONNECTOR: "Google Drive",
- COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Composio Google Drive",
- COMPOSIO_GMAIL_CONNECTOR: "Composio Gmail",
- COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Composio Google Calendar",
- LINEAR_CONNECTOR: "Linear",
- NOTION_CONNECTOR: "Notion",
- SLACK_CONNECTOR: "Slack",
- TEAMS_CONNECTOR: "Microsoft Teams",
- DISCORD_CONNECTOR: "Discord",
- JIRA_CONNECTOR: "Jira",
- CONFLUENCE_CONNECTOR: "Confluence",
- BOOKSTACK_CONNECTOR: "BookStack",
- CLICKUP_CONNECTOR: "ClickUp",
- AIRTABLE_CONNECTOR: "Airtable",
- LUMA_CONNECTOR: "Luma",
- ELASTICSEARCH_CONNECTOR: "Elasticsearch",
- WEBCRAWLER_CONNECTOR: "Web Crawler",
- YOUTUBE_CONNECTOR: "YouTube",
- CIRCLEBACK_CONNECTOR: "Circleback",
- MCP_CONNECTOR: "MCP",
- OBSIDIAN_CONNECTOR: "Obsidian",
- ONEDRIVE_CONNECTOR: "OneDrive",
- DROPBOX_CONNECTOR: "Dropbox",
- TAVILY_API: "Tavily",
- SEARXNG_API: "SearXNG",
- LINKUP_API: "Linkup",
- BAIDU_SEARCH_API: "Baidu",
- };
-
- return (
- displayNames[connectorType] ||
- connectorType
- .replace(/_/g, " ")
- .replace(/CONNECTOR|API/gi, "")
- .trim()
- );
-}
-
-type InboxTab = "comments" | "status";
-type InboxFilter = "all" | "unread" | "errors";
-
-interface TabDataSource {
- items: InboxItem[];
- unreadCount: number;
- loading: boolean;
- loadingMore: boolean;
- hasMore: boolean;
- loadMore: () => void;
- markAsRead: (id: number) => Promise;
- markAllAsRead: () => Promise;
-}
-
-export interface InboxSidebarContentProps {
- onOpenChange: (open: boolean) => void;
- comments: TabDataSource;
- status: TabDataSource;
- totalUnreadCount: number;
- onCloseMobileSidebar?: () => void;
-}
-
-interface InboxSidebarProps extends InboxSidebarContentProps {
- open: boolean;
-}
-
-export function InboxSidebarContent({
- onOpenChange,
- comments,
- status,
- totalUnreadCount,
- onCloseMobileSidebar,
-}: InboxSidebarContentProps) {
- const t = useTranslations("sidebar");
- const router = useRouter();
- const params = useParams();
- const isMobile = !useMediaQuery("(min-width: 640px)");
- const workspaceId = getWorkspaceIdNumber(params) ?? null;
-
- const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
-
- const [searchQuery, setSearchQuery] = useState("");
- const debouncedSearch = useDebouncedValue(searchQuery, 300);
- const isSearchMode = !!debouncedSearch.trim();
- const [activeTab, setActiveTab] = useState("comments");
- const [activeFilter, setActiveFilter] = useState("all");
- const [selectedSource, setSelectedSource] = useState(null);
- const [mounted, setMounted] = useState(false);
- const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
- const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
- const connectorRafRef = useRef(null);
- const handleConnectorScroll = useCallback((e: React.UIEvent) => {
- const el = e.currentTarget;
- if (connectorRafRef.current) return;
- connectorRafRef.current = requestAnimationFrame(() => {
- const atTop = el.scrollTop <= 2;
- const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
- setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
- connectorRafRef.current = null;
- });
- }, []);
- useEffect(
- () => () => {
- if (connectorRafRef.current) cancelAnimationFrame(connectorRafRef.current);
- },
- []
- );
- const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
- const [markingAsReadId, setMarkingAsReadId] = useState(null);
-
- const prefetchTriggerRef = useRef(null);
-
- // Server-side search query
- const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined;
- const { data: searchResponse, isLoading: isSearchLoading } = useQuery({
- queryKey: cacheKeys.notifications.search(workspaceId, debouncedSearch.trim(), activeTab),
- queryFn: () =>
- notificationsApiService.getNotifications({
- queryParams: {
- workspace_id: workspaceId ?? undefined,
- type: searchTypeFilter,
- search: debouncedSearch.trim(),
- limit: 50,
- },
- }),
- staleTime: 30 * 1000,
- enabled: isSearchMode,
- });
-
- useEffect(() => {
- setMounted(true);
- }, []);
-
- useEffect(() => {
- if (!isMobile) return;
- const originalOverflow = document.body.style.overflow;
- document.body.style.overflow = "hidden";
- return () => {
- document.body.style.overflow = originalOverflow;
- };
- }, [isMobile]);
-
- useEffect(() => {
- if (activeTab !== "status") {
- setSelectedSource(null);
- }
- }, [activeTab]);
-
- // Active tab's data source — fully independent loading, pagination, and counts
- const activeSource = activeTab === "comments" ? comments : status;
-
- // Fetch source types for the status tab filter
- const { data: sourceTypesData } = useQuery({
- queryKey: cacheKeys.notifications.sourceTypes(workspaceId),
- queryFn: () => notificationsApiService.getSourceTypes(workspaceId ?? undefined),
- staleTime: 60 * 1000,
- enabled: activeTab === "status",
- });
-
- const statusSourceOptions = useMemo(() => {
- if (!sourceTypesData?.sources) return [];
-
- return sourceTypesData.sources.map((source) => ({
- key: source.key,
- type: source.type,
- category: source.category,
- displayName:
- source.category === "connector"
- ? getConnectorTypeDisplayName(source.type)
- : getDocumentTypeLabel(source.type),
- }));
- }, [sourceTypesData]);
-
- // Client-side filter: source type
- const matchesSourceFilter = useCallback(
- (item: InboxItem): boolean => {
- if (!selectedSource) return true;
- if (selectedSource.startsWith("connector:")) {
- const connectorType = selectedSource.slice("connector:".length);
- return (
- item.type === "connector_indexing" &&
- isConnectorIndexingMetadata(item.metadata) &&
- item.metadata.connector_type === connectorType
- );
- }
- if (selectedSource.startsWith("doctype:")) {
- const docType = selectedSource.slice("doctype:".length);
- return (
- item.type === "document_processing" &&
- isDocumentProcessingMetadata(item.metadata) &&
- item.metadata.document_type === docType
- );
- }
- return true;
- },
- [selectedSource]
- );
-
- // Client-side filter: unread / errors
- const matchesActiveFilter = useCallback(
- (item: InboxItem): boolean => {
- if (activeFilter === "unread") return !item.read;
- if (activeFilter === "errors") {
- if (item.type === "insufficient_credits") return true;
- const meta = item.metadata as Record | undefined;
- return typeof meta?.status === "string" && meta.status === "failed";
- }
- return true;
- },
- [activeFilter]
- );
-
- // Defer non-urgent list updates so the search input stays responsive.
- // The deferred snapshot lags one render behind the live value intentionally.
- const deferredTabItems = useDeferredValue(activeSource.items);
- const deferredSearchItems = useDeferredValue(searchResponse?.items ?? []);
-
- // Two data paths: search mode (API) or default (per-tab data source)
- const filteredItems = useMemo(() => {
- const tabItems: InboxItem[] = isSearchMode ? deferredSearchItems : deferredTabItems;
-
- let result = tabItems;
- if (activeFilter !== "all") {
- result = result.filter(matchesActiveFilter);
- }
- if (activeTab === "status" && selectedSource) {
- result = result.filter(matchesSourceFilter);
- }
-
- return result;
- }, [
- isSearchMode,
- deferredSearchItems,
- deferredTabItems,
- activeTab,
- activeFilter,
- selectedSource,
- matchesActiveFilter,
- matchesSourceFilter,
- ]);
-
- // Infinite scroll — uses active tab's pagination
- useEffect(() => {
- if (!activeSource.hasMore || activeSource.loadingMore || isSearchMode) return;
-
- const observer = new IntersectionObserver(
- (entries) => {
- if (entries[0]?.isIntersecting) {
- activeSource.loadMore();
- }
- },
- {
- root: null,
- rootMargin: "100px",
- threshold: 0,
- }
- );
-
- if (prefetchTriggerRef.current) {
- observer.observe(prefetchTriggerRef.current);
- }
-
- return () => observer.disconnect();
- }, [activeSource.hasMore, activeSource.loadingMore, activeSource.loadMore, isSearchMode]);
-
- const handleItemClick = useCallback(
- async (item: InboxItem) => {
- if (!item.read) {
- setMarkingAsReadId(item.id);
- await activeSource.markAsRead(item.id);
- setMarkingAsReadId(null);
- }
-
- if (item.type === "new_mention") {
- if (isNewMentionMetadata(item.metadata)) {
- const workspaceId = item.workspace_id;
- const threadId = item.metadata.thread_id;
- const commentId = item.metadata.comment_id;
-
- if (workspaceId && threadId) {
- if (commentId) {
- setTargetCommentId(commentId);
- }
- const url = commentId
- ? `/dashboard/${workspaceId}/new-chat/${threadId}?commentId=${commentId}`
- : `/dashboard/${workspaceId}/new-chat/${threadId}`;
- onOpenChange(false);
- onCloseMobileSidebar?.();
- router.push(url);
- }
- }
- } else if (item.type === "comment_reply") {
- if (isCommentReplyMetadata(item.metadata)) {
- const workspaceId = item.workspace_id;
- const threadId = item.metadata.thread_id;
- const replyId = item.metadata.reply_id;
-
- if (workspaceId && threadId) {
- if (replyId) {
- setTargetCommentId(replyId);
- }
- const url = replyId
- ? `/dashboard/${workspaceId}/new-chat/${threadId}?commentId=${replyId}`
- : `/dashboard/${workspaceId}/new-chat/${threadId}`;
- onOpenChange(false);
- onCloseMobileSidebar?.();
- router.push(url);
- }
- }
- } else if (item.type === "insufficient_credits") {
- if (isInsufficientCreditsMetadata(item.metadata)) {
- const actionUrl = item.metadata.action_url;
- if (actionUrl) {
- onOpenChange(false);
- onCloseMobileSidebar?.();
- router.push(actionUrl);
- }
- }
- }
- },
- [activeSource.markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
- );
-
- const handleMarkAllAsRead = useCallback(async () => {
- await Promise.all([comments.markAllAsRead(), status.markAllAsRead()]);
- }, [comments.markAllAsRead, status.markAllAsRead]);
-
- const handleClearSearch = useCallback(() => {
- setSearchQuery("");
- }, []);
-
- const formatTime = (dateString: string) => {
- try {
- const date = new Date(dateString);
- const now = new Date();
- const diffMs = now.getTime() - date.getTime();
- const diffMins = Math.floor(diffMs / (1000 * 60));
- const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
- const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
-
- if (diffMins < 1) return "now";
- if (diffMins < 60) return `${diffMins}m`;
- if (diffHours < 24) return `${diffHours}h`;
- if (diffDays < 7) return `${diffDays}d`;
- return `${Math.floor(diffDays / 7)}w`;
- } catch {
- return "now";
- }
- };
-
- const getStatusIcon = (item: InboxItem) => {
- if (item.type === "new_mention" || item.type === "comment_reply") {
- const metadata =
- item.type === "new_mention"
- ? isNewMentionMetadata(item.metadata)
- ? item.metadata
- : null
- : isCommentReplyMetadata(item.metadata)
- ? item.metadata
- : null;
-
- if (metadata) {
- return (
-
- {metadata.author_avatar_url && (
-
- )}
-
- {getInitials(metadata.author_name, metadata.author_email)}
-
-
- );
- }
- return (
-
-
- {getInitials(null, null)}
-
-
- );
- }
-
- if (item.type === "insufficient_credits") {
- return (
-
- );
- }
-
- const metadata = item.metadata as Record;
- const status = typeof metadata?.status === "string" ? metadata.status : undefined;
-
- switch (status) {
- case "in_progress":
- return (
-
-
-
- );
- case "completed":
- return (
-
-
-
- );
- case "failed":
- return (
-
- );
- default:
- return (
-
-
-
- );
- }
- };
-
- const getEmptyStateMessage = () => {
- if (activeTab === "comments") {
- return {
- title: t("no_comments") || "No comments",
- hint: t("no_comments_hint") || "You'll see mentions and replies here",
- };
- }
- return {
- title: t("no_status_updates") || "No status updates",
- hint: t("no_status_updates_hint") || "Document and connector updates will appear here",
- };
- };
-
- if (!mounted) return null;
-
- const isLoading = isSearchMode ? isSearchLoading : activeSource.loading;
-
- return (
- <>
-
-
-
- {isMobile && (
- onOpenChange(false)}
- >
-
- {t("close") || "Close"}
-
- )}
-
{t("inbox") || "Inbox"}
-
-
- {isMobile ? (
- <>
-
setFilterDrawerOpen(true)}
- >
-
- {t("filter") || "Filter"}
-
-
-
-
-
-
-
- {t("filter") || "Filter"}
-
-
-
-
-
- {t("filter") || "Filter"}
-
-
-
{
- setActiveFilter("all");
- setFilterDrawerOpen(false);
- }}
- className={cn(
- "h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
- activeFilter === "all"
- ? "bg-primary/10 text-primary"
- : "hover:bg-muted"
- )}
- >
-
-
- {t("all") || "All"}
-
- {activeFilter === "all" && }
-
-
{
- setActiveFilter("unread");
- setFilterDrawerOpen(false);
- }}
- className={cn(
- "h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
- activeFilter === "unread"
- ? "bg-primary/10 text-primary"
- : "hover:bg-muted"
- )}
- >
-
-
- {t("unread") || "Unread"}
-
- {activeFilter === "unread" && }
-
- {activeTab === "status" && (
-
{
- setActiveFilter("errors");
- setFilterDrawerOpen(false);
- }}
- className={cn(
- "h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
- activeFilter === "errors"
- ? "bg-primary/10 text-primary"
- : "hover:bg-muted"
- )}
- >
-
-
- {t("errors_only") || "Errors only"}
-
- {activeFilter === "errors" && }
-
- )}
-
-
- {activeTab === "status" && statusSourceOptions.length > 0 && (
-
-
- {t("sources") || "Sources"}
-
-
- {
- setSelectedSource(null);
- setFilterDrawerOpen(false);
- }}
- className={cn(
- "h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
- selectedSource === null
- ? "bg-primary/10 text-primary"
- : "hover:bg-muted"
- )}
- >
-
-
- {t("all_sources") || "All sources"}
-
- {selectedSource === null && }
-
- {statusSourceOptions.map((source) => (
- {
- setSelectedSource(source.key);
- setFilterDrawerOpen(false);
- }}
- className={cn(
- "h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
- selectedSource === source.key
- ? "bg-primary/10 text-primary"
- : "hover:bg-muted"
- )}
- >
-
- {getConnectorIcon(source.type, "h-4 w-4")}
- {source.displayName}
-
- {selectedSource === source.key && }
-
- ))}
-
-
- )}
-
-
-
- >
- ) : (
-
setOpenDropdown(isOpen ? "filter" : null)}
- >
-
-
-
-
-
- {t("filter") || "Filter"}
-
-
-
- {t("filter") || "Filter"}
-
-
-
- {t("filter") || "Filter"}
-
- setActiveFilter("all")}
- className="flex items-center justify-between"
- >
-
-
- {t("all") || "All"}
-
- {activeFilter === "all" && }
-
- setActiveFilter("unread")}
- className="flex items-center justify-between"
- >
-
-
- {t("unread") || "Unread"}
-
- {activeFilter === "unread" && }
-
- {activeTab === "status" && (
- setActiveFilter("errors")}
- className="flex items-center justify-between"
- >
-
-
- {t("errors_only") || "Errors only"}
-
- {activeFilter === "errors" && }
-
- )}
- {activeTab === "status" && statusSourceOptions.length > 0 && (
- <>
-
- {t("sources") || "Sources"}
-
-
- setSelectedSource(null)}
- className="flex items-center justify-between"
- >
-
-
- {t("all_sources") || "All sources"}
-
- {selectedSource === null && }
-
- {statusSourceOptions.map((source) => (
- setSelectedSource(source.key)}
- className="flex items-center justify-between"
- >
-
- {getConnectorIcon(source.type, "h-4 w-4")}
- {source.displayName}
-
- {selectedSource === source.key && }
-
- ))}
-
- >
- )}
-
-
- )}
-
-
-
-
- {t("mark_all_read") || "Mark all as read"}
-
-
-
- {t("mark_all_read") || "Mark all as read"}
-
-
-
-
-
-
-
- setSearchQuery(e.target.value)}
- className="h-8 border-0 bg-muted pl-8 pr-7 text-sm shadow-none"
- />
- {searchQuery && (
-
-
- {t("clear_search") || "Clear search"}
-
- )}
-
-
-
- {
- const tab = value as InboxTab;
- setActiveTab(tab);
- if (tab !== "status" && activeFilter === "errors") {
- setActiveFilter("all");
- }
- }}
- className="shrink-0 mx-3 mt-1.5"
- >
-
-
-
-
- {t("comments") || "Comments"}
-
- {formatInboxCount(comments.unreadCount)}
-
-
-
-
-
-
- {t("status") || "Status"}
-
- {formatInboxCount(status.unreadCount)}
-
-
-
-
-
-
-
- {isLoading ? (
-
- {activeTab === "comments"
- ? [85, 60, 90, 70, 50, 75].map((titleWidth) => (
-
- ))
- : [75, 90, 55, 80, 65, 85].map((titleWidth) => (
-
- ))}
-
- ) : filteredItems.length > 0 ? (
-
- {filteredItems.map((item, index) => {
- const isMarkingAsRead = markingAsReadId === item.id;
- const isPrefetchTrigger =
- !isSearchMode && activeSource.hasMore && index === filteredItems.length - 5;
-
- return (
-
- {activeTab === "status" ? (
-
-
- handleItemClick(item)}
- disabled={isMarkingAsRead}
- className="h-auto flex-1 justify-start gap-3 overflow-hidden bg-transparent p-0 text-left hover:bg-transparent"
- >
- {getStatusIcon(item)}
-
-
- {item.title}
-
-
- {convertRenderedToDisplay(item.message)}
-
-
-
-
-
- {item.title}
-
- {convertRenderedToDisplay(item.message)}
-
-
-
- ) : (
-
handleItemClick(item)}
- disabled={isMarkingAsRead}
- className="h-auto flex-1 justify-start gap-3 overflow-hidden bg-transparent p-0 text-left hover:bg-transparent"
- >
- {getStatusIcon(item)}
-
-
- {item.title}
-
-
- {convertRenderedToDisplay(item.message)}
-
-
-
- )}
-
-
-
- {formatTime(item.created_at)}
-
- {!item.read && }
-
-
- );
- })}
- {!isSearchMode && filteredItems.length < 5 && activeSource.hasMore && (
-
- )}
- {activeSource.loadingMore &&
- (activeTab === "comments"
- ? [80, 60, 90].map((titleWidth) => (
-
- ))
- : [70, 85, 55].map((titleWidth) => (
-
- )))}
-
- ) : isSearchMode ? (
-
-
-
- {t("no_results_found") || "No results found"}
-
-
- {t("try_different_search") || "Try a different search term"}
-
-
- ) : (
-
- {activeTab === "comments" ? (
-
- ) : (
-
- )}
-
{getEmptyStateMessage().title}
-
- {getEmptyStateMessage().hint}
-
-
- )}
-
- >
- );
-}
-
-export function InboxSidebar({
- open,
- onOpenChange,
- comments,
- status,
- totalUnreadCount,
- onCloseMobileSidebar,
-}: InboxSidebarProps) {
- const t = useTranslations("sidebar");
-
- return (
-
-
-
- );
-}
diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
index f9901d7cb..1d02b56f4 100644
--- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
@@ -6,6 +6,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import type { ChatItem, NavItem, PageUsage, User, Workspace } from "../../types/layout.types";
import { WorkspaceAvatar } from "../icon-rail/WorkspaceAvatar";
+import { NotificationsDropdown, type NotificationsDropdownData } from "./NotificationsDropdown";
import { Sidebar } from "./Sidebar";
import { SidebarUserProfile } from "./SidebarUserProfile";
@@ -35,6 +36,7 @@ interface MobileSidebarProps {
onUserSettings?: () => void;
onAnnouncements?: () => void;
announcementUnreadCount?: number;
+ notifications?: NotificationsDropdownData;
onLogout?: () => void;
pageUsage?: PageUsage;
theme?: string;
@@ -83,6 +85,7 @@ export function MobileSidebar({
onUserSettings,
onAnnouncements,
announcementUnreadCount = 0,
+ notifications,
onLogout,
pageUsage,
theme,
@@ -161,11 +164,19 @@ export function MobileSidebar({
isCollapsed
theme={theme}
setTheme={setTheme}
+ topContent={
+ notifications ? (
+ onOpenChange(false)}
+ />
+ ) : undefined
+ }
/>
{/* Sidebar Content - right side */}
-
+
void;
+ markAsRead: (id: number) => Promise;
+ markAllAsRead: () => Promise;
+}
+
+export interface NotificationsDropdownData {
+ totalUnreadCount: number;
+ comments: NotificationsDataSource;
+ status: NotificationsDataSource;
+}
+
+interface NotificationsDropdownProps {
+ notifications: NotificationsDropdownData;
+ onCloseMobileSidebar?: () => void;
+}
+
+type NotificationFilter = "all" | "mentions" | "unread";
+
+function formatNotificationCount(count: number): string {
+ if (count <= 999) {
+ return count.toString();
+ }
+ const thousands = Math.floor(count / 1000);
+ return `${thousands}k+`;
+}
+
+function formatTime(dateString: string): string {
+ try {
+ const date = new Date(dateString);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffMins = Math.floor(diffMs / (1000 * 60));
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+ if (diffMins < 1) return "now";
+ if (diffMins < 60) return `${diffMins}m ago`;
+ if (diffHours < 24) return `${diffHours}h ago`;
+ if (diffDays < 7) return `${diffDays}d ago`;
+ return `${Math.floor(diffDays / 7)}w ago`;
+ } catch {
+ return "now";
+ }
+}
+
+function isCommentNotification(item: InboxItem): boolean {
+ return item.type === "new_mention" || item.type === "comment_reply";
+}
+
+export function NotificationsDropdown({
+ notifications,
+ onCloseMobileSidebar,
+}: NotificationsDropdownProps) {
+ const router = useRouter();
+ const isMobile = useIsMobile();
+ const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
+ const [open, setOpen] = useState(false);
+ const [activeFilter, setActiveFilter] = useState("all");
+ const [markingAsReadId, setMarkingAsReadId] = useState(null);
+ const [markingAllAsRead, setMarkingAllAsRead] = useState(false);
+ const scrollContainerRef = useRef(null);
+ const loadMoreTriggerRef = useRef(null);
+
+ const unreadLabel = formatNotificationCount(notifications.totalUnreadCount);
+ const allCount =
+ (notifications.comments.totalCount ?? 0) + (notifications.status.totalCount ?? 0);
+ const mentionsCount = notifications.comments.totalCount ?? notifications.comments.items.length;
+ const visibleUnreadCount =
+ activeFilter === "mentions"
+ ? notifications.comments.unreadCount
+ : notifications.totalUnreadCount;
+ const isLoading =
+ activeFilter === "mentions"
+ ? notifications.comments.loading
+ : notifications.comments.loading || notifications.status.loading;
+ const isLoadingMore =
+ activeFilter === "mentions"
+ ? !!notifications.comments.loadingMore
+ : !!notifications.comments.loadingMore || !!notifications.status.loadingMore;
+ const hasMore =
+ activeFilter === "mentions"
+ ? !!notifications.comments.hasMore
+ : !!notifications.comments.hasMore || !!notifications.status.hasMore;
+ const items = useMemo(() => {
+ const sourceItems =
+ activeFilter === "mentions"
+ ? notifications.comments.items
+ : [...notifications.comments.items, ...notifications.status.items];
+
+ return sourceItems
+ .filter((item) => activeFilter !== "unread" || !item.read)
+ .toSorted((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
+ }, [activeFilter, notifications.comments.items, notifications.status.items]);
+
+ const loadMoreForActiveFilter = useCallback(() => {
+ if (isLoadingMore) return;
+
+ if (activeFilter === "mentions") {
+ if (notifications.comments.hasMore) {
+ notifications.comments.loadMore?.();
+ }
+ return;
+ }
+
+ if (notifications.comments.hasMore) {
+ notifications.comments.loadMore?.();
+ }
+ if (notifications.status.hasMore) {
+ notifications.status.loadMore?.();
+ }
+ }, [activeFilter, isLoadingMore, notifications.comments, notifications.status]);
+
+ useEffect(() => {
+ if (!open || isLoading || isLoadingMore || !hasMore) return;
+ const root = scrollContainerRef.current;
+ const target = loadMoreTriggerRef.current;
+ if (!root || !target) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting) {
+ loadMoreForActiveFilter();
+ }
+ },
+ {
+ root,
+ rootMargin: "120px",
+ threshold: 0,
+ }
+ );
+
+ observer.observe(target);
+ return () => observer.disconnect();
+ }, [hasMore, isLoading, isLoadingMore, loadMoreForActiveFilter, open]);
+
+ const markItemAsRead = useCallback(
+ async (item: InboxItem) => {
+ if (item.read) return;
+ setMarkingAsReadId(item.id);
+ try {
+ await (isCommentNotification(item)
+ ? notifications.comments.markAsRead(item.id)
+ : notifications.status.markAsRead(item.id));
+ } finally {
+ setMarkingAsReadId(null);
+ }
+ },
+ [notifications.comments, notifications.status]
+ );
+
+ const handleItemClick = useCallback(
+ async (item: InboxItem) => {
+ await markItemAsRead(item);
+
+ if (item.type === "new_mention" && isNewMentionMetadata(item.metadata)) {
+ const threadId = item.metadata.thread_id;
+ const commentId = item.metadata.comment_id;
+ if (item.workspace_id && threadId) {
+ if (commentId) setTargetCommentId(commentId);
+ setOpen(false);
+ onCloseMobileSidebar?.();
+ router.push(
+ commentId
+ ? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${commentId}`
+ : `/dashboard/${item.workspace_id}/new-chat/${threadId}`
+ );
+ }
+ return;
+ }
+
+ if (item.type === "comment_reply" && isCommentReplyMetadata(item.metadata)) {
+ const threadId = item.metadata.thread_id;
+ const replyId = item.metadata.reply_id;
+ if (item.workspace_id && threadId) {
+ if (replyId) setTargetCommentId(replyId);
+ setOpen(false);
+ onCloseMobileSidebar?.();
+ router.push(
+ replyId
+ ? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${replyId}`
+ : `/dashboard/${item.workspace_id}/new-chat/${threadId}`
+ );
+ }
+ return;
+ }
+
+ if (item.type === "insufficient_credits" && isInsufficientCreditsMetadata(item.metadata)) {
+ if (item.metadata.action_url) {
+ setOpen(false);
+ onCloseMobileSidebar?.();
+ router.push(item.metadata.action_url);
+ }
+ }
+ },
+ [markItemAsRead, onCloseMobileSidebar, router, setTargetCommentId]
+ );
+
+ const handleMarkAllAsRead = useCallback(async () => {
+ if (visibleUnreadCount === 0 || markingAllAsRead) return;
+ setMarkingAllAsRead(true);
+ try {
+ if (activeFilter === "mentions") {
+ await notifications.comments.markAllAsRead();
+ } else {
+ await Promise.all([
+ notifications.comments.markAllAsRead(),
+ notifications.status.markAllAsRead(),
+ ]);
+ }
+ } finally {
+ setMarkingAllAsRead(false);
+ }
+ }, [activeFilter, markingAllAsRead, notifications, visibleUnreadCount]);
+
+ const emptyStateCopy =
+ activeFilter === "mentions"
+ ? {
+ title: "No mentions",
+ description: "Mentions and replies will appear here.",
+ }
+ : activeFilter === "unread"
+ ? {
+ title: "No unread notifications",
+ description: "New mentions and status updates will appear here.",
+ }
+ : {
+ title: "No notifications",
+ description: "Mentions, replies, and status updates will appear here.",
+ };
+
+ const tabs: { value: NotificationFilter; label: string; count: number }[] = [
+ { value: "all", label: "All", count: allCount },
+ { value: "mentions", label: "Mentions", count: mentionsCount },
+ { value: "unread", label: "Unread", count: notifications.totalUnreadCount },
+ ];
+
+ const triggerButton = (
+
+
+ {notifications.totalUnreadCount > 0 ? (
+
+ {unreadLabel}
+
+ ) : null}
+
+ );
+
+ const panelContent = (
+ <>
+
+
+
Notifications
+
+
+ {markingAllAsRead ? : null}
+ Mark all read
+
+
+
+
+ {tabs.map((tab) => {
+ const isActive = activeFilter === tab.value;
+ return (
+ setActiveFilter(tab.value)}
+ className={cn(
+ "relative z-10 flex h-11 items-center gap-2 border-b-2 border-transparent px-0 text-sm font-medium text-muted-foreground transition-colors",
+ "hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
+ isActive && "border-primary text-primary"
+ )}
+ >
+ {tab.label}
+
+ {formatNotificationCount(tab.count)}
+
+
+ );
+ })}
+
+
+
+ {isLoading ? (
+
+ {[82, 64, 74].map((width) => (
+
+ ))}
+
+ ) : items.length > 0 ? (
+
+ {items.map((item) => {
+ const isMarkingAsRead = markingAsReadId === item.id;
+ return (
+
handleItemClick(item)}
+ className={cn(
+ "group h-auto w-full justify-start rounded-lg px-2 py-2 text-left",
+ "hover:bg-accent hover:text-accent-foreground",
+ !item.read && "bg-accent/40"
+ )}
+ style={{ contentVisibility: "auto", containIntrinsicSize: "0 72px" }}
+ >
+
+
+
+ {item.title}
+
+ {!item.read ? (
+
+ ) : null}
+
+
+ {item.message}
+
+
+ {formatTime(item.created_at)}
+
+
+ {isMarkingAsRead ? : null}
+
+ );
+ })}
+ {hasMore ? (
+
+ {isLoadingMore ? : null}
+
+ ) : null}
+
+ ) : (
+
+
{emptyStateCopy.title}
+
{emptyStateCopy.description}
+ {hasMore ? (
+
+ {isLoadingMore ? : null}
+ Load more
+
+ ) : null}
+
+ )}
+
+ >
+ );
+
+ if (isMobile) {
+ return (
+
+ {triggerButton}
+
+
+ Notifications
+ {panelContent}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {triggerButton}
+
+
+ Notifications
+
+
+
+ {panelContent}
+
+
+ );
+}
diff --git a/surfsense_web/components/layout/ui/sidebar/PlaygroundSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/PlaygroundSidebar.tsx
deleted file mode 100644
index 1c2f96212..000000000
--- a/surfsense_web/components/layout/ui/sidebar/PlaygroundSidebar.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-"use client";
-
-import { History, KeyRound } from "lucide-react";
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { ConnectAgentDialog } from "@/components/mcp/connect-agent-dialog";
-import { PLAYGROUND_PLATFORMS, type PlatformIcon } from "@/lib/playground/catalog";
-import { cn } from "@/lib/utils";
-
-interface PlaygroundSidebarProps {
- workspaceId: number | string;
-}
-
-function PlaygroundNavLink({
- href,
- label,
- icon: Icon,
- isActive,
- indented = false,
-}: {
- href: string;
- label: string;
- icon?: PlatformIcon;
- isActive: boolean;
- indented?: boolean;
-}) {
- return (
-
- {Icon ? : null}
- {label}
-
- );
-}
-
-export function PlaygroundSidebar({ workspaceId }: PlaygroundSidebarProps) {
- const pathname = usePathname();
- const base = `/dashboard/${workspaceId}/playground`;
-
- return (
-
-
- API Playground
-
-
-
-
-
- {PLAYGROUND_PLATFORMS.map((platform) => (
-
-
- {platform.verbs.map((verb) => {
- const href = `${base}/${platform.id}/${verb.verb}`;
- return (
-
- );
- })}
-
- ))}
-
-
-
-
-
-
- );
-}
diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
index 35746acf1..483d3a43d 100644
--- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
@@ -5,7 +5,7 @@ import Link from "next/link";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { type ReactNode, useMemo, useState } from "react";
-import { Badge } from "@/components/ui/badge";
+import { ConnectAgentDialog } from "@/components/mcp/connect-agent-dialog";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
@@ -18,7 +18,7 @@ import { ChatListItem } from "./ChatListItem";
import { CreditBalanceDisplay } from "./CreditBalanceDisplay";
import { DocumentsSidebar } from "./DocumentsSidebar";
import { NavSection } from "./NavSection";
-import { SidebarButton } from "./SidebarButton";
+import { SidebarButton, SidebarButtonBadge } from "./SidebarButton";
import { SidebarCollapseButton } from "./SidebarCollapseButton";
import { SidebarHeader } from "./SidebarHeader";
import { SidebarSection } from "./SidebarSection";
@@ -46,21 +46,6 @@ function ChatListSkeletonRows() {
);
}
-function CollapsedInboxIcon({ item }: { item: NavItem }) {
- const Icon = item.icon;
-
- return (
-
-
- {typeof item.badge === "string" ? (
-
- {item.badge}
-
- ) : null}
-
- );
-}
-
interface SidebarProps {
workspace: Workspace | null;
isCollapsed?: boolean;
@@ -138,10 +123,9 @@ export function Sidebar({
const [openDropdownChatId, setOpenDropdownChatId] = useState(null);
const [isSidebarNavScrolled, setIsSidebarNavScrolled] = useState(false);
- // Inbox, Automations, and Artifacts are rendered explicitly right below
+ // Automations, Artifacts, and Playground are rendered explicitly right below
// New Chat. Pull them out of the nav items list so they don't also appear
// in the bottom NavSection. Documents is embedded below Recents.
- const inboxItem = useMemo(() => navItems.find((item) => item.url === "#inbox"), [navItems]);
const automationsItem = useMemo(
() => navItems.find((item) => item.url.endsWith("/automations")),
[navItems]
@@ -158,7 +142,6 @@ export function Sidebar({
() =>
navItems.filter(
(item) =>
- item.url !== "#inbox" &&
!item.url.endsWith("/automations") &&
!item.url.endsWith("/artifacts") &&
!item.url.endsWith("/playground")
@@ -218,7 +201,7 @@ export function Sidebar({
@@ -235,23 +218,6 @@ export function Sidebar({
onScroll={(event) => setIsSidebarNavScrolled(event.currentTarget.scrollTop > 0)}
>
- {inboxItem && (
-
onNavItemClick?.(inboxItem)}
- isCollapsed={isCollapsed}
- isActive={inboxItem.isActive}
- badge={inboxItem.badge}
- collapsedIconNode={ }
- tooltipContent={isCollapsed ? inboxItem.title : undefined}
- buttonProps={
- {
- "data-joyride": "inbox-sidebar",
- } as React.ButtonHTMLAttributes
- }
- />
- )}
{automationsItem && (
onNavItemClick?.(playgroundItem)}
isCollapsed={isCollapsed}
isActive={playgroundItem.isActive}
+ badge={New }
tooltipContent={isCollapsed ? playgroundItem.title : undefined}
/>
)}
@@ -356,10 +323,16 @@ export function Sidebar({
/>
)}
+ {!isCollapsed && (
+
+
+
+ )}
+
0}
+ hasNavSectionAbove={footerNavItems.length > 0 || !isCollapsed}
onNavigate={onNavigate}
/>
@@ -436,29 +409,25 @@ function SidebarUsageFooter({
return (
-
+
-
-
- Earn credits
-
-
+
+ Earn
+
FREE
-
+
-
-
- Buy credits
-
+
+ Buy
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarButton.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarButton.tsx
index cc7f83e1d..1557253a9 100644
--- a/surfsense_web/components/layout/ui/sidebar/SidebarButton.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarButton.tsx
@@ -2,6 +2,7 @@
import type { LucideIcon } from "lucide-react";
import type React from "react";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
@@ -28,6 +29,26 @@ const baseClassName = cn(
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
);
+export function SidebarButtonBadge({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
export function SidebarButton({
icon: Icon,
label,
@@ -73,17 +94,19 @@ export function SidebarButton({
isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[260px] flex-1 opacity-100 ml-2"
)}
>
-
{label}
+
+ {label}
+ {!isCollapsed && badge && typeof badge !== "string" ? badge : null}
+ {!isCollapsed && badge && typeof badge === "string" ? (
+
+ {badge}
+
+ ) : null}
+
{!isCollapsed && trailingContent}
- {!isCollapsed && badge && typeof badge !== "string" ? badge : null}
- {!isCollapsed && badge && typeof badge === "string" ? (
-
- {badge}
-
- ) : null}
{collapsedOverlay && (
void;
- ariaLabel: string;
- width?: number;
- children: React.ReactNode;
-}
-
-/**
- * Reusable slide-out panel that extends from the sidebar.
- *
- * Desktop: absolutely positioned at the sidebar's right edge, overlaying the main
- * content with a blur backdrop. Does not push/shrink the main content.
- *
- * Mobile: full-width absolute overlay (unchanged).
- */
-export function SidebarSlideOutPanel({
- open,
- onOpenChange,
- ariaLabel,
- width = 360,
- children,
-}: SidebarSlideOutPanelProps) {
- const isMobile = useIsMobile();
- const bumpSlideoutOpenedTick = useSetAtom(slideoutOpenedTickAtom);
-
- useEffect(() => {
- if (open) {
- bumpSlideoutOpenedTick((tick) => tick + 1);
- }
- }, [open, bumpSlideoutOpenedTick]);
-
- const handleEscape = useCallback(
- (e: KeyboardEvent) => {
- if (e.key === "Escape") onOpenChange(false);
- },
- [onOpenChange]
- );
-
- useEffect(() => {
- if (!open) return;
- document.addEventListener("keydown", handleEscape);
- return () => document.removeEventListener("keydown", handleEscape);
- }, [open, handleEscape]);
-
- if (isMobile) {
- return (
-
- {open && (
-
-
- {children}
-
-
- )}
-
- );
- }
-
- return (
-
- {open && (
- <>
- {/* Blur backdrop covering the main content area (right of sidebar) */}
- onOpenChange(false)}
- aria-hidden="true"
- />
-
- {/* Panel extending from sidebar's right edge, flush with the wrapper border */}
-
-
- {children}
-
-
- >
- )}
-
- );
-}
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
index ea93ce4d0..d5e72174e 100644
--- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
@@ -2,6 +2,7 @@
import {
Check,
+ ChevronRight,
ChevronUp,
Download,
ExternalLink,
@@ -16,8 +17,10 @@ import {
} from "lucide-react";
import Image from "next/image";
import { useTranslations } from "next-intl";
-import { useState } from "react";
+import type React from "react";
+import { Fragment, useState } from "react";
import { Button } from "@/components/ui/button";
+import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
@@ -63,6 +66,8 @@ const LEARN_MORE_LINKS = [
{ key: "github" as const, href: "https://github.com/MODSetter/SurfSense" },
];
+type MobileProfileSubmenu = "theme" | "language" | "learn_more";
+
interface SidebarUserProfileProps {
user: User;
onUserSettings?: () => void;
@@ -72,6 +77,7 @@ interface SidebarUserProfileProps {
isCollapsed?: boolean;
theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void;
+ topContent?: React.ReactNode;
}
function formatAnnouncementCount(count: number): string {
@@ -134,6 +140,7 @@ export function SidebarUserProfile({
isCollapsed = false,
theme,
setTheme,
+ topContent,
}: SidebarUserProfileProps) {
const t = useTranslations("sidebar");
const { locale, setLocale } = useLocaleContext();
@@ -141,12 +148,14 @@ export function SidebarUserProfile({
const isDesktopViewport = useMediaQuery("(min-width: 768px)");
const { os, primary, isMobileOS } = usePrimaryDownload();
const [isLoggingOut, setIsLoggingOut] = useState(false);
+ const [mobileSubmenu, setMobileSubmenu] = useState(null);
const bgColor = getUserAvatarColor(user.email);
const initials = getUserInitials(user.email);
const displayName = user.name || user.email.split("@")[0];
const downloadUrl = primary?.url ?? GITHUB_RELEASES_URL;
const downloadLabel = t("download_for_os", { os });
const showDownloadCta = !isDesktop && !isMobileOS && isDesktopViewport;
+ const useMobileSubmenus = !isDesktopViewport;
const handleLanguageChange = (newLocale: "en" | "es" | "pt" | "hi" | "zh") => {
setLocale(newLocale);
@@ -166,11 +175,248 @@ export function SidebarUserProfile({
}
};
+ const submenuTriggerClassName = "cursor-default";
+ const drawerItemClassName = cn(
+ "flex h-12 w-full items-center gap-3 rounded-lg px-3 text-left text-sm transition-colors",
+ "hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
+ );
+ const drawerSeparatorClassName = "mx-3 h-px bg-popover-border";
+
+ const renderThemeSubmenu = () => {
+ if (!setTheme) return null;
+
+ if (useMobileSubmenus) {
+ return (
+ setMobileSubmenu("theme")}
+ >
+
+ {t("theme")}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {t("theme")}
+
+
+
+ {THEMES.map((themeOption) => {
+ const Icon = themeOption.icon;
+ const isSelected = theme === themeOption.value;
+ return (
+ handleThemeChange(themeOption.value)}
+ className={cn(
+ "mb-1 last:mb-0 transition-all",
+ "hover:bg-accent hover:text-accent-foreground",
+ isSelected && "text-primary"
+ )}
+ >
+
+ {t(themeOption.value)}
+ {isSelected && }
+
+ );
+ })}
+
+
+
+ );
+ };
+
+ const renderLanguageSubmenu = () => {
+ if (useMobileSubmenus) {
+ return (
+ setMobileSubmenu("language")}
+ >
+
+ {t("language")}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {t("language")}
+
+
+
+ {LANGUAGES.map((language) => {
+ const isSelected = locale === language.code;
+ return (
+ handleLanguageChange(language.code)}
+ className={cn(
+ "mb-1 last:mb-0 transition-all",
+ "hover:bg-accent hover:text-accent-foreground",
+ isSelected && "text-primary"
+ )}
+ >
+ {language.flag}
+ {language.name}
+ {isSelected && }
+
+ );
+ })}
+
+
+
+ );
+ };
+
+ const renderLearnMoreSubmenu = () => {
+ if (useMobileSubmenus) {
+ return (
+ setMobileSubmenu("learn_more")}
+ >
+
+ {t("learn_more")}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {t("learn_more")}
+
+
+
+ {LEARN_MORE_LINKS.map((link) => (
+
+
+ {t(link.key)}
+
+
+
+ ))}
+
+
+ v{APP_VERSION}
+
+
+
+
+ );
+ };
+
+ const mobileSubmenuDrawer = (
+ {
+ if (!open) setMobileSubmenu(null);
+ }}
+ shouldScaleBackground={false}
+ >
+
+
+
+ {mobileSubmenu === "theme"
+ ? t("theme")
+ : mobileSubmenu === "language"
+ ? t("language")
+ : t("learn_more")}
+
+
+ {mobileSubmenu === "theme" &&
+ setTheme &&
+ THEMES.map((themeOption, index) => {
+ const Icon = themeOption.icon;
+ const isSelected = theme === themeOption.value;
+ return (
+
+ {index > 0 &&
}
+ {
+ handleThemeChange(themeOption.value);
+ setMobileSubmenu(null);
+ }}
+ >
+
+ {t(themeOption.value)}
+ {isSelected && }
+
+
+ );
+ })}
+
+ {mobileSubmenu === "language" &&
+ LANGUAGES.map((language, index) => {
+ const isSelected = locale === language.code;
+ return (
+
+ {index > 0 &&
}
+ {
+ handleLanguageChange(language.code);
+ setMobileSubmenu(null);
+ }}
+ >
+ {language.flag}
+ {language.name}
+ {isSelected && }
+
+
+ );
+ })}
+
+ {mobileSubmenu === "learn_more" && (
+ <>
+ {LEARN_MORE_LINKS.map((link, index) => (
+
+ {index > 0 &&
}
+ setMobileSubmenu(null)}
+ >
+ {t(link.key)}
+
+
+
+ ))}
+
+ v{APP_VERSION}
+
+ >
+ )}
+
+
+
+ );
+
// Collapsed view - just show avatar with dropdown
if (isCollapsed) {
return (
-
+
+ {topContent}
{showDownloadCta && (
@@ -247,89 +493,11 @@ export function SidebarUserProfile({
)}
- {setTheme && (
-
-
-
- {t("theme")}
-
-
-
- {THEMES.map((themeOption) => {
- const Icon = themeOption.icon;
- const isSelected = theme === themeOption.value;
- return (
- handleThemeChange(themeOption.value)}
- className={cn(
- "mb-1 last:mb-0 transition-all",
- "hover:bg-accent hover:text-accent-foreground",
- isSelected && "text-primary"
- )}
- >
-
- {t(themeOption.value)}
- {isSelected && }
-
- );
- })}
-
-
-
- )}
+ {renderThemeSubmenu()}
-
-
-
- {t("language")}
-
-
-
- {LANGUAGES.map((language) => {
- const isSelected = locale === language.code;
- return (
- handleLanguageChange(language.code)}
- className={cn(
- "mb-1 last:mb-0 transition-all",
- "hover:bg-accent hover:text-accent-foreground",
- isSelected && "text-primary"
- )}
- >
- {language.flag}
- {language.name}
- {isSelected && }
-
- );
- })}
-
-
-
+ {renderLanguageSubmenu()}
-
-
-
- {t("learn_more")}
-
-
-
- {LEARN_MORE_LINKS.map((link) => (
-
-
- {t(link.key)}
-
-
-
- ))}
-
-
- v{APP_VERSION}
-
-
-
-
+ {renderLearnMoreSubmenu()}
{!isDesktop && !isMobileOS && (
@@ -352,6 +520,7 @@ export function SidebarUserProfile({
+ {mobileSubmenuDrawer}
);
@@ -429,89 +598,11 @@ export function SidebarUserProfile({
)}
- {setTheme && (
-
-
-
- {t("theme")}
-
-
-
- {THEMES.map((themeOption) => {
- const Icon = themeOption.icon;
- const isSelected = theme === themeOption.value;
- return (
- handleThemeChange(themeOption.value)}
- className={cn(
- "mb-1 last:mb-0 transition-all",
- "hover:bg-accent hover:text-accent-foreground",
- isSelected && "text-primary"
- )}
- >
-
- {t(themeOption.value)}
- {isSelected && }
-
- );
- })}
-
-
-
- )}
+ {renderThemeSubmenu()}
-
-
-
- {t("language")}
-
-
-
- {LANGUAGES.map((language) => {
- const isSelected = locale === language.code;
- return (
- handleLanguageChange(language.code)}
- className={cn(
- "mb-1 last:mb-0 transition-all",
- "hover:bg-accent hover:text-accent-foreground",
- isSelected && "text-primary"
- )}
- >
- {language.flag}
- {language.name}
- {isSelected && }
-
- );
- })}
-
-
-
+ {renderLanguageSubmenu()}
-
-
-
- {t("learn_more")}
-
-
-
- {LEARN_MORE_LINKS.map((link) => (
-
-
- {t(link.key)}
-
-
-
- ))}
-
-
- v{APP_VERSION}
-
-
-
-
+ {renderLearnMoreSubmenu()}
{!isDesktop && (
@@ -530,6 +621,7 @@ export function SidebarUserProfile({
+ {mobileSubmenuDrawer}
);
}
diff --git a/surfsense_web/components/layout/ui/sidebar/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts
index d1a1f85e5..e806b06d0 100644
--- a/surfsense_web/components/layout/ui/sidebar/index.ts
+++ b/surfsense_web/components/layout/ui/sidebar/index.ts
@@ -2,10 +2,9 @@ export { AllChatsWorkspaceContent } from "./AllChatsSidebar";
export { ChatListItem } from "./ChatListItem";
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
export { DocumentsSidebar } from "./DocumentsSidebar";
-export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { NavSection } from "./NavSection";
-export { PlaygroundSidebar } from "./PlaygroundSidebar";
+export { NotificationsDropdown } from "./NotificationsDropdown";
export { Sidebar } from "./Sidebar";
export { SidebarCollapseButton } from "./SidebarCollapseButton";
export { SidebarHeader } from "./SidebarHeader";
diff --git a/surfsense_web/components/mcp/connect-agent-dialog.tsx b/surfsense_web/components/mcp/connect-agent-dialog.tsx
index b48b849c6..fdbf08d71 100644
--- a/surfsense_web/components/mcp/connect-agent-dialog.tsx
+++ b/surfsense_web/components/mcp/connect-agent-dialog.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Cable } from "lucide-react";
+import { SidebarButtonBadge } from "@/components/layout/ui/sidebar/SidebarButton";
import { AgentSetupTabs } from "@/components/mcp/agent-setup-tabs";
import {
Dialog,
@@ -28,8 +28,18 @@ export function ConnectAgentDialog({ className }: { className?: string }) {
className
)}
>
-
- Connect your AI Agent
+
+
+ Connect your agent
+ New
+
diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx
index cc7d81307..74c76ad36 100644
--- a/surfsense_web/components/pricing/pricing-section.tsx
+++ b/surfsense_web/components/pricing/pricing-section.tsx
@@ -41,7 +41,7 @@ const demoPlans = [
"Premium models like GPT-5.5, Claude Sonnet 5, Gemini 3.1 Pro billed at provider cost",
"Scheduled and event-triggered agents for briefs, alerts, and monitoring",
"Write results back to Notion, Slack, Linear, and Jira",
- "Top up any amount, $1 buys $1 of credit, optional auto-reload",
+ "Add credit any time. $1 buys $1 of credit, with optional automatic refills",
"Priority support on Discord",
],
description: "",
@@ -95,7 +95,7 @@ const faqData: FAQSection[] = [
{
question: "How does Pay As You Go work?",
answer:
- "There is no monthly subscription. Start with $5 of free credit, and when you need more, top up any amount. $1 buys exactly $1 of credit, added to your balance immediately. You can enable auto-reload to top up automatically when your balance runs low, and turn it off any time.",
+ "There is no monthly subscription. Start with $5 of free credit, and when you need more, add any amount. $1 buys exactly $1 of credit, added to your balance immediately. You can enable automatic refills when your balance runs low, and turn them off any time."
},
{
question: "What happens if I run out of credit?",
diff --git a/surfsense_web/components/settings/auto-reload-settings.tsx b/surfsense_web/components/settings/auto-reload-settings.tsx
index b11a64353..ede826dd0 100644
--- a/surfsense_web/components/settings/auto-reload-settings.tsx
+++ b/surfsense_web/components/settings/auto-reload-settings.tsx
@@ -2,15 +2,15 @@
import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { AlertTriangle, CreditCard, RefreshCw } from "lucide-react";
+import { AlertTriangle, Info } from "lucide-react";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
@@ -69,7 +69,7 @@ export function AutoReloadSettings() {
const setupResult = searchParams.get("auto_reload_setup");
if (!setupResult) return;
if (setupResult === "success") {
- toast.success("Card saved. You can now enable auto-reload.");
+ toast.success("Card saved. You can now enable top-ups.");
queryClient.invalidateQueries({ queryKey: ["auto-reload-settings"] });
} else if (setupResult === "cancel") {
toast.info("Card setup canceled.");
@@ -92,19 +92,19 @@ export function AutoReloadSettings() {
mutationFn: stripeApiService.updateAutoReloadSettings,
onSuccess: (updated) => {
queryClient.setQueryData(["auto-reload-settings"], updated);
- toast.success(updated.enabled ? "Auto-reload is on." : "Auto-reload settings saved.");
+ toast.success(updated.enabled ? "Top-ups are on." : "Top-up settings saved.");
},
onError: (error) => {
if (error instanceof AppError && error.message) {
toast.error(error.message);
return;
}
- toast.error("Couldn't save auto-reload settings. Please try again.");
+ toast.error("Couldn't save top-up settings. Please try again.");
},
});
// Render nothing while loading (avoids a spinner flash on pages where the
- // feature flag turns out to be off) and when auto-reload is disabled
+ // feature flag turns out to be off) and when top-ups are disabled
// server-side.
if (isLoading || !settings || !settings.feature_enabled) {
return null;
@@ -131,7 +131,7 @@ export function AutoReloadSettings() {
return;
}
if (amountMicros == null || amountMicros < settings.min_amount_micros) {
- toast.error(`Reload amount must be at least $${minAmountDollars}.`);
+ toast.error(`Top-up amount must be at least $${minAmountDollars}.`);
return;
}
@@ -142,135 +142,168 @@ export function AutoReloadSettings() {
});
};
- return (
-
-
-
-
- Auto-reload
-
-
- Automatically top up your credit balance when it drops below a threshold, using a saved
- card. Current balance:{" "}
- {formatUsd(balanceMicros)} .
-
-
-
+ const addCardButton = (
+ setupMutation.mutate()}
+ disabled={setupMutation.isPending}
+ >
+ {setupMutation.isPending ? (
+ <>
+
+ Redirecting
+ >
+ ) : (
+ "Add a card"
+ )}
+
+ );
+
+ if (!hasCard) {
+ return (
+
{settings.failed_at && (
- Last auto-reload failed
+ Last top-up failed
- Your saved card was declined and auto-reload was turned off. Update your card and
- re-enable it below to keep topping up automatically.
+ Your saved card was declined and top-ups were turned off. Update your card and
+ re-enable top-ups below.
)}
- {!hasCard ? (
-
-
-
-
Add a card to enable automatic top-ups.
+
+
+
+
+
+ Automatically top up your credit balance when it drops below a threshold, using a
+ saved card. Current balance:{" "}
+ {formatUsd(balanceMicros)} .
+
- setupMutation.mutate()} disabled={setupMutation.isPending}>
- {setupMutation.isPending ? (
- <>
-
- Redirecting
- >
- ) : (
- "Add a card"
- )}
-
+ {addCardButton}
+
+
+
+
+ );
+ }
+
+ return (
+
+ {settings.failed_at && (
+
+
+ Last top-up failed
+
+ Your saved card was declined and top-ups were turned off. Update your card and
+ re-enable top-ups below.
+
+
+ )}
+
+
+
+
+
Automatic top-ups
+
+ Current balance:{" "}
+ {formatUsd(balanceMicros)}
+
- ) : (
- <>
-
-
-
- Enable auto-reload
-
-
- Charge your saved card when the balance gets low.
-
-
-
-
+
setupMutation.mutate()}
+ disabled={setupMutation.isPending}
+ >
+ {setupMutation.isPending ? (
+ <>
+
+ Redirecting
+ >
+ ) : (
+ "Update card"
+ )}
+
+
-
-
-
- When balance falls below
-
-
-
- $
-
- setThresholdInput(e.target.value)}
- disabled={!enabled}
- placeholder="5"
- />
-
-
-
-
- Add this much credit
-
-
-
- $
-
- setAmountInput(e.target.value)}
- disabled={!enabled}
- placeholder="10"
- />
-
-
Minimum ${minAmountDollars}.
-
-
+
+
+
+ Enable top-ups
+
+
+ Charge your saved card when the balance gets low.
+
+
+
+
-
-
setupMutation.mutate()}
- disabled={setupMutation.isPending}
- >
-
- Update card
-
-
- {saveMutation.isPending ? (
- <>
-
- Saving
- >
- ) : (
- "Save"
- )}
-
+
+
+
+ When balance falls below
+
+
+
+ $
+
+ setThresholdInput(e.target.value)}
+ disabled={!enabled}
+ placeholder="5"
+ />
- >
- )}
-
-
+
+
+
+ Add this much credit
+
+
+
+ $
+
+ setAmountInput(e.target.value)}
+ disabled={!enabled}
+ placeholder="10"
+ />
+
+
Minimum ${minAmountDollars}.
+
+
+
+
+
+ {saveMutation.isPending ? (
+ <>
+
+ Saving
+ >
+ ) : (
+ "Save"
+ )}
+
+
+
+
+
+
);
}
diff --git a/surfsense_web/components/ui/tabs.tsx b/surfsense_web/components/ui/tabs.tsx
index 5dbfc4bc5..693e246f4 100644
--- a/surfsense_web/components/ui/tabs.tsx
+++ b/surfsense_web/components/ui/tabs.tsx
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
void }) {
function EmptyState() {
return (
-
-
No artifacts yet
+
+
+
+
+
No artifacts yet
+
+ Artifacts collect the reports, resumes, podcasts, presentations, and images SurfSense
+ creates for this workspace. Generated deliverables from your chats will appear here.
+
);
}
diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts
index 1caf73b1d..eb33f84f7 100644
--- a/surfsense_web/hooks/use-inbox.ts
+++ b/surfsense_web/hooks/use-inbox.ts
@@ -54,6 +54,7 @@ export function useInbox(
const [hasMore, setHasMore] = useState(false);
const [error, setError] = useState
(null);
const [unreadCount, setUnreadCount] = useState(0);
+ const [totalCount, setTotalCount] = useState(0);
const initialLoadDoneRef = useRef(false);
const olderUnreadOffsetRef = useRef(null);
@@ -71,6 +72,7 @@ export function useInbox(
setLoading(true);
setInboxItems([]);
setHasMore(false);
+ setTotalCount(0);
initialLoadDoneRef.current = false;
olderUnreadOffsetRef.current = null;
apiUnreadTotalRef.current = 0;
@@ -97,6 +99,7 @@ export function useInbox(
if (cancelled) return;
setInboxItems(notificationsResponse.items);
+ setTotalCount(notificationsResponse.total);
setHasMore(notificationsResponse.has_more);
setUnreadCount(unreadResponse.total_unread);
apiUnreadTotalRef.current = unreadResponse.total_unread;
@@ -222,6 +225,7 @@ export function useInbox(
const deduped = newItems.filter((d) => !existingIds.has(d.id));
return [...prev, ...deduped];
});
+ setTotalCount(response.total);
setHasMore(response.has_more);
} catch (err) {
console.error(`[useInbox:${category}] Load more failed:`, err);
@@ -299,6 +303,7 @@ export function useInbox(
return {
inboxItems,
unreadCount,
+ totalCount,
markAsRead,
markAllAsRead,
loading,
diff --git a/surfsense_web/public/connectors/reddit.svg b/surfsense_web/public/connectors/reddit.svg
index 97b05bc57..488596ce1 100644
--- a/surfsense_web/public/connectors/reddit.svg
+++ b/surfsense_web/public/connectors/reddit.svg
@@ -1 +1,57 @@
-Reddit
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/surfsense_web/public/connectors/web.svg b/surfsense_web/public/connectors/web.svg
index 15a38b8c1..699027f7c 100644
--- a/surfsense_web/public/connectors/web.svg
+++ b/surfsense_web/public/connectors/web.svg
@@ -1 +1,8 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
+