Merge commit '1ae927a7db' into dev

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-07-08 15:05:11 -07:00
commit b8155c6c0e
116 changed files with 4331 additions and 3713 deletions

View file

@ -138,7 +138,7 @@ Agrega el servidor MCP de SurfSense a Claude, Cursor o tu propio framework de ag
{
"mcpServers": {
"surfsense": {
"url": "https://mcp.surfsense.com",
"url": "https://mcp.surfsense.com/mcp",
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
}
}

View file

@ -138,7 +138,7 @@ SurfSense MCP सर्वर को Claude, Cursor या अपने एज
{
"mcpServers": {
"surfsense": {
"url": "https://mcp.surfsense.com",
"url": "https://mcp.surfsense.com/mcp",
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
}
}

View file

@ -138,7 +138,7 @@ Add the SurfSense MCP server to Claude, Cursor, or your own agent framework:
{
"mcpServers": {
"surfsense": {
"url": "https://mcp.surfsense.com",
"url": "https://mcp.surfsense.com/mcp",
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
}
}

View file

@ -138,7 +138,7 @@ Adicione o servidor MCP do SurfSense ao Claude, ao Cursor ou ao seu próprio fra
{
"mcpServers": {
"surfsense": {
"url": "https://mcp.surfsense.com",
"url": "https://mcp.surfsense.com/mcp",
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
}
}

View file

@ -138,7 +138,7 @@ curl -X POST "$SURFSENSE_API_URL/workspaces/$WORKSPACE_ID/scrapers/reddit/scrape
{
"mcpServers": {
"surfsense": {
"url": "https://mcp.surfsense.com",
"url": "https://mcp.surfsense.com/mcp",
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
}
}

View file

@ -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(),
),

View file

@ -62,3 +62,4 @@ class Capability:
output_schema: type[BaseModel]
executor: Executor
billing_unit: BillingUnit | None
docs_url: str | None = None

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -0,0 +1,11 @@
.git
.gitignore
.venv
__pycache__/
*.py[cod]
*$py.class
*.so
.pytest_cache/
.ruff_cache/
.env
tests/

36
surfsense_mcp/Dockerfile Normal file
View file

@ -0,0 +1,36 @@
# syntax=docker.io/docker/dockerfile:1
# SurfSense MCP Server — remote (streamable-http) image.
# Serves /mcp (per-request API key, no baked secret) and a public /health probe.
# Stage 1: deps frozen from uv.lock so rebuilds never drift.
FROM python:3.12-slim AS deps
WORKDIR /app
# curl is used by the container healthcheck probe against /health.
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir uv && \
uv export --frozen --no-dev --no-emit-project --no-hashes \
--format requirements-txt -o /tmp/requirements.txt && \
uv pip install --system --no-cache-dir -r /tmp/requirements.txt && \
rm /tmp/requirements.txt
# Stage 2: project source; --no-deps since deps are already installed above.
FROM deps AS production
COPY . .
RUN uv pip install --system --no-cache-dir --no-deps -e .
ENV PYTHONUNBUFFERED=1 \
SURFSENSE_MCP_TRANSPORT=streamable-http \
SURFSENSE_MCP_HOST=0.0.0.0 \
SURFSENSE_MCP_PORT=8080 \
SURFSENSE_BASE_URL=https://api.surfsense.com
EXPOSE 8080
CMD ["python", "-m", "mcp_server"]

View file

@ -2,9 +2,15 @@
A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes
SurfSense to MCP clients like **Claude Code**, **Cursor**, and **Claude Desktop**.
It talks to a running SurfSense backend purely over its REST API using a SurfSense
API key — it imports no backend code and can point at any instance (local or
hosted) by changing two environment variables.
It talks to a SurfSense backend purely over its REST API using a SurfSense API
key — it imports no backend code.
Connect it two ways:
- **Hosted** (recommended) — point your client at `https://mcp.surfsense.com/mcp`
and pass your API key in a header. Nothing to install or keep running.
- **Self-host (stdio)** — run the server yourself against any backend (cloud or
your own). Best for self-hosters and clients without remote-server support.
## Tools
@ -29,34 +35,58 @@ Workspace-scoped tools default to the active workspace; pass `workspace` (a name
or id) to override for a single call. Ids never need to be typed by hand — the
model carries them between calls.
## Prerequisites
## Get an API key
1. A running SurfSense backend (default `http://localhost:8000`).
2. A **SurfSense API key**: SurfSense → Settings → API → create key (`ss_pat_…`).
3. **API access enabled** on the workspace(s) you want to use (workspace settings).
1. SurfSense → **API Playground → API Keys**: create a personal key (`ss_pat_…`).
It is shown only once.
2. Toggle **API key access** on for the workspace(s) you want to use.
## Setup
## Connect (hosted)
Uses [uv](https://github.com/astral-sh/uv):
Point your client at the hosted server and send the key as a Bearer token. For
clients that read an `mcpServers` map (Cursor, Claude Desktop, and others):
```json
{
"mcpServers": {
"surfsense": {
"url": "https://mcp.surfsense.com/mcp",
"headers": { "Authorization": "Bearer ss_pat_your_key_here" }
}
}
}
```
Claude Code, from a terminal:
```bash
claude mcp add --transport http surfsense https://mcp.surfsense.com/mcp \
--header "Authorization: Bearer ss_pat_your_key_here"
```
Most MCP clients accept this `url` + `headers` form; check your client's docs for
its exact remote-server field.
## Self-host (stdio)
Run the server yourself when you host your own backend or use a client without
remote support. It uses [uv](https://github.com/astral-sh/uv):
```bash
cd surfsense_mcp
uv sync
uv run python -m surfsense_mcp.selfcheck # verify tools register correctly
uv run python -m mcp_server.selfcheck # verify tools register correctly
```
## Connect it to a client
### Cursor
Add to `~/.cursor/mcp.json` (or a project `.cursor/mcp.json`):
Then add it to your client. Cursor (`~/.cursor/mcp.json` or a project
`.cursor/mcp.json`):
```json
{
"mcpServers": {
"surfsense": {
"command": "uv",
"args": ["run", "--directory", "/absolute/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"],
"args": ["run", "--directory", "/absolute/path/to/SurfSense/surfsense_mcp", "python", "-m", "mcp_server"],
"env": {
"SURFSENSE_BASE_URL": "http://localhost:8000",
"SURFSENSE_API_KEY": "ss_pat_your_token_here"
@ -66,24 +96,22 @@ Add to `~/.cursor/mcp.json` (or a project `.cursor/mcp.json`):
}
```
### Claude Code
Claude Code:
```bash
claude mcp add surfsense \
-e SURFSENSE_BASE_URL=http://localhost:8000 \
-e SURFSENSE_API_KEY=ss_pat_your_token_here \
-- uv run --directory /absolute/path/to/SurfSense/surfsense_mcp python -m surfsense_mcp
-- uv run --directory /absolute/path/to/SurfSense/surfsense_mcp python -m mcp_server
```
### Claude Desktop
Add the same `mcpServers` block as Cursor to
Claude Desktop: add the same `mcpServers` block as Cursor to
`claude_desktop_config.json` (Settings → Developer → Edit Config).
## Configuration
See `.env.example`. Secrets are passed as environment variables by the client;
never commit tokens.
See `.env.example`. For self-host, secrets are passed as environment variables by
the client; never commit tokens.
## Backend dependency

View file

@ -0,0 +1,58 @@
"""Entry point: load settings from the environment and run the MCP server.
Two transports share one build:
- ``stdio`` (default): Cursor/Claude launch one process per user; the key comes
from the environment, so it is required here.
- ``streamable-http``: one process serves many users, each passing their own key
per request; the key is enforced by the transport's auth middleware instead.
For stdio, stdout is the protocol channel, so every log line goes to stderr.
"""
from __future__ import annotations
import logging
import os
import sys
from mcp.server.fastmcp import FastMCP
from .config import Settings
from .server import build_server
def main() -> None:
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(levelname)s %(name)s: %(message)s",
)
settings = Settings.from_env()
transport = os.environ.get("SURFSENSE_MCP_TRANSPORT", "stdio").strip() or "stdio"
mcp, _client = build_server(settings)
if transport in ("streamable-http", "http"):
_run_http(mcp, settings)
return
if transport == "stdio" and not settings.api_key:
raise SystemExit(
"SURFSENSE_API_KEY is required for stdio transport. Create an API "
"key in SurfSense (Settings -> API) and pass it via the "
"SURFSENSE_API_KEY environment variable."
)
mcp.run(transport=transport)
def _run_http(mcp: FastMCP, settings: Settings) -> None:
"""Serve the streamable-http app directly, so the per-request identity
middleware wraps the SDK's MCP endpoint."""
import uvicorn
from .core.transport import build_http_app
uvicorn.run(build_http_app(mcp), host=settings.host, port=settings.port)
if __name__ == "__main__":
main()

View file

@ -12,6 +12,8 @@ from dataclasses import dataclass
DEFAULT_BASE_URL = "http://localhost:8000"
DEFAULT_API_PREFIX = "/api/v1"
DEFAULT_TIMEOUT_SECONDS = 180.0
DEFAULT_HTTP_HOST = "127.0.0.1"
DEFAULT_HTTP_PORT = 8080
@dataclass(frozen=True)
@ -19,10 +21,12 @@ class Settings:
"""Resolved configuration for a server process."""
base_url: str
api_key: str
api_key: str | None
api_prefix: str
timeout: float
default_workspace: str | None
host: str
port: int
@property
def api_base(self) -> str:
@ -30,13 +34,9 @@ class Settings:
@classmethod
def from_env(cls) -> Settings:
api_key = os.environ.get("SURFSENSE_API_KEY", "").strip()
if not api_key:
raise SystemExit(
"SURFSENSE_API_KEY is required. Create an API key in SurfSense "
"(Settings -> API) and pass it via the SURFSENSE_API_KEY "
"environment variable."
)
# Optional here: remote (http) callers pass their own key per request in
# a header. ``__main__`` enforces it for stdio, its only source of a key.
api_key = os.environ.get("SURFSENSE_API_KEY", "").strip() or None
base_url = (
os.environ.get("SURFSENSE_BASE_URL", DEFAULT_BASE_URL).strip().rstrip("/")
@ -53,10 +53,19 @@ class Settings:
default_workspace = os.environ.get("SURFSENSE_WORKSPACE", "").strip() or None
host = os.environ.get("SURFSENSE_MCP_HOST", "").strip() or DEFAULT_HTTP_HOST
raw_port = os.environ.get("SURFSENSE_MCP_PORT", "").strip()
try:
port = int(raw_port) if raw_port else DEFAULT_HTTP_PORT
except ValueError:
port = DEFAULT_HTTP_PORT
return cls(
base_url=base_url,
api_key=api_key,
api_prefix=api_prefix,
timeout=timeout,
default_workspace=default_workspace,
host=host,
port=port,
)

View file

@ -0,0 +1,7 @@
"""Per-request caller identity: header parsing, request-scoped storage, and the
ASGI middleware that binds them together for the remote transport."""
from .identity import current_api_key, current_identity
from .middleware import ApiKeyIdentityMiddleware
__all__ = ["current_api_key", "current_identity", "ApiKeyIdentityMiddleware"]

View file

@ -0,0 +1,24 @@
"""Extract a SurfSense API key from request headers.
Pure header parsing, kept separate from transport and state.
"""
from __future__ import annotations
from starlette.datastructures import Headers
_BEARER_PREFIX = "bearer "
def extract_api_key(headers: Headers) -> str | None:
"""Return the caller's key from the ``Authorization: Bearer`` slot the
backend already expects, falling back to ``X-API-Key`` for clients that can
only send custom headers."""
authorization = headers.get("authorization", "")
if authorization[: len(_BEARER_PREFIX)].lower() == _BEARER_PREFIX:
token = authorization[len(_BEARER_PREFIX) :].strip()
if token:
return token
fallback = headers.get("x-api-key", "").strip()
return fallback or None

View file

@ -0,0 +1,34 @@
"""Request-scoped caller identity.
Over streamable-http one process serves many users, so the caller's key lives in
a contextvar for the life of a request: the auth middleware binds it and the
client reads it when calling the backend. Under stdio there is no request, so the
contextvar is empty and the env key is used instead.
"""
from __future__ import annotations
from contextvars import ContextVar, Token
_LOCAL_IDENTITY = "__local__"
_api_key: ContextVar[str | None] = ContextVar("surfsense_api_key", default=None)
def bind_api_key(api_key: str | None) -> Token:
"""Bind the caller's key to the current request; returns a reset token."""
return _api_key.set(api_key)
def unbind_api_key(token: Token) -> None:
_api_key.reset(token)
def current_api_key() -> str | None:
"""The caller's key for the in-flight request, or ``None`` under stdio."""
return _api_key.get()
def current_identity() -> str:
"""Stable per-caller key for scoping request state; shared under stdio."""
return _api_key.get() or _LOCAL_IDENTITY

View file

@ -0,0 +1,58 @@
"""ASGI middleware that establishes the caller's identity for each request.
A pure ASGI middleware, deliberately not Starlette's ``BaseHTTPMiddleware``:
the latter runs the endpoint in a separate task, so a contextvar set in it does
not reach the tool handler. A pure middleware binds the key in the request's own
task, from which the SDK's per-request handling inherits it.
Requests without a key are rejected here so no tool ever runs unauthenticated.
Paths in ``public_paths`` (e.g. the health probe) skip the check entirely.
"""
from __future__ import annotations
from collections.abc import Iterable
from starlette.datastructures import Headers
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Receive, Scope, Send
from .headers import extract_api_key
from .identity import bind_api_key, unbind_api_key
class ApiKeyIdentityMiddleware:
"""Binds the per-request API key into the identity contextvar, or 401s."""
def __init__(self, app: ASGIApp, public_paths: Iterable[str] = ()) -> None:
self._app = app
self._public_paths = frozenset(public_paths)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or scope["path"] in self._public_paths:
await self._app(scope, receive, send)
return
api_key = extract_api_key(Headers(scope=scope))
if api_key is None:
await _unauthenticated()(scope, receive, send)
return
token = bind_api_key(api_key)
try:
await self._app(scope, receive, send)
finally:
unbind_api_key(token)
def _unauthenticated() -> JSONResponse:
return JSONResponse(
{
"error": "unauthorized",
"message": (
"Missing SurfSense API key. Send 'Authorization: Bearer "
"ss_pat_...' (or an X-API-Key header)."
),
},
status_code=401,
)

View file

@ -10,10 +10,11 @@ from typing import Any
import httpx
from .auth.identity import current_api_key
from .errors import ToolError
_FAILURE_HINTS: dict[int, str] = {
401: "Authentication failed — check that SURFSENSE_API_KEY is a valid, unexpired key.",
401: "Authentication failed — the SurfSense API key is invalid or expired.",
402: "The workspace is out of credits for this operation.",
403: (
"Access denied — the token lacks permission, or API access is disabled "
@ -27,17 +28,30 @@ _FAILURE_HINTS: dict[int, str] = {
class SurfSenseClient:
"""Issues authenticated requests against ``{base_url}{api_prefix}``."""
def __init__(self, *, api_base: str, api_key: str, timeout: float) -> None:
def __init__(
self, *, api_base: str, timeout: float, fallback_api_key: str | None = None
) -> None:
self._api_base = api_base
# Resolved per request, so no key is baked into the shared client. The
# fallback is the env key used under stdio, where there is no header.
self._fallback_api_key = fallback_api_key
self._http = httpx.AsyncClient(
base_url=api_base,
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
},
headers={"Accept": "application/json"},
timeout=timeout,
)
def _auth_headers(self) -> dict[str, str]:
"""Resolve the caller's key: the per-request header, else the env key."""
api_key = current_api_key() or self._fallback_api_key
if not api_key:
raise ToolError(
"No SurfSense API key supplied. Send it as an 'Authorization: "
"Bearer ss_pat_...' header (remote server), or set the "
"SURFSENSE_API_KEY environment variable (stdio)."
)
return {"Authorization": f"Bearer {api_key}"}
async def request(
self,
method: str,
@ -53,9 +67,16 @@ class SurfSenseClient:
# as a value (e.g. int("") on folder_id) and fail.
if params is not None:
params = {key: value for key, value in params.items() if value is not None}
headers = self._auth_headers()
try:
response = await self._http.request(
method, path, params=params, json=json, data=data, files=files
method,
path,
params=params,
json=json,
data=data,
files=files,
headers=headers,
)
except httpx.RequestError as exc:
raise ToolError(

View file

@ -0,0 +1,5 @@
"""Transport wiring for the MCP server (streamable-http app assembly)."""
from .http import build_http_app
__all__ = ["build_http_app"]

View file

@ -0,0 +1,36 @@
"""Wrap the SDK's MCP endpoint with identity + CORS for the remote transport.
CORS is outermost so keyless browser preflight is answered before the identity
middleware. ``/health`` is a public path, exempt from the key check.
"""
from __future__ import annotations
from mcp.server.fastmcp import FastMCP
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.middleware.cors import CORSMiddleware
from starlette.types import ASGIApp
from ..auth.middleware import ApiKeyIdentityMiddleware
HEALTH_PATH = "/health"
async def _health(_request: Request) -> JSONResponse:
return JSONResponse({"status": "ok"})
def build_http_app(mcp: FastMCP) -> ASGIApp:
"""Return the MCP streamable-http app wrapped with identity + CORS."""
mcp.custom_route(HEALTH_PATH, methods=["GET"])(_health)
app: ASGIApp = ApiKeyIdentityMiddleware(
mcp.streamable_http_app(), public_paths={HEALTH_PATH}
)
return CORSMiddleware(
app,
allow_origins=["*"],
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["*"],
expose_headers=["Mcp-Session-Id"],
)

View file

@ -7,13 +7,22 @@ speaks a name, we resolve it, and remember the choice for later calls.
from __future__ import annotations
from collections import OrderedDict
from dataclasses import dataclass
from typing import Annotated
from pydantic import Field
from .auth.identity import current_identity
from .client import SurfSenseClient
from .errors import ToolError
from .workspace_matching import as_int, match_by_name, name_list
# ponytail: one small entry per distinct caller (API token). Bounded so a flood
# of keys can't grow memory without limit; an evicted caller just re-resolves
# its default workspace on the next call. Upgrade path: a TTL/LRU store if
# per-caller state ever grows past this one field.
_MAX_TRACKED_IDENTITIES = 2048
# Shared parameter type for every workspace-scoped tool.
WorkspaceParam = Annotated[
@ -44,15 +53,21 @@ class WorkspaceContext:
) -> None:
self._client = client
self._preferred_reference = preferred_reference
self._active: Workspace | None = None
# Active selection is per caller: one shared slot would leak one user's
# choice to every other user on a shared server.
self._active_by_identity: OrderedDict[str, Workspace] = OrderedDict()
@property
def active(self) -> Workspace | None:
return self._active
return self._active_by_identity.get(current_identity())
def remember(self, workspace: Workspace) -> Workspace:
"""Make ``workspace`` the default for later scoped calls."""
self._active = workspace
"""Make ``workspace`` the default for the current caller's later calls."""
identity = current_identity()
self._active_by_identity[identity] = workspace
self._active_by_identity.move_to_end(identity)
while len(self._active_by_identity) > _MAX_TRACKED_IDENTITIES:
self._active_by_identity.popitem(last=False)
return workspace
async def fetch_all(self) -> list[Workspace]:
@ -67,8 +82,9 @@ class WorkspaceContext:
return self.remember(await self._match(reference))
async def _resolve_default(self) -> Workspace:
if self._active is not None:
return self._active
active = self.active
if active is not None:
return active
if self._preferred_reference:
return self.remember(await self._match(self._preferred_reference))
return self.remember(await self._only_workspace_or_prompt())
@ -84,43 +100,20 @@ class WorkspaceContext:
)
raise ToolError(
"No workspace selected. Choose one first with surfsense_select_workspace, "
f"or pass 'workspace'. Available: {_name_list(workspaces)}."
f"or pass 'workspace'. Available: {name_list(workspaces)}."
)
async def _match(self, reference: str | int) -> Workspace:
workspaces = await self.fetch_all()
as_id = _as_int(reference)
as_id = as_int(reference)
if as_id is not None:
found = next((w for w in workspaces if w.id == as_id), None)
if found is None:
raise ToolError(
f"No workspace with id {as_id}. Available: {_name_list(workspaces)}."
f"No workspace with id {as_id}. Available: {name_list(workspaces)}."
)
return found
return _match_by_name(str(reference), workspaces)
def _match_by_name(reference: str, workspaces: list[Workspace]) -> Workspace:
"""Match on name: exact, then case-insensitive, then unique substring."""
needle = reference.strip()
exact = [w for w in workspaces if w.name == needle]
if exact:
return exact[0]
lowered = needle.casefold()
insensitive = [w for w in workspaces if w.name.casefold() == lowered]
if insensitive:
return insensitive[0]
partial = [w for w in workspaces if lowered in w.name.casefold()]
if len(partial) == 1:
return partial[0]
if len(partial) > 1:
raise ToolError(
f"'{reference}' matches several workspaces: {_name_list(partial)}. "
"Use a more specific name or the id."
)
raise ToolError(
f"No workspace named '{reference}'. Available: {_name_list(workspaces)}."
)
return match_by_name(str(reference), workspaces)
def _to_workspace(row: dict) -> Workspace:
@ -131,14 +124,3 @@ def _to_workspace(row: dict) -> Workspace:
is_owner=row.get("is_owner", False),
member_count=row.get("member_count", 1),
)
def _as_int(reference: str | int) -> int | None:
if isinstance(reference, int):
return reference
text = reference.strip()
return int(text) if text.isdigit() else None
def _name_list(workspaces: list[Workspace]) -> str:
return ", ".join(f"{w.name} (id {w.id})" for w in workspaces)

View file

@ -0,0 +1,51 @@
"""Resolve a user-supplied workspace reference to a single workspace.
Pure matching over an already-fetched list: name (exact, then case-insensitive,
then unique substring) or numeric id. Kept apart from WorkspaceContext so the
resolution rules can be read and tested without the network.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from .errors import ToolError
if TYPE_CHECKING:
from .workspace_context import Workspace
def match_by_name(reference: str, workspaces: list[Workspace]) -> Workspace:
"""Match on name: exact, then case-insensitive, then unique substring."""
needle = reference.strip()
exact = [w for w in workspaces if w.name == needle]
if exact:
return exact[0]
lowered = needle.casefold()
insensitive = [w for w in workspaces if w.name.casefold() == lowered]
if insensitive:
return insensitive[0]
partial = [w for w in workspaces if lowered in w.name.casefold()]
if len(partial) == 1:
return partial[0]
if len(partial) > 1:
raise ToolError(
f"'{reference}' matches several workspaces: {name_list(partial)}. "
"Use a more specific name or the id."
)
raise ToolError(
f"No workspace named '{reference}'. Available: {name_list(workspaces)}."
)
def as_int(reference: str | int) -> int | None:
"""Return the reference as an id, or None when it isn't numeric."""
if isinstance(reference, int):
return reference
text = reference.strip()
return int(text) if text.isdigit() else None
def name_list(workspaces: list[Workspace]) -> str:
"""Render workspaces as a human-readable 'name (id N)' list."""
return ", ".join(f"{w.name} (id {w.id})" for w in workspaces)

View file

@ -0,0 +1,22 @@
"""Knowledge-base tools: search the KB and manage its documents.
Semantic search plus the document lifecycle list, read, add text, upload a
file, update, and delete over a workspace's knowledge base. Read tools live in
search_tools, mutations in document_tools.
"""
from __future__ import annotations
from mcp.server.fastmcp import FastMCP
from ...core.client import SurfSenseClient
from ...core.workspace_context import WorkspaceContext
from . import document_tools, search_tools
def register(
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
) -> None:
"""Register every knowledge-base tool on the server."""
search_tools.register(mcp, client, context)
document_tools.register(mcp, client, context)

View file

@ -0,0 +1,34 @@
"""Tool-call policy hints and shared parameter types for knowledge-base tools."""
from __future__ import annotations
from typing import Annotated
from mcp.types import ToolAnnotations
from pydantic import Field
READ = ToolAnnotations(
readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False
)
WRITE = ToolAnnotations(
readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False
)
DELETE = ToolAnnotations(
readOnlyHint=False, destructiveHint=True, idempotentHint=False, openWorldHint=False
)
DocumentId = Annotated[
int,
Field(
description="Document id from surfsense_search_knowledge_base or "
"surfsense_list_documents results."
),
]
DocumentTypes = Annotated[
list[str] | None,
Field(
description="Restrict to these document types, e.g. "
"['FILE', 'CRAWLED_URL', 'YOUTUBE_VIDEO']. Omit for all types."
),
]

View file

@ -0,0 +1,185 @@
"""Knowledge-base write tools: add a note, upload a file, update, and delete.
Add and upload target the active workspace; update and delete address a document
by its account-unique id, so they need no workspace.
"""
from __future__ import annotations
import mimetypes
from pathlib import Path
from typing import Annotated
from mcp.server.fastmcp import FastMCP
from pydantic import Field
from ...core.client import SurfSenseClient
from ...core.errors import ToolError
from ...core.workspace_context import WorkspaceContext, WorkspaceParam
from .annotations import DELETE, WRITE, DocumentId
from .note_ingestion import build_note_document
def register(
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
) -> None:
"""Register the knowledge-base write and delete tools."""
@mcp.tool(
name="surfsense_add_document",
title="Add a note",
annotations=WRITE,
structured_output=False,
)
async def add_document(
title: Annotated[
str,
Field(min_length=1, description="Short descriptive title for the note."),
],
content: Annotated[
str,
Field(
min_length=1,
description="The note's body; plain text or markdown.",
),
],
source_url: Annotated[
str | None,
Field(description="Where the text came from, if anywhere."),
] = None,
workspace: WorkspaceParam = None,
) -> str:
"""Save a text or markdown note into the workspace's knowledge base.
Use this to store notes, summaries, or findings so they become
searchable later e.g. after finishing a piece of research. For files
on disk use surfsense_upload_file instead. Indexing is asynchronous,
so the note may take a moment to appear in search.
Example: title='NotebookLM subreddits', content='- r/notebooklm ...'.
"""
resolved = await context.resolve(workspace)
await client.request(
"POST",
"/documents",
json=build_note_document(
workspace_id=resolved.id,
title=title,
content=content,
source_url=source_url,
),
)
return (
f"Queued '{title}' for indexing in '{resolved.name}'. "
"It will be searchable once processing completes."
)
@mcp.tool(
name="surfsense_upload_file",
title="Upload a file",
annotations=WRITE,
structured_output=False,
)
async def upload_file(
file_path: Annotated[
str,
Field(
description="Path to a local file, e.g. "
"'C:/Users/me/report.pdf' or '~/notes/summary.md'."
),
],
use_vision_llm: Annotated[
bool,
Field(
description="True reads scanned or image-heavy files with a "
"vision model (slower)."
),
] = False,
workspace: WorkspaceParam = None,
) -> str:
"""Upload a local file (PDF, docx, markdown, etc.) into the knowledge base.
Use this to ingest a file from disk so its content becomes searchable;
for text you already have in hand use surfsense_add_document instead.
The file is parsed, chunked, and indexed asynchronously. Duplicate
files are detected and skipped.
Example: file_path='C:/Users/me/report.pdf'.
"""
resolved = await context.resolve(workspace)
payload = _read_upload(file_path)
result = await client.request(
"POST",
"/documents/fileupload",
data={
"workspace_id": str(resolved.id),
"use_vision_llm": str(use_vision_llm).lower(),
"processing_mode": "basic",
},
files=[("files", payload)],
)
pending = (result or {}).get("pending_files", 0)
skipped = (result or {}).get("skipped_duplicates", 0)
note = " (already present, skipped)" if skipped and not pending else ""
return (
f"Uploaded '{Path(file_path).name}' to '{resolved.name}'{note}. "
"It will be searchable once processing completes."
)
@mcp.tool(
name="surfsense_update_document",
title="Replace a document's content",
annotations=WRITE,
structured_output=False,
)
async def update_document(
document_id: DocumentId,
content: Annotated[
str,
Field(
min_length=1,
description="New full text; replaces the existing content "
"entirely.",
),
],
) -> str:
"""Replace a document's stored content by id.
Use this to correct or rewrite a document's text. The new content
REPLACES the old entirely to append, read the document first with
surfsense_get_document and resend the combined text. Search chunks are
not re-indexed by this call.
"""
existing = await client.request("GET", f"/documents/{document_id}")
await client.request(
"PUT",
f"/documents/{document_id}",
json={
"document_type": existing["document_type"],
"workspace_id": existing["workspace_id"],
"content": content,
},
)
return f"Updated document {document_id} ('{existing.get('title', '')}')."
@mcp.tool(
name="surfsense_delete_document",
title="Delete a document",
annotations=DELETE,
structured_output=False,
)
async def delete_document(document_id: DocumentId) -> str:
"""Permanently delete a document from the knowledge base by id.
Use this only when the user explicitly asks to remove a document
deletion cannot be undone. The document stops appearing in searches
immediately.
"""
await client.request("DELETE", f"/documents/{document_id}")
return f"Deleted document {document_id}."
def _read_upload(file_path: str) -> tuple[str, bytes, str]:
path = Path(file_path).expanduser()
if not path.is_file():
raise ToolError(f"No file at '{file_path}'.")
mime, _ = mimetypes.guess_type(path.name)
return (path.name, path.read_bytes(), mime or "application/octet-stream")

View file

@ -0,0 +1,188 @@
"""Knowledge-base read tools: semantic search, list, and read one document.
Search and list default to the active workspace; a document read is addressed by
id, which is unique across the account, so it needs no workspace.
"""
from __future__ import annotations
from typing import Annotated
from mcp.server.fastmcp import FastMCP
from pydantic import Field
from ...core.client import SurfSenseClient
from ...core.rendering import ResponseFormatParam, clip, to_json
from ...core.workspace_context import WorkspaceContext, WorkspaceParam
from .annotations import READ, DocumentId, DocumentTypes
def register(
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
) -> None:
"""Register the knowledge-base read tools."""
@mcp.tool(
name="surfsense_search_knowledge_base",
title="Search knowledge base",
annotations=READ,
structured_output=False,
)
async def search_knowledge_base(
query: Annotated[
str,
Field(
min_length=1,
description="Natural-language search, e.g. "
"'notebooklm user complaints'.",
),
],
top_k: Annotated[
int, Field(ge=1, le=20, description="Maximum documents to return.")
] = 5,
document_types: DocumentTypes = None,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Search the workspace's knowledge base by meaning and keywords.
Use this FIRST when a question might be answered by content already
stored in SurfSense notes, uploaded files, saved pages, past
research. Do NOT use it to fetch new data from the web; use the
scraper tools for that. Returns the most relevant documents with the
passages that matched, ranked by relevance score.
Example: query='pricing feedback', top_k=5.
"""
resolved = await context.resolve(workspace)
hits = await client.request(
"POST",
"/documents/search-semantic",
json={
"workspace_id": resolved.id,
"query": query,
"top_k": max(1, min(top_k, 20)),
"document_types": document_types,
},
)
items = (hits or {}).get("items", [])
if response_format == "json":
return to_json(items)
return _render_search(query, items)
@mcp.tool(
name="surfsense_list_documents",
title="List documents",
annotations=READ,
structured_output=False,
)
async def list_documents(
document_types: DocumentTypes = None,
folder_id: Annotated[
int | None,
Field(description="Only documents in this folder. Omit for all."),
] = None,
page: Annotated[
int, Field(ge=0, description="Zero-based page number.")
] = 0,
page_size: Annotated[
int, Field(ge=1, description="Documents per page.")
] = 20,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""List documents in the workspace's knowledge base, newest first.
Use this to browse or inventory what is stored; to find documents
about a topic, prefer surfsense_search_knowledge_base. Returns each
document's title, id, type, and update time, plus a has_more flag —
request the next page by increasing page.
Example: document_types=['FILE'], page=0, page_size=20.
"""
resolved = await context.resolve(workspace)
result = await client.request(
"GET",
"/documents",
params={
"workspace_id": resolved.id,
"page": page,
"page_size": page_size,
"document_types": _join(document_types),
"folder_id": folder_id,
},
)
if response_format == "json":
return to_json(result)
return _render_document_list(result)
@mcp.tool(
name="surfsense_get_document",
title="Read one document",
annotations=READ,
structured_output=False,
)
async def get_document(
document_id: DocumentId,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Read one document's full content and metadata by id.
Use this after surfsense_search_knowledge_base or
surfsense_list_documents to open a specific document search results
only include the matching passages, this returns the whole text.
"""
document = await client.request("GET", f"/documents/{document_id}")
if response_format == "json":
return clip(to_json(document))
return _render_document(document)
def _join(values: list[str] | None) -> str | None:
return ",".join(values) if values else None
def _render_search(query: str, items: list[dict]) -> str:
if not items:
return f'No matches for "{query}".'
lines = [f'# {len(items)} result(s) for "{query}"', ""]
for hit in items:
lines.append(
f"## {hit.get('title', 'Untitled')} "
f"(id {hit.get('document_id')}) — score {hit.get('score', 0):.3f}"
)
for chunk in hit.get("chunks", []):
excerpt = clip(chunk.get("content", "").strip(), 500)
lines.append(f"> {excerpt}")
lines.append("")
return "\n".join(lines).strip()
def _render_document_list(result: dict | None) -> str:
items = (result or {}).get("items", [])
if not items:
return "No documents found."
lines = ["# Documents", ""]
for doc in items:
lines.append(
f"- **{doc.get('title', 'Untitled')}** (id {doc.get('id')}) · "
f"{doc.get('document_type')} · updated {doc.get('updated_at')}"
)
total = (result or {}).get("total", len(items))
page = (result or {}).get("page", 0)
has_more = (result or {}).get("has_more", False)
lines.append("")
lines.append(
f"_Page {page} · showing {len(items)} of {total}"
+ (" · more available_" if has_more else "_")
)
return "\n".join(lines)
def _render_document(document: dict) -> str:
content = clip(document.get("content", "") or "(empty)")
return (
f"# {document.get('title', 'Untitled')} (id {document.get('id')})\n"
f"- type: {document.get('document_type')}\n"
f"- workspace: {document.get('workspace_id')}\n"
f"- updated: {document.get('updated_at')}\n\n"
f"{content}"
)

View file

@ -0,0 +1,26 @@
"""Scraper tools: one MCP surface per SurfSense platform capability.
Web crawl, Google Search, Reddit, YouTube, and Google Maps each get a tool that
maps a natural-language request to the workspace's scraper. Two run-history tools
list and fetch past runs, so a large result truncated inline can be retrieved in
full later. Each platform lives in its own module under platforms/.
"""
from __future__ import annotations
from mcp.server.fastmcp import FastMCP
from ...core.client import SurfSenseClient
from ...core.workspace_context import WorkspaceContext
from . import run_history
from .platforms import google_maps, google_search, reddit, web, youtube
_REGISTRARS = (web, google_search, reddit, youtube, google_maps, run_history)
def register(
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
) -> None:
"""Register every scraper and run-history tool on the server."""
for module in _REGISTRARS:
module.register(mcp, client, context)

View file

@ -0,0 +1,13 @@
"""Tool-call policy hints shared across scraper tools."""
from __future__ import annotations
from mcp.types import ToolAnnotations
SCRAPE = ToolAnnotations(
readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True
)
READ_RUNS = ToolAnnotations(
readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False
)

View file

@ -0,0 +1 @@
"""One module per scraper platform; each exposes register(mcp, client, context)."""

View file

@ -0,0 +1,151 @@
"""Google Maps scraper tools: places and reviews."""
from __future__ import annotations
from typing import Annotated, Literal
from mcp.server.fastmcp import FastMCP
from pydantic import Field
from ....core.client import SurfSenseClient
from ....core.rendering import ResponseFormatParam
from ....core.workspace_context import WorkspaceContext, WorkspaceParam
from ..annotations import SCRAPE
from ..capability import run_scraper
ReviewSort = Literal["newest", "mostRelevant", "highestRanking", "lowestRanking"]
def register(
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
) -> None:
"""Register the Google Maps place and review tools."""
@mcp.tool(
name="surfsense_google_maps_scrape",
title="Find places on Google Maps",
annotations=SCRAPE,
structured_output=False,
)
async def google_maps_scrape(
search_queries: Annotated[
list[str] | None,
Field(
description="Place searches, e.g. ['coffee shops']. Provide "
"search_queries OR urls OR place_ids."
),
] = None,
urls: Annotated[
list[str] | None,
Field(description="Google Maps URLs of specific places."),
] = None,
place_ids: Annotated[
list[str] | None,
Field(description="Google place ids, e.g. ['ChIJj61dQgK6j4AR...']."),
] = None,
location: Annotated[
str | None,
Field(
description="Geographic scope for a search, e.g. "
"'Seattle, USA'."
),
] = None,
max_places: Annotated[
int, Field(ge=1, description="Maximum places to return.")
] = 10,
include_details: Annotated[
bool,
Field(
description="True adds opening hours and extra contact info "
"(slower)."
),
] = False,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Find places on Google Maps by search, URL, or place id.
Use this for local-business and location research: names, addresses,
ratings, categories, coordinates, place ids. For a place's customer
reviews use surfsense_google_maps_reviews instead.
Example: search_queries=['ramen'], location='Osaka, Japan', max_places=5.
"""
return await run_scraper(
client,
context,
platform="google_maps",
verb="scrape",
payload={
"search_queries": search_queries,
"urls": urls,
"place_ids": place_ids,
"location": location,
"max_places": max_places,
"include_details": include_details,
},
workspace=workspace,
response_format=response_format,
)
@mcp.tool(
name="surfsense_google_maps_reviews",
title="Fetch Google Maps reviews",
annotations=SCRAPE,
structured_output=False,
)
async def google_maps_reviews(
urls: Annotated[
list[str] | None,
Field(
description="Google Maps URLs of places. Provide urls OR "
"place_ids."
),
] = None,
place_ids: Annotated[
list[str] | None,
Field(
description="Google place ids from surfsense_google_maps_scrape."
),
] = None,
max_reviews: Annotated[
int, Field(ge=1, description="Maximum reviews per place.")
] = 20,
sort_by: Annotated[
ReviewSort, Field(description="Review ordering.")
] = "newest",
language: Annotated[
str, Field(description="Reviews language code, e.g. 'en'.")
] = "en",
start_date: Annotated[
str | None,
Field(
description="ISO date like '2026-01-01'; keeps only reviews on "
"or after that day."
),
] = None,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Fetch customer reviews for Google Maps places by URL or place id.
Use this to read feedback on specific places; get urls or place_ids
from surfsense_google_maps_scrape first if you only have a name.
Returns review text, rating, author, and date per review.
Example: place_ids=['ChIJj61dQgK6j4AR...'], sort_by='newest'.
"""
return await run_scraper(
client,
context,
platform="google_maps",
verb="reviews",
payload={
"urls": urls,
"place_ids": place_ids,
"max_reviews": max_reviews,
"sort_by": sort_by,
"language": language,
"start_date": start_date,
},
workspace=workspace,
response_format=response_format,
)

View file

@ -0,0 +1,79 @@
"""Google Search scraper tool."""
from __future__ import annotations
from typing import Annotated
from mcp.server.fastmcp import FastMCP
from pydantic import Field
from ....core.client import SurfSenseClient
from ....core.rendering import ResponseFormatParam
from ....core.workspace_context import WorkspaceContext, WorkspaceParam
from ..annotations import SCRAPE
from ..capability import run_scraper
def register(
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
) -> None:
"""Register the Google Search tool."""
@mcp.tool(
name="surfsense_google_search",
title="Scrape Google Search",
annotations=SCRAPE,
structured_output=False,
)
async def google_search(
queries: Annotated[
list[str],
Field(
min_length=1,
description="Search terms or full Google Search URLs, e.g. "
"['best rss readers 2026'].",
),
],
max_pages_per_query: Annotated[
int, Field(ge=1, description="Result pages to fetch per query.")
] = 1,
country_code: Annotated[
str | None,
Field(description="Two-letter country to search from, e.g. 'us'."),
] = None,
language_code: Annotated[
str, Field(description="Results language, e.g. 'en'. Empty for default.")
] = "",
site: Annotated[
str | None,
Field(
description="Restrict results to one domain, e.g. 'example.com'."
),
] = None,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Scrape Google Search result pages for one or more queries.
Use this to discover pages on the open web by topic; follow up with
surfsense_web_crawl to read a result in full. Do NOT use it for
Reddit, YouTube, or Google Maps research the dedicated tools return
richer data. Returns each query's parsed results: title, url, and
snippet per organic result.
Example: queries=['notebooklm review'], site='news.ycombinator.com'.
"""
return await run_scraper(
client,
context,
platform="google_search",
verb="scrape",
payload={
"queries": queries,
"max_pages_per_query": max_pages_per_query,
"country_code": country_code,
"language_code": language_code,
"site": site,
},
workspace=workspace,
response_format=response_format,
)

View file

@ -0,0 +1,98 @@
"""Reddit scraper tool."""
from __future__ import annotations
from typing import Annotated, Literal
from mcp.server.fastmcp import FastMCP
from pydantic import Field
from ....core.client import SurfSenseClient
from ....core.rendering import ResponseFormatParam
from ....core.workspace_context import WorkspaceContext, WorkspaceParam
from ..annotations import SCRAPE
from ..capability import run_scraper
RedditSort = Literal["relevance", "hot", "top", "new", "rising", "comments"]
RedditTime = Literal["hour", "day", "week", "month", "year", "all"]
def register(
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
) -> None:
"""Register the Reddit tool."""
@mcp.tool(
name="surfsense_reddit_scrape",
title="Search or scrape Reddit",
annotations=SCRAPE,
structured_output=False,
)
async def reddit_scrape(
urls: Annotated[
list[str] | None,
Field(
description="Reddit URLs: a post, a subreddit like "
"'https://reddit.com/r/LocalLLaMA', a user page, or a search "
"URL. Provide urls OR search_queries."
),
] = None,
search_queries: Annotated[
list[str] | None,
Field(
description="Terms to search Reddit for, e.g. "
"['NotebookLM alternatives']. Provide search_queries OR urls."
),
] = None,
community: Annotated[
str | None,
Field(
description="Restrict a search to one subreddit, name without "
"'r/', e.g. 'ArtificialInteligence'."
),
] = None,
sort: Annotated[RedditSort, Field(description="Post ordering.")] = "new",
time_filter: Annotated[
RedditTime | None,
Field(description="Time window; only valid with sort='top'."),
] = None,
max_items: Annotated[
int, Field(ge=1, description="Maximum posts to return.")
] = 10,
skip_comments: Annotated[
bool,
Field(
description="True fetches posts only (faster); False also "
"fetches each post's comment thread."
),
] = False,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Search or scrape Reddit: posts, comments, subreddits, and users.
Use this for ANY Reddit research finding relevant subreddits or
communities for a topic, top posts, or discussions instead of a
generic web search. Returns posts (title, text, score, subreddit, url)
with comment threads unless skip_comments is set. Every post carries
its subreddit, so to find communities for a topic, search posts and
aggregate their subreddits.
Example: search_queries=['NotebookLM'], sort='top', time_filter='month'.
"""
return await run_scraper(
client,
context,
platform="reddit",
verb="scrape",
payload={
"urls": urls,
"search_queries": search_queries,
"community": community,
"sort": sort,
"time_filter": time_filter,
"max_items": max_items,
"skip_comments": skip_comments,
},
workspace=workspace,
response_format=response_format,
)

View file

@ -0,0 +1,89 @@
"""Web crawl scraper tool."""
from __future__ import annotations
from typing import Annotated
from mcp.server.fastmcp import FastMCP
from pydantic import Field
from ....core.client import SurfSenseClient
from ....core.rendering import ResponseFormatParam
from ....core.workspace_context import WorkspaceContext, WorkspaceParam
from ..annotations import SCRAPE
from ..capability import run_scraper
def register(
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
) -> None:
"""Register the web crawl tool."""
@mcp.tool(
name="surfsense_web_crawl",
title="Crawl web pages",
annotations=SCRAPE,
structured_output=False,
)
async def web_crawl(
start_urls: Annotated[
list[str],
Field(
min_length=1,
description="Full URLs to fetch, e.g. "
"['https://example.com/blog/post'].",
),
],
max_crawl_depth: Annotated[
int,
Field(
ge=0,
description="Link-hops to follow from start_urls within the "
"same site. 0 fetches only start_urls.",
),
] = 0,
max_crawl_pages: Annotated[
int, Field(ge=1, description="Stop after this many pages in total.")
] = 10,
max_length: Annotated[
int, Field(ge=1, description="Max characters kept per page.")
] = 50_000,
include_url_patterns: Annotated[
list[str] | None,
Field(
description="Regexes; only discovered links matching one are "
"followed, e.g. ['/docs/.*']."
),
] = None,
exclude_url_patterns: Annotated[
list[str] | None,
Field(description="Regexes; discovered links matching one are skipped."),
] = None,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Fetch specific web pages and return their cleaned content as markdown.
Use this to read a page the user names, or to spider a site from a
starting URL. Do NOT use it to find pages on a topic use
surfsense_google_search for discovery. Returns one item per crawled
page: url, title, and the page text as markdown.
Example: start_urls=['https://blog.example.com'], max_crawl_depth=1,
include_url_patterns=['/2026/'].
"""
return await run_scraper(
client,
context,
platform="web",
verb="crawl",
payload={
"startUrls": start_urls,
"maxCrawlDepth": max_crawl_depth,
"maxCrawlPages": max_crawl_pages,
"maxLength": max_length,
"includeUrlPatterns": include_url_patterns,
"excludeUrlPatterns": exclude_url_patterns,
},
workspace=workspace,
response_format=response_format,
)

View file

@ -0,0 +1,131 @@
"""YouTube scraper tools: videos and comments."""
from __future__ import annotations
from typing import Annotated, Literal
from mcp.server.fastmcp import FastMCP
from pydantic import Field
from ....core.client import SurfSenseClient
from ....core.rendering import ResponseFormatParam
from ....core.workspace_context import WorkspaceContext, WorkspaceParam
from ..annotations import SCRAPE
from ..capability import run_scraper
CommentSort = Literal["TOP_COMMENTS", "NEWEST_FIRST"]
def register(
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
) -> None:
"""Register the YouTube video and comment tools."""
@mcp.tool(
name="surfsense_youtube_scrape",
title="Search or scrape YouTube",
annotations=SCRAPE,
structured_output=False,
)
async def youtube_scrape(
urls: Annotated[
list[str] | None,
Field(
description="YouTube URLs: video, channel, playlist, shorts, "
"or hashtag pages. Provide urls OR search_queries."
),
] = None,
search_queries: Annotated[
list[str] | None,
Field(
description="Terms to search YouTube for, e.g. "
"['NotebookLM tutorial']. Provide search_queries OR urls."
),
] = None,
max_results: Annotated[
int, Field(ge=1, description="Maximum videos to return.")
] = 10,
download_subtitles: Annotated[
bool,
Field(description="True also fetches each video's transcript."),
] = False,
subtitles_language: Annotated[
str, Field(description="Transcript language code, e.g. 'en'.")
] = "en",
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Search or scrape YouTube videos, optionally with transcripts.
Use this for YouTube research: finding videos on a topic, or reading a
video's details or transcript. For a video's comment section use
surfsense_youtube_comments instead. Returns per-video metadata (title,
channel, views, description, url) and, if requested, the transcript.
Example: search_queries=['NotebookLM tutorial'], download_subtitles=True.
"""
return await run_scraper(
client,
context,
platform="youtube",
verb="scrape",
payload={
"urls": urls,
"search_queries": search_queries,
"max_results": max_results,
"download_subtitles": download_subtitles,
"subtitles_language": subtitles_language,
},
workspace=workspace,
response_format=response_format,
)
@mcp.tool(
name="surfsense_youtube_comments",
title="Fetch YouTube comments",
annotations=SCRAPE,
structured_output=False,
)
async def youtube_comments(
urls: Annotated[
list[str],
Field(
min_length=1,
description="YouTube video URLs, e.g. "
"['https://www.youtube.com/watch?v=abc123'].",
),
],
max_comments: Annotated[
int,
Field(
ge=1,
description="Maximum comments per video, counting top-level "
"comments and replies together.",
),
] = 20,
sort_by: Annotated[
CommentSort, Field(description="Comment ordering.")
] = "NEWEST_FIRST",
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Fetch the comments (and replies) on one or more YouTube videos.
Use this when the user wants a video's discussion or audience reaction
rather than the video itself; get video URLs from
surfsense_youtube_scrape if you only have a topic. Returns comment
text, author, likes, and replies.
Example: urls=['https://www.youtube.com/watch?v=abc123'], max_comments=50.
"""
return await run_scraper(
client,
context,
platform="youtube",
verb="comments",
payload={
"urls": urls,
"max_comments": max_comments,
"sort_by": sort_by,
},
workspace=workspace,
response_format=response_format,
)

View file

@ -0,0 +1,112 @@
"""Scraper run history: list past runs and fetch one in full.
A scrape whose inline result was truncated is retrievable here by run id, so the
model never re-runs a scraper just to recover output.
"""
from __future__ import annotations
from typing import Annotated
from mcp.server.fastmcp import FastMCP
from pydantic import Field
from ...core.client import SurfSenseClient
from ...core.rendering import ResponseFormatParam, clip, to_json
from ...core.workspace_context import WorkspaceContext, WorkspaceParam
from .annotations import READ_RUNS
def register(
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
) -> None:
"""Register the run-history tools."""
@mcp.tool(
name="surfsense_list_scraper_runs",
title="List past scraper runs",
annotations=READ_RUNS,
structured_output=False,
)
async def list_scraper_runs(
limit: Annotated[
int, Field(ge=1, description="Maximum runs to list.")
] = 20,
capability: Annotated[
str | None,
Field(
description="Filter by capability slug, e.g. 'web.crawl' or "
"'reddit.scrape'."
),
] = None,
status: Annotated[
str | None,
Field(description="Filter by run status: 'success' or 'error'."),
] = None,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""List recent scraper runs in the workspace, newest first.
Use this to find the run_id of an earlier scrape for example when an
inline result was truncated then fetch it in full with
surfsense_get_scraper_run. Returns each run's id, capability, status,
item count, and creation time.
Example: capability='reddit.scrape', status='success'.
"""
resolved = await context.resolve(workspace)
runs = await client.request(
"GET",
f"/workspaces/{resolved.id}/scrapers/runs",
params={
"limit": limit,
"capability": capability,
"status": status,
},
)
if response_format == "json":
return to_json(runs)
return _render_runs(runs)
@mcp.tool(
name="surfsense_get_scraper_run",
title="Fetch one scraper run in full",
annotations=READ_RUNS,
structured_output=False,
)
async def get_scraper_run(
run_id: Annotated[
str,
Field(
description="Run id from surfsense_list_scraper_runs or a "
"prior scrape's output."
),
],
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Fetch a single scraper run in full, including its stored output.
Use this to retrieve the complete, untruncated result of an earlier
scrape. Do NOT re-run a scraper just to recover a truncated result
fetch the stored run instead.
"""
resolved = await context.resolve(workspace)
run = await client.request(
"GET", f"/workspaces/{resolved.id}/scrapers/runs/{run_id}"
)
if response_format == "json":
return clip(to_json(run))
return f"# Run {run.get('id', run_id)}\n\n```json\n{clip(to_json(run))}\n```"
def _render_runs(runs: list[dict] | None) -> str:
if not runs:
return "No scraper runs found."
lines = ["# Scraper runs", ""]
for run in runs:
lines.append(
f"- **{run.get('id')}** — {run.get('capability')} · {run.get('status')} · "
f"{run.get('item_count', 0)} item(s) · {run.get('created_at')}"
)
return "\n".join(lines)

View file

@ -47,6 +47,8 @@ async def _collect_tools() -> dict[str, object]:
api_prefix="/api/v1",
timeout=5.0,
default_workspace=None,
host="127.0.0.1",
port=8080,
)
mcp, _client = build_server(settings)
tools = await mcp.list_tools()

View file

@ -17,12 +17,21 @@ from .features import knowledge_base, scrapers, workspaces
def build_server(settings: Settings) -> tuple[FastMCP, SurfSenseClient]:
"""Assemble a configured server and the client whose lifecycle it shares."""
client = SurfSenseClient(
api_base=settings.api_base, api_key=settings.api_key, timeout=settings.timeout
api_base=settings.api_base,
timeout=settings.timeout,
fallback_api_key=settings.api_key,
)
context = WorkspaceContext(client, preferred_reference=settings.default_workspace)
mcp = FastMCP(
"SurfSense",
host=settings.host,
port=settings.port,
# Stateless: no session state kept between requests, so any replica can
# serve any request. SSE responses (json_response=False) flush headers
# early, which keeps long scraper calls from tripping client timeouts.
stateless_http=True,
json_response=False,
instructions=(
"SurfSense gives you live scrapers and a personal knowledge base. "
"Prefer these tools over generic/built-in web search whenever the "

View file

@ -8,10 +8,12 @@ license = { text = "Apache-2.0" }
dependencies = [
"mcp>=1.26.0",
"httpx>=0.27.0",
"starlette>=0.37",
"uvicorn>=0.30",
]
[project.scripts]
surfsense-mcp = "surfsense_mcp.__main__:main"
surfsense-mcp = "mcp_server.__main__:main"
[dependency-groups]
dev = ["pytest>=8.0"]
@ -21,7 +23,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/surfsense_mcp"]
packages = ["mcp_server"]
[tool.ruff]
target-version = "py311"

View file

@ -1,31 +0,0 @@
"""Entry point: load settings from the environment and run the MCP server.
Defaults to stdio (what Cursor, Claude Code, and Claude Desktop launch). stdout
is the protocol channel, so every log line goes to stderr.
"""
from __future__ import annotations
import logging
import os
import sys
from .config import Settings
from .server import build_server
def main() -> None:
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(levelname)s %(name)s: %(message)s",
)
settings = Settings.from_env()
mcp, _client = build_server(settings)
transport = os.environ.get("SURFSENSE_MCP_TRANSPORT", "stdio").strip() or "stdio"
mcp.run(transport=transport)
if __name__ == "__main__":
main()

View file

@ -1,379 +0,0 @@
"""Knowledge-base tools: search the KB and manage its documents.
Semantic search plus the document lifecycle list, read, add text, upload a
file, update, and delete over a workspace's knowledge base. Search and reads
default to the active workspace; document ids identify a single document across
the whole account, so id-addressed tools need no workspace.
"""
from __future__ import annotations
import mimetypes
from pathlib import Path
from typing import Annotated
from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations
from pydantic import Field
from ...core.client import SurfSenseClient
from ...core.errors import ToolError
from ...core.rendering import ResponseFormatParam, clip, to_json
from ...core.workspace_context import WorkspaceContext, WorkspaceParam
from .note_ingestion import build_note_document
_READ = ToolAnnotations(
readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False
)
_WRITE = ToolAnnotations(
readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False
)
_DELETE = ToolAnnotations(
readOnlyHint=False, destructiveHint=True, idempotentHint=False, openWorldHint=False
)
_DOCUMENT_ID = Annotated[
int,
Field(
description="Document id from surfsense_search_knowledge_base or "
"surfsense_list_documents results."
),
]
_DOCUMENT_TYPES = Annotated[
list[str] | None,
Field(
description="Restrict to these document types, e.g. "
"['FILE', 'CRAWLED_URL', 'YOUTUBE_VIDEO']. Omit for all types."
),
]
def register(
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
) -> None:
"""Register the knowledge-base tools on the server."""
@mcp.tool(
name="surfsense_search_knowledge_base",
title="Search knowledge base",
annotations=_READ,
structured_output=False,
)
async def search_knowledge_base(
query: Annotated[
str,
Field(
min_length=1,
description="Natural-language search, e.g. "
"'notebooklm user complaints'.",
),
],
top_k: Annotated[
int, Field(ge=1, le=20, description="Maximum documents to return.")
] = 5,
document_types: _DOCUMENT_TYPES = None,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Search the workspace's knowledge base by meaning and keywords.
Use this FIRST when a question might be answered by content already
stored in SurfSense notes, uploaded files, saved pages, past
research. Do NOT use it to fetch new data from the web; use the
scraper tools for that. Returns the most relevant documents with the
passages that matched, ranked by relevance score.
Example: query='pricing feedback', top_k=5.
"""
resolved = await context.resolve(workspace)
hits = await client.request(
"POST",
"/documents/search-semantic",
json={
"workspace_id": resolved.id,
"query": query,
"top_k": max(1, min(top_k, 20)),
"document_types": document_types,
},
)
items = (hits or {}).get("items", [])
if response_format == "json":
return to_json(items)
return _render_search(query, items)
@mcp.tool(
name="surfsense_list_documents",
title="List documents",
annotations=_READ,
structured_output=False,
)
async def list_documents(
document_types: _DOCUMENT_TYPES = None,
folder_id: Annotated[
int | None,
Field(description="Only documents in this folder. Omit for all."),
] = None,
page: Annotated[
int, Field(ge=0, description="Zero-based page number.")
] = 0,
page_size: Annotated[
int, Field(ge=1, description="Documents per page.")
] = 20,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""List documents in the workspace's knowledge base, newest first.
Use this to browse or inventory what is stored; to find documents
about a topic, prefer surfsense_search_knowledge_base. Returns each
document's title, id, type, and update time, plus a has_more flag —
request the next page by increasing page.
Example: document_types=['FILE'], page=0, page_size=20.
"""
resolved = await context.resolve(workspace)
result = await client.request(
"GET",
"/documents",
params={
"workspace_id": resolved.id,
"page": page,
"page_size": page_size,
"document_types": _join(document_types),
"folder_id": folder_id,
},
)
if response_format == "json":
return to_json(result)
return _render_document_list(result)
@mcp.tool(
name="surfsense_get_document",
title="Read one document",
annotations=_READ,
structured_output=False,
)
async def get_document(
document_id: _DOCUMENT_ID,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Read one document's full content and metadata by id.
Use this after surfsense_search_knowledge_base or
surfsense_list_documents to open a specific document search results
only include the matching passages, this returns the whole text.
"""
document = await client.request("GET", f"/documents/{document_id}")
if response_format == "json":
return clip(to_json(document))
return _render_document(document)
@mcp.tool(
name="surfsense_add_document",
title="Add a note",
annotations=_WRITE,
structured_output=False,
)
async def add_document(
title: Annotated[
str,
Field(min_length=1, description="Short descriptive title for the note."),
],
content: Annotated[
str,
Field(
min_length=1,
description="The note's body; plain text or markdown.",
),
],
source_url: Annotated[
str | None,
Field(description="Where the text came from, if anywhere."),
] = None,
workspace: WorkspaceParam = None,
) -> str:
"""Save a text or markdown note into the workspace's knowledge base.
Use this to store notes, summaries, or findings so they become
searchable later e.g. after finishing a piece of research. For files
on disk use surfsense_upload_file instead. Indexing is asynchronous,
so the note may take a moment to appear in search.
Example: title='NotebookLM subreddits', content='- r/notebooklm ...'.
"""
resolved = await context.resolve(workspace)
await client.request(
"POST",
"/documents",
json=build_note_document(
workspace_id=resolved.id,
title=title,
content=content,
source_url=source_url,
),
)
return (
f"Queued '{title}' for indexing in '{resolved.name}'. "
"It will be searchable once processing completes."
)
@mcp.tool(
name="surfsense_upload_file",
title="Upload a file",
annotations=_WRITE,
structured_output=False,
)
async def upload_file(
file_path: Annotated[
str,
Field(
description="Path to a local file, e.g. "
"'C:/Users/me/report.pdf' or '~/notes/summary.md'."
),
],
use_vision_llm: Annotated[
bool,
Field(
description="True reads scanned or image-heavy files with a "
"vision model (slower)."
),
] = False,
workspace: WorkspaceParam = None,
) -> str:
"""Upload a local file (PDF, docx, markdown, etc.) into the knowledge base.
Use this to ingest a file from disk so its content becomes searchable;
for text you already have in hand use surfsense_add_document instead.
The file is parsed, chunked, and indexed asynchronously. Duplicate
files are detected and skipped.
Example: file_path='C:/Users/me/report.pdf'.
"""
resolved = await context.resolve(workspace)
payload = _read_upload(file_path)
result = await client.request(
"POST",
"/documents/fileupload",
data={
"workspace_id": str(resolved.id),
"use_vision_llm": str(use_vision_llm).lower(),
"processing_mode": "basic",
},
files=[("files", payload)],
)
pending = (result or {}).get("pending_files", 0)
skipped = (result or {}).get("skipped_duplicates", 0)
note = " (already present, skipped)" if skipped and not pending else ""
return (
f"Uploaded '{Path(file_path).name}' to '{resolved.name}'{note}. "
"It will be searchable once processing completes."
)
@mcp.tool(
name="surfsense_update_document",
title="Replace a document's content",
annotations=_WRITE,
structured_output=False,
)
async def update_document(
document_id: _DOCUMENT_ID,
content: Annotated[
str,
Field(
min_length=1,
description="New full text; replaces the existing content "
"entirely.",
),
],
) -> str:
"""Replace a document's stored content by id.
Use this to correct or rewrite a document's text. The new content
REPLACES the old entirely to append, read the document first with
surfsense_get_document and resend the combined text. Search chunks are
not re-indexed by this call.
"""
existing = await client.request("GET", f"/documents/{document_id}")
await client.request(
"PUT",
f"/documents/{document_id}",
json={
"document_type": existing["document_type"],
"workspace_id": existing["workspace_id"],
"content": content,
},
)
return f"Updated document {document_id} ('{existing.get('title', '')}')."
@mcp.tool(
name="surfsense_delete_document",
title="Delete a document",
annotations=_DELETE,
structured_output=False,
)
async def delete_document(document_id: _DOCUMENT_ID) -> str:
"""Permanently delete a document from the knowledge base by id.
Use this only when the user explicitly asks to remove a document
deletion cannot be undone. The document stops appearing in searches
immediately.
"""
await client.request("DELETE", f"/documents/{document_id}")
return f"Deleted document {document_id}."
def _read_upload(file_path: str) -> tuple[str, bytes, str]:
path = Path(file_path).expanduser()
if not path.is_file():
raise ToolError(f"No file at '{file_path}'.")
mime, _ = mimetypes.guess_type(path.name)
return (path.name, path.read_bytes(), mime or "application/octet-stream")
def _join(values: list[str] | None) -> str | None:
return ",".join(values) if values else None
def _render_search(query: str, items: list[dict]) -> str:
if not items:
return f'No matches for "{query}".'
lines = [f'# {len(items)} result(s) for "{query}"', ""]
for hit in items:
lines.append(
f"## {hit.get('title', 'Untitled')} "
f"(id {hit.get('document_id')}) — score {hit.get('score', 0):.3f}"
)
for chunk in hit.get("chunks", []):
excerpt = clip(chunk.get("content", "").strip(), 500)
lines.append(f"> {excerpt}")
lines.append("")
return "\n".join(lines).strip()
def _render_document_list(result: dict | None) -> str:
items = (result or {}).get("items", [])
if not items:
return "No documents found."
lines = ["# Documents", ""]
for doc in items:
lines.append(
f"- **{doc.get('title', 'Untitled')}** (id {doc.get('id')}) · "
f"{doc.get('document_type')} · updated {doc.get('updated_at')}"
)
total = (result or {}).get("total", len(items))
page = (result or {}).get("page", 0)
has_more = (result or {}).get("has_more", False)
lines.append("")
lines.append(
f"_Page {page} · showing {len(items)} of {total}"
+ (" · more available_" if has_more else "_")
)
return "\n".join(lines)
def _render_document(document: dict) -> str:
content = clip(document.get("content", "") or "(empty)")
return (
f"# {document.get('title', 'Untitled')} (id {document.get('id')})\n"
f"- type: {document.get('document_type')}\n"
f"- workspace: {document.get('workspace_id')}\n"
f"- updated: {document.get('updated_at')}\n\n"
f"{content}"
)

View file

@ -1,570 +0,0 @@
"""Scraper tools: one MCP surface per SurfSense platform capability.
Web crawl, Google Search, Reddit, YouTube, and Google Maps each get a tool that
maps a natural-language request to the workspace's scraper door. Two more tools
list and fetch past runs, so a large result truncated inline can be retrieved in
full later.
"""
from __future__ import annotations
from typing import Annotated, Literal
from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations
from pydantic import Field
from ...core.client import SurfSenseClient
from ...core.rendering import ResponseFormatParam, clip, to_json
from ...core.workspace_context import WorkspaceContext, WorkspaceParam
from .capability import run_scraper
# Scrapers reach the open web and record a billable run; they are neither
# read-only nor idempotent, but they do not mutate the knowledge base.
_SCRAPE = ToolAnnotations(
readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True
)
_READ_RUNS = ToolAnnotations(
readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False
)
RedditSort = Literal["relevance", "hot", "top", "new", "rising", "comments"]
RedditTime = Literal["hour", "day", "week", "month", "year", "all"]
CommentSort = Literal["TOP_COMMENTS", "NEWEST_FIRST"]
ReviewSort = Literal["newest", "mostRelevant", "highestRanking", "lowestRanking"]
def register(
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
) -> None:
"""Register the scraper and run-history tools on the server."""
@mcp.tool(
name="surfsense_web_crawl",
title="Crawl web pages",
annotations=_SCRAPE,
structured_output=False,
)
async def web_crawl(
start_urls: Annotated[
list[str],
Field(
min_length=1,
description="Full URLs to fetch, e.g. "
"['https://example.com/blog/post'].",
),
],
max_crawl_depth: Annotated[
int,
Field(
ge=0,
description="Link-hops to follow from start_urls within the "
"same site. 0 fetches only start_urls.",
),
] = 0,
max_crawl_pages: Annotated[
int, Field(ge=1, description="Stop after this many pages in total.")
] = 10,
max_length: Annotated[
int, Field(ge=1, description="Max characters kept per page.")
] = 50_000,
include_url_patterns: Annotated[
list[str] | None,
Field(
description="Regexes; only discovered links matching one are "
"followed, e.g. ['/docs/.*']."
),
] = None,
exclude_url_patterns: Annotated[
list[str] | None,
Field(description="Regexes; discovered links matching one are skipped."),
] = None,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Fetch specific web pages and return their cleaned content as markdown.
Use this to read a page the user names, or to spider a site from a
starting URL. Do NOT use it to find pages on a topic use
surfsense_google_search for discovery. Returns one item per crawled
page: url, title, and the page text as markdown.
Example: start_urls=['https://blog.example.com'], max_crawl_depth=1,
include_url_patterns=['/2026/'].
"""
return await run_scraper(
client,
context,
platform="web",
verb="crawl",
payload={
"startUrls": start_urls,
"maxCrawlDepth": max_crawl_depth,
"maxCrawlPages": max_crawl_pages,
"maxLength": max_length,
"includeUrlPatterns": include_url_patterns,
"excludeUrlPatterns": exclude_url_patterns,
},
workspace=workspace,
response_format=response_format,
)
@mcp.tool(
name="surfsense_google_search",
title="Scrape Google Search",
annotations=_SCRAPE,
structured_output=False,
)
async def google_search(
queries: Annotated[
list[str],
Field(
min_length=1,
description="Search terms or full Google Search URLs, e.g. "
"['best rss readers 2026'].",
),
],
max_pages_per_query: Annotated[
int, Field(ge=1, description="Result pages to fetch per query.")
] = 1,
country_code: Annotated[
str | None,
Field(description="Two-letter country to search from, e.g. 'us'."),
] = None,
language_code: Annotated[
str, Field(description="Results language, e.g. 'en'. Empty for default.")
] = "",
site: Annotated[
str | None,
Field(
description="Restrict results to one domain, e.g. 'example.com'."
),
] = None,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Scrape Google Search result pages for one or more queries.
Use this to discover pages on the open web by topic; follow up with
surfsense_web_crawl to read a result in full. Do NOT use it for
Reddit, YouTube, or Google Maps research the dedicated tools return
richer data. Returns each query's parsed results: title, url, and
snippet per organic result.
Example: queries=['notebooklm review'], site='news.ycombinator.com'.
"""
return await run_scraper(
client,
context,
platform="google_search",
verb="scrape",
payload={
"queries": queries,
"max_pages_per_query": max_pages_per_query,
"country_code": country_code,
"language_code": language_code,
"site": site,
},
workspace=workspace,
response_format=response_format,
)
@mcp.tool(
name="surfsense_reddit_scrape",
title="Search or scrape Reddit",
annotations=_SCRAPE,
structured_output=False,
)
async def reddit_scrape(
urls: Annotated[
list[str] | None,
Field(
description="Reddit URLs: a post, a subreddit like "
"'https://reddit.com/r/LocalLLaMA', a user page, or a search "
"URL. Provide urls OR search_queries."
),
] = None,
search_queries: Annotated[
list[str] | None,
Field(
description="Terms to search Reddit for, e.g. "
"['NotebookLM alternatives']. Provide search_queries OR urls."
),
] = None,
community: Annotated[
str | None,
Field(
description="Restrict a search to one subreddit, name without "
"'r/', e.g. 'ArtificialInteligence'."
),
] = None,
sort: Annotated[RedditSort, Field(description="Post ordering.")] = "new",
time_filter: Annotated[
RedditTime | None,
Field(description="Time window; only valid with sort='top'."),
] = None,
max_items: Annotated[
int, Field(ge=1, description="Maximum posts to return.")
] = 10,
skip_comments: Annotated[
bool,
Field(
description="True fetches posts only (faster); False also "
"fetches each post's comment thread."
),
] = False,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Search or scrape Reddit: posts, comments, subreddits, and users.
Use this for ANY Reddit research finding relevant subreddits or
communities for a topic, top posts, or discussions instead of a
generic web search. Returns posts (title, text, score, subreddit, url)
with comment threads unless skip_comments is set. Every post carries
its subreddit, so to find communities for a topic, search posts and
aggregate their subreddits.
Example: search_queries=['NotebookLM'], sort='top', time_filter='month'.
"""
return await run_scraper(
client,
context,
platform="reddit",
verb="scrape",
payload={
"urls": urls,
"search_queries": search_queries,
"community": community,
"sort": sort,
"time_filter": time_filter,
"max_items": max_items,
"skip_comments": skip_comments,
},
workspace=workspace,
response_format=response_format,
)
@mcp.tool(
name="surfsense_youtube_scrape",
title="Search or scrape YouTube",
annotations=_SCRAPE,
structured_output=False,
)
async def youtube_scrape(
urls: Annotated[
list[str] | None,
Field(
description="YouTube URLs: video, channel, playlist, shorts, "
"or hashtag pages. Provide urls OR search_queries."
),
] = None,
search_queries: Annotated[
list[str] | None,
Field(
description="Terms to search YouTube for, e.g. "
"['NotebookLM tutorial']. Provide search_queries OR urls."
),
] = None,
max_results: Annotated[
int, Field(ge=1, description="Maximum videos to return.")
] = 10,
download_subtitles: Annotated[
bool,
Field(description="True also fetches each video's transcript."),
] = False,
subtitles_language: Annotated[
str, Field(description="Transcript language code, e.g. 'en'.")
] = "en",
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Search or scrape YouTube videos, optionally with transcripts.
Use this for YouTube research: finding videos on a topic, or reading a
video's details or transcript. For a video's comment section use
surfsense_youtube_comments instead. Returns per-video metadata (title,
channel, views, description, url) and, if requested, the transcript.
Example: search_queries=['NotebookLM tutorial'], download_subtitles=True.
"""
return await run_scraper(
client,
context,
platform="youtube",
verb="scrape",
payload={
"urls": urls,
"search_queries": search_queries,
"max_results": max_results,
"download_subtitles": download_subtitles,
"subtitles_language": subtitles_language,
},
workspace=workspace,
response_format=response_format,
)
@mcp.tool(
name="surfsense_youtube_comments",
title="Fetch YouTube comments",
annotations=_SCRAPE,
structured_output=False,
)
async def youtube_comments(
urls: Annotated[
list[str],
Field(
min_length=1,
description="YouTube video URLs, e.g. "
"['https://www.youtube.com/watch?v=abc123'].",
),
],
max_comments: Annotated[
int,
Field(
ge=1,
description="Maximum comments per video, counting top-level "
"comments and replies together.",
),
] = 20,
sort_by: Annotated[
CommentSort, Field(description="Comment ordering.")
] = "NEWEST_FIRST",
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Fetch the comments (and replies) on one or more YouTube videos.
Use this when the user wants a video's discussion or audience reaction
rather than the video itself; get video URLs from
surfsense_youtube_scrape if you only have a topic. Returns comment
text, author, likes, and replies.
Example: urls=['https://www.youtube.com/watch?v=abc123'], max_comments=50.
"""
return await run_scraper(
client,
context,
platform="youtube",
verb="comments",
payload={
"urls": urls,
"max_comments": max_comments,
"sort_by": sort_by,
},
workspace=workspace,
response_format=response_format,
)
@mcp.tool(
name="surfsense_google_maps_scrape",
title="Find places on Google Maps",
annotations=_SCRAPE,
structured_output=False,
)
async def google_maps_scrape(
search_queries: Annotated[
list[str] | None,
Field(
description="Place searches, e.g. ['coffee shops']. Provide "
"search_queries OR urls OR place_ids."
),
] = None,
urls: Annotated[
list[str] | None,
Field(description="Google Maps URLs of specific places."),
] = None,
place_ids: Annotated[
list[str] | None,
Field(description="Google place ids, e.g. ['ChIJj61dQgK6j4AR...']."),
] = None,
location: Annotated[
str | None,
Field(
description="Geographic scope for a search, e.g. "
"'Seattle, USA'."
),
] = None,
max_places: Annotated[
int, Field(ge=1, description="Maximum places to return.")
] = 10,
include_details: Annotated[
bool,
Field(
description="True adds opening hours and extra contact info "
"(slower)."
),
] = False,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Find places on Google Maps by search, URL, or place id.
Use this for local-business and location research: names, addresses,
ratings, categories, coordinates, place ids. For a place's customer
reviews use surfsense_google_maps_reviews instead.
Example: search_queries=['ramen'], location='Osaka, Japan', max_places=5.
"""
return await run_scraper(
client,
context,
platform="google_maps",
verb="scrape",
payload={
"search_queries": search_queries,
"urls": urls,
"place_ids": place_ids,
"location": location,
"max_places": max_places,
"include_details": include_details,
},
workspace=workspace,
response_format=response_format,
)
@mcp.tool(
name="surfsense_google_maps_reviews",
title="Fetch Google Maps reviews",
annotations=_SCRAPE,
structured_output=False,
)
async def google_maps_reviews(
urls: Annotated[
list[str] | None,
Field(
description="Google Maps URLs of places. Provide urls OR "
"place_ids."
),
] = None,
place_ids: Annotated[
list[str] | None,
Field(
description="Google place ids from surfsense_google_maps_scrape."
),
] = None,
max_reviews: Annotated[
int, Field(ge=1, description="Maximum reviews per place.")
] = 20,
sort_by: Annotated[
ReviewSort, Field(description="Review ordering.")
] = "newest",
language: Annotated[
str, Field(description="Reviews language code, e.g. 'en'.")
] = "en",
start_date: Annotated[
str | None,
Field(
description="ISO date like '2026-01-01'; keeps only reviews on "
"or after that day."
),
] = None,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Fetch customer reviews for Google Maps places by URL or place id.
Use this to read feedback on specific places; get urls or place_ids
from surfsense_google_maps_scrape first if you only have a name.
Returns review text, rating, author, and date per review.
Example: place_ids=['ChIJj61dQgK6j4AR...'], sort_by='newest'.
"""
return await run_scraper(
client,
context,
platform="google_maps",
verb="reviews",
payload={
"urls": urls,
"place_ids": place_ids,
"max_reviews": max_reviews,
"sort_by": sort_by,
"language": language,
"start_date": start_date,
},
workspace=workspace,
response_format=response_format,
)
@mcp.tool(
name="surfsense_list_scraper_runs",
title="List past scraper runs",
annotations=_READ_RUNS,
structured_output=False,
)
async def list_scraper_runs(
limit: Annotated[
int, Field(ge=1, description="Maximum runs to list.")
] = 20,
capability: Annotated[
str | None,
Field(
description="Filter by capability slug, e.g. 'web.crawl' or "
"'reddit.scrape'."
),
] = None,
status: Annotated[
str | None,
Field(description="Filter by run status: 'success' or 'error'."),
] = None,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""List recent scraper runs in the workspace, newest first.
Use this to find the run_id of an earlier scrape for example when an
inline result was truncated then fetch it in full with
surfsense_get_scraper_run. Returns each run's id, capability, status,
item count, and creation time.
Example: capability='reddit.scrape', status='success'.
"""
resolved = await context.resolve(workspace)
runs = await client.request(
"GET",
f"/workspaces/{resolved.id}/scrapers/runs",
params={
"limit": limit,
"capability": capability,
"status": status,
},
)
if response_format == "json":
return to_json(runs)
return _render_runs(runs)
@mcp.tool(
name="surfsense_get_scraper_run",
title="Fetch one scraper run in full",
annotations=_READ_RUNS,
structured_output=False,
)
async def get_scraper_run(
run_id: Annotated[
str,
Field(
description="Run id from surfsense_list_scraper_runs or a "
"prior scrape's output."
),
],
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Fetch a single scraper run in full, including its stored output.
Use this to retrieve the complete, untruncated result of an earlier
scrape. Do NOT re-run a scraper just to recover a truncated result
fetch the stored run instead.
"""
resolved = await context.resolve(workspace)
run = await client.request(
"GET", f"/workspaces/{resolved.id}/scrapers/runs/{run_id}"
)
if response_format == "json":
return clip(to_json(run))
return f"# Run {run.get('id', run_id)}\n\n```json\n{clip(to_json(run))}\n```"
def _render_runs(runs: list[dict] | None) -> str:
if not runs:
return "No scraper runs found."
lines = ["# Scraper runs", ""]
for run in runs:
lines.append(
f"- **{run.get('id')}** — {run.get('capability')} · {run.get('status')} · "
f"{run.get('item_count', 0)} item(s) · {run.get('created_at')}"
)
return "\n".join(lines)

View file

@ -0,0 +1,40 @@
"""API key extraction from request headers: Bearer, fallback, and rejection."""
from __future__ import annotations
from starlette.datastructures import Headers
from mcp_server.core.auth.headers import extract_api_key
def _headers(**pairs: str) -> Headers:
return Headers(pairs)
def test_reads_bearer_token():
assert extract_api_key(_headers(authorization="Bearer ss_pat_abc")) == "ss_pat_abc"
def test_bearer_scheme_is_case_insensitive():
assert extract_api_key(_headers(authorization="bearer ss_pat_abc")) == "ss_pat_abc"
def test_falls_back_to_x_api_key():
assert extract_api_key(Headers({"x-api-key": "ss_pat_xyz"})) == "ss_pat_xyz"
def test_bearer_wins_over_fallback():
headers = Headers({"authorization": "Bearer primary", "x-api-key": "secondary"})
assert extract_api_key(headers) == "primary"
def test_missing_headers_return_none():
assert extract_api_key(_headers()) is None
def test_empty_bearer_is_rejected():
assert extract_api_key(_headers(authorization="Bearer ")) is None
def test_non_bearer_authorization_is_ignored():
assert extract_api_key(_headers(authorization="Basic abc123")) is None

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import httpx
from surfsense_mcp.core.client import SurfSenseClient
from mcp_server.core.client import SurfSenseClient
_REQUEST = httpx.Request("GET", "http://localhost:8000/api/v1/documents")
@ -15,7 +15,7 @@ def _response(status: int, **kwargs) -> httpx.Response:
def test_explains_401_with_token_hint():
message = SurfSenseClient._explain_failure(_response(401, json={"detail": "bad"}))
assert "SURFSENSE_API_KEY" in message
assert "API key" in message
assert "bad" in message

View file

@ -6,7 +6,7 @@ import asyncio
import httpx
from surfsense_mcp.core.client import SurfSenseClient
from mcp_server.core.client import SurfSenseClient
def _capture(client: SurfSenseClient) -> dict:
@ -25,7 +25,9 @@ def _capture(client: SurfSenseClient) -> dict:
def test_none_params_are_dropped():
client = SurfSenseClient(api_base="http://test/api/v1", api_key="ss_pat_x", timeout=5)
client = SurfSenseClient(
api_base="http://test/api/v1", timeout=5, fallback_api_key="ss_pat_x"
)
seen = _capture(client)
asyncio.run(
client.request(

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from surfsense_mcp.features.knowledge_base.note_ingestion import build_note_document
from mcp_server.features.knowledge_base.note_ingestion import build_note_document
def test_builds_extension_document_with_content():

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from surfsense_mcp.core.rendering import clip, compact_items, to_json
from mcp_server.core.rendering import clip, compact_items, to_json
def test_clip_leaves_short_text_untouched():

View file

@ -0,0 +1,100 @@
"""Per-request key resolution and the Authorization header the backend receives.
Covers the security-critical behaviors: the per-request key wins over the env
fallback, the fallback covers stdio, a missing key is refused, and concurrent
callers never see each other's key.
"""
from __future__ import annotations
import asyncio
import httpx
import pytest
from mcp_server.core.auth import identity
from mcp_server.core.client import SurfSenseClient
from mcp_server.core.errors import ToolError
def _client_recording_auth(seen: dict, *, fallback: str | None) -> SurfSenseClient:
async def handler(request: httpx.Request) -> httpx.Response:
seen["authorization"] = request.headers.get("authorization")
return httpx.Response(200, json={"ok": True})
client = SurfSenseClient(
api_base="http://test/api/v1", timeout=5, fallback_api_key=fallback
)
client._http = httpx.AsyncClient(
base_url="http://test/api/v1", transport=httpx.MockTransport(handler)
)
return client
async def _get(client: SurfSenseClient) -> None:
await client.request("GET", "/workspaces")
def test_request_key_is_sent_as_bearer():
seen: dict = {}
client = _client_recording_auth(seen, fallback=None)
async def run() -> None:
token = identity.bind_api_key("ss_pat_request")
try:
await _get(client)
finally:
identity.unbind_api_key(token)
asyncio.run(run())
assert seen["authorization"] == "Bearer ss_pat_request"
def test_request_key_overrides_env_fallback():
seen: dict = {}
client = _client_recording_auth(seen, fallback="ss_pat_env")
async def run() -> None:
token = identity.bind_api_key("ss_pat_request")
try:
await _get(client)
finally:
identity.unbind_api_key(token)
asyncio.run(run())
assert seen["authorization"] == "Bearer ss_pat_request"
def test_env_fallback_used_without_request_key():
seen: dict = {}
client = _client_recording_auth(seen, fallback="ss_pat_env")
asyncio.run(_get(client))
assert seen["authorization"] == "Bearer ss_pat_env"
def test_missing_key_is_refused():
client = _client_recording_auth({}, fallback=None)
with pytest.raises(ToolError):
asyncio.run(_get(client))
def test_concurrent_callers_do_not_leak_keys():
seen_by_caller: dict[str, str | None] = {}
async def call_as(key: str) -> None:
# Each caller runs in its own task, so the contextvar is isolated.
recorded: dict = {}
client = _client_recording_auth(recorded, fallback=None)
token = identity.bind_api_key(key)
try:
await _get(client)
finally:
identity.unbind_api_key(token)
seen_by_caller[key] = recorded["authorization"]
async def run() -> None:
await asyncio.gather(call_as("ss_pat_A"), call_as("ss_pat_B"))
asyncio.run(run())
assert seen_by_caller["ss_pat_A"] == "Bearer ss_pat_A"
assert seen_by_caller["ss_pat_B"] == "Bearer ss_pat_B"

View file

@ -6,8 +6,9 @@ import asyncio
import pytest
from surfsense_mcp.core.errors import ToolError
from surfsense_mcp.core.workspace_context import WorkspaceContext
from mcp_server.core.auth import identity
from mcp_server.core.errors import ToolError
from mcp_server.core.workspace_context import WorkspaceContext
class FakeClient:
@ -96,3 +97,30 @@ def test_resolution_is_remembered_as_active():
assert ctx.active is not None and ctx.active.id == 2
# a later default call reuses the active selection without re-choosing
assert asyncio.run(ctx.resolve(None)).id == 2
def test_active_workspace_is_isolated_per_identity():
ctx = _context(_rows(("A", 1), ("B", 2)))
async def select_as(key: str, reference: str) -> None:
token = identity.bind_api_key(key)
try:
await ctx.resolve(reference)
finally:
identity.unbind_api_key(token)
async def active_for(key: str) -> int | None:
token = identity.bind_api_key(key)
try:
return ctx.active.id if ctx.active else None
finally:
identity.unbind_api_key(token)
asyncio.run(select_as("ss_pat_A", "A"))
asyncio.run(select_as("ss_pat_B", "B"))
# Each caller keeps its own selection; no bleed across identities.
assert asyncio.run(active_for("ss_pat_A")) == 1
assert asyncio.run(active_for("ss_pat_B")) == 2
# An unknown caller has no active selection.
assert asyncio.run(active_for("ss_pat_C")) is None

4
surfsense_mcp/uv.lock generated
View file

@ -718,6 +718,8 @@ source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "mcp" },
{ name = "starlette" },
{ name = "uvicorn" },
]
[package.dev-dependencies]
@ -729,6 +731,8 @@ dev = [
requires-dist = [
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "mcp", specifier = ">=1.26.0" },
{ name = "starlette", specifier = ">=0.37" },
{ name = "uvicorn", specifier = ">=0.30" },
]
[package.metadata.requires-dev]

View file

@ -50,16 +50,13 @@ export const metadata: Metadata = {
},
};
/* Mirrors surfsense_mcp/README.md: the real Cursor config. */
/* The hosted Cursor config; mirrors lib/mcp/clients.ts. */
const CURSOR_CONFIG = `{
"mcpServers": {
"surfsense": {
"command": "uv",
"args": ["run", "--directory", ".../surfsense_mcp",
"python", "-m", "surfsense_mcp"],
"env": {
"SURFSENSE_BASE_URL": "https://api.surfsense.com",
"SURFSENSE_API_KEY": "ss_pat_..."
"url": "https://mcp.surfsense.com/mcp",
"headers": {
"Authorization": "Bearer ss_pat_..."
}
}
}
@ -76,7 +73,7 @@ const STEPS = [
icon: TerminalSquare,
title: "Add the server to your client",
description:
"Drop the config into Cursor's mcp.json, run claude mcp add for Claude Code, or paste it into Claude Desktop. Point it at the cloud or your own self-hosted instance.",
"Point your client at https://mcp.surfsense.com/mcp with your key in an Authorization header — the hosted config for Cursor, Claude Code, and others is one paste. Prefer stdio? Switch to Self-host and run it against your own backend.",
},
{
icon: Server,
@ -135,7 +132,7 @@ const FAQ: FaqItem[] = [
{
question: "Which MCP clients does it work with?",
answer:
"Any MCP client that supports stdio servers. Claude Code, Codex, OpenCode, Cursor, Claude Desktop, VS Code, Windsurf, and Gemini CLI are documented with copy-paste configs on this page, and the same command works in custom agent harnesses built on the MCP SDK.",
"Any MCP client that speaks remote (streamable HTTP) or stdio. Claude Code, Codex, OpenCode, Cursor, Claude Desktop, VS Code, Windsurf, and Gemini CLI all have copy-paste configs on this page — Hosted for the one-paste https://mcp.surfsense.com/mcp endpoint, or Self-host for stdio against your own backend.",
},
{
question: "How is usage billed?",
@ -278,9 +275,9 @@ export default function McpServerPage() {
Step-by-step setup for every agent
</h2>
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
Pick your client, follow its two steps, and paste the config. Replace the placeholder
path with your surfsense_mcp checkout and the key with one from API Playground API
Keys or grab a pre-filled config from the playground itself.
Pick your client, choose <strong>Hosted</strong> or <strong>Self-host</strong>, and
paste the config. Replace the key with one from API Playground API Keys or grab a
pre-filled config from the playground itself.
</p>
</Reveal>
<Reveal>

View file

@ -1,4 +1,8 @@
import { ApiKeysSection } from "../components/api-keys-section";
import { Info } from "lucide-react";
import { WorkspaceApiAccessControl } from "@/components/settings/workspace-api-access-control";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import { ApiKeyContent } from "../../user-settings/components/ApiKeyContent";
export default async function PlaygroundApiKeysPage({
params,
@ -6,10 +10,33 @@ export default async function PlaygroundApiKeysPage({
params: Promise<{ workspace_id: string }>;
}) {
const { workspace_id } = await params;
const workspaceId = Number(workspace_id);
return (
<div className="mx-auto w-full max-w-3xl">
<ApiKeysSection workspaceId={Number(workspace_id)} />
<div className="mx-auto w-full max-w-5xl space-y-6">
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">API keys</h2>
<p className="text-sm text-muted-foreground">
Create user API keys and choose whether they can access this workspace.
</p>
</div>
<Alert>
<Info />
<AlertDescription>
External API calls need both a user API key and workspace API key access enabled.
</AlertDescription>
</Alert>
<section>
<WorkspaceApiAccessControl workspaceId={workspaceId} />
</section>
<Separator className="bg-border" />
<section>
<ApiKeyContent />
</section>
</div>
);
}

View file

@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold">API Keys</h1>
<p className="mt-1 text-sm text-muted-foreground">
Enable API access for this workspace and manage the keys that use it.
</p>
</div>
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/60 px-4 py-3">
<div className="space-y-1">
<Label htmlFor="playground-api-access">API key access</Label>
<p className="text-xs text-muted-foreground">
Allow API keys to access this workspace.
{!isLoading && !apiAccessEnabled && " Currently disabled — keys won't work here."}
</p>
</div>
{isLoading ? (
<Skeleton className="h-5 w-9 rounded-full" />
) : (
<Switch
id="playground-api-access"
checked={apiAccessEnabled}
disabled={saving}
onCheckedChange={handleToggle}
/>
)}
</div>
<ApiKeyContent />
</div>
);
}

View file

@ -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 ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy"}
</Button>
);
}
@ -45,8 +45,9 @@ function SchemaBlock({ title, schema }: { title: string; schema: Record<string,
const json = useMemo(() => JSON.stringify(schema, null, 2), [schema]);
return (
<details className="group rounded-md border border-border/60">
<summary className="cursor-pointer select-none px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground">
{title}
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground [&::-webkit-details-marker]:hidden">
<span>{title}</span>
<ChevronRight className="h-4 w-4 shrink-0 transition-transform group-open:rotate-90" />
</summary>
<div className="relative border-t border-border/60">
<CopyButton text={json} />
@ -90,16 +91,13 @@ export function ApiReference({
<div>
<h2 className="text-base font-semibold">API reference</h2>
<p className="mt-1 text-sm text-muted-foreground">
Call this API from your own project. Create a key under{" "}
<span className="font-medium text-foreground">API Keys</span> (and enable API access for
this workspace), then send it as a{" "}
<code className="rounded bg-muted/40 px-1 py-0.5 text-xs">Authorization: Bearer</code>{" "}
header.
Create an API key, enable API access for this workspace, then use the examples below to
call this endpoint.
</p>
</div>
<Tabs defaultValue="curl">
<TabsList className="h-auto flex-wrap">
<TabsList className="flex h-auto w-full flex-nowrap justify-start overflow-x-auto overflow-y-hidden">
{snippets.map((snippet) => (
<TabsTrigger key={snippet.id} value={snippet.id}>
{snippet.label}

View file

@ -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 (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="inline-flex rounded-md border border-border/60 p-0.5">
{items && (
<button
type="button"
onClick={() => 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
</button>
)}
<button
type="button"
onClick={() => 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
</button>
</div>
<Tabs value={view} onValueChange={(value) => setView(value as "table" | "json")}>
<TabsList className="h-auto">
{items && <TabsTrigger value="table">Table</TabsTrigger>}
<TabsTrigger value="json">JSON</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center gap-1">
{items && items.length > 0 && (
<Button
@ -150,9 +128,15 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa
Export CSV
</Button>
)}
<Button type="button" variant="ghost" size="sm" onClick={copy} className="gap-1.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={copy}
aria-label={copied ? "Copied JSON" : "Copy JSON"}
className="h-8 w-8 p-0"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy JSON"}
</Button>
</div>
</div>
@ -168,7 +152,7 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa
<ResultTable items={items} />
{truncated && (
<p className="text-xs text-muted-foreground">
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.
</p>
)}

View file

@ -1,8 +1,9 @@
"use client";
import { ArrowRight, History, KeyRound } from "lucide-react";
import { ArrowRight, History, Info, KeyRound } 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 (
<div className="space-y-8">
<div>
<h1 className="text-xl font-semibold">API Playground</h1>
<p className="mt-1 text-sm text-muted-foreground">
Manually run SurfSense's platform-native APIs and inspect their output. Every run is
captured under Runs.
</p>
</div>
<Alert>
<Info />
<AlertDescription>
<p>
Manually run SurfSense's platform-native APIs and inspect their output. To use these APIs outside SurfSense,{" "}
<Link
href={`${base}/api-keys`}
className="font-medium text-foreground underline-offset-4 hover:underline"
>
create an API key
</Link>
.
</p>
</AlertDescription>
</Alert>
<div className="grid gap-3 sm:grid-cols-2">
<Link
@ -36,7 +45,7 @@ export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) {
<div className="flex items-center gap-3">
<History className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Runs</p>
<p className="text-sm font-medium">API Runs</p>
<p className="text-xs text-muted-foreground">See every API run in this workspace</p>
</div>
</div>
@ -50,9 +59,7 @@ export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) {
<KeyRound className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">API Keys</p>
<p className="text-xs text-muted-foreground">
Enable workspace access and manage keys
</p>
<p className="text-xs text-muted-foreground">Manage keys and workspace API access</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />

View file

@ -1,14 +1,16 @@
"use client";
import { AlertTriangle, Coins, Hash, Loader2, Play, Timer, X } from "lucide-react";
import { Check, Coins, Copy, Hash, Info, 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 +60,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 (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm">
<p className="font-medium text-destructive">Insufficient credits</p>
<p className="mt-1 text-muted-foreground">You don't have enough credits to run this API.</p>
<Button asChild size="sm" variant="outline" className="mt-3">
<Link href={`/dashboard/${workspaceId}/buy-more`}>Buy credits</Link>
</Button>
</div>
);
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 (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{message}</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-auto max-w-full items-start justify-start gap-2 whitespace-normal rounded bg-muted/40 px-2 py-1 font-mono text-xs text-muted-foreground hover:bg-muted hover:text-foreground sm:whitespace-nowrap"
>
<code className="min-w-0 break-all text-left sm:break-normal">{endpoint}</code>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
<span className="sr-only">{copied ? "Copied endpoint" : "Copy endpoint"}</span>
</Button>
);
}
@ -110,6 +120,8 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
const [values, setValues] = useState<Record<string, unknown>>({});
const run = useRunStream(workspaceId);
const isRunning = run.status === "running";
const previousStatusRef = useRef(run.status);
const notifiedRunRef = useRef<string | null>(null);
// Seed form defaults once the schema is available.
useEffect(() => {
@ -158,6 +170,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 +221,40 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
return (
<div className="space-y-10">
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-6">
{capability.description && (
<Alert>
<Info />
<AlertDescription>
<p>
{capability.description}
{capability.docs_url ? (
<>
{" "}
<Link
href={capability.docs_url}
className="font-medium text-foreground underline-offset-4 hover:underline"
>
Read docs
</Link>{" "}
for more info.
</>
) : null}
</p>
</AlertDescription>
</Alert>
)}
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold">
{catalogVerb.label} <span className="text-muted-foreground">· {platform}</span>
</h1>
{capability.description && (
<p className="mt-1 text-sm text-muted-foreground">{capability.description}</p>
)}
<code className="mt-2 inline-block rounded bg-muted/40 px-1.5 py-0.5 text-xs text-muted-foreground">
POST /workspaces/{workspaceId}/scrapers/{platform}/{verb}
</code>
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<Coins className="h-3.5 w-3.5" />
<div className="space-y-2">
<EndpointCopyButton endpoint={endpoint} />
<div className="text-xs text-muted-foreground">
<span>Pricing: </span>
<span className="font-medium tabular-nums text-foreground">
{formatPricing(capability.pricing)}
</span>
</div>
</div>
<SchemaForm
fields={fields}
values={values}
@ -214,33 +263,22 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
/>
<div className="flex items-center gap-2">
<Button type="button" onClick={handleRun} disabled={isRunning} className="gap-1.5">
{isRunning ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
Run
<Button type="button" onClick={handleRun} disabled={isRunning} className="relative">
<span className={isRunning ? "opacity-0" : ""}>Run</span>
{isRunning && <Spinner size="sm" className="absolute" />}
</Button>
{isRunning && (
<Button type="button" variant="outline" onClick={run.cancel} className="gap-1.5">
<X className="h-4 w-4" />
<Button type="button" variant="secondary" onClick={run.cancel}>
Cancel
</Button>
)}
</div>
{run.status === "error" && <ErrorPanel error={run.error} workspaceId={workspaceId} />}
</div>
<div className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground">Output</h2>
{isRunning ? (
<RunProgressPanel
latest={run.latest}
events={run.events}
elapsedMs={run.elapsedMs}
/>
<RunProgressPanel latest={run.latest} events={run.events} elapsedMs={run.elapsedMs} />
) : run.status === "cancelled" ? (
<div className="flex h-64 items-center justify-center rounded-md border border-border/60 px-4 text-center text-sm text-muted-foreground">
Run cancelled.

View file

@ -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 (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold">Runs</h1>
<p className="mt-1 text-sm text-muted-foreground">
Every platform-native API run in this workspace from the playground, API keys, and
agents. Newest first.
</p>
</div>
<Alert>
<Info />
<AlertDescription>
View all API runs for this workspace, including runs from the playground, API keys, and agents.
</AlertDescription>
</Alert>
<div className="flex flex-wrap items-center gap-2">
<Select value={capability} onValueChange={setCapability}>

View file

@ -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({
<Label htmlFor={`field-${field.name}`} className="text-sm font-medium">
{field.title}
</Label>
{field.required && <span className="text-xs text-destructive">required</span>}
<Badge
variant={field.required ? "destructive" : "secondary"}
className="px-1.5 py-0 text-[10px]"
>
{field.required ? "required" : "optional"}
</Badge>
</div>
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>

View file

@ -0,0 +1,47 @@
"use client";
import { useSelectedLayoutSegments } from "next/navigation";
import type React from "react";
import { useMemo } from "react";
import {
getPlaygroundNavGroups,
getPlaygroundNavItems,
getPlaygroundSelectedLabel,
RoutedSectionShell,
} from "@/components/layout";
interface PlaygroundLayoutShellProps {
workspaceId: string;
children: React.ReactNode;
}
export function PlaygroundLayoutShell({ workspaceId, children }: PlaygroundLayoutShellProps) {
const segments = useSelectedLayoutSegments();
const base = `/dashboard/${workspaceId}/playground`;
const topLevelItems = useMemo(() => getPlaygroundNavItems(base), [base]);
const providerGroups = useMemo(() => getPlaygroundNavGroups(base), [base]);
const activeValue =
segments.length >= 2
? `${segments[0]}/${segments[1]}`
: segments[0] && topLevelItems.some((item) => item.value === segments[0])
? segments[0]
: "overview";
const selectedLabel = getPlaygroundSelectedLabel(activeValue, topLevelItems, providerGroups);
return (
<RoutedSectionShell
title="API Playground"
items={topLevelItems}
groups={providerGroups}
activeValue={activeValue}
selectedLabel={selectedLabel}
mobileNav="drawer"
desktopNav={false}
>
{children}
</RoutedSectionShell>
);
}

View file

@ -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 <PlaygroundLayoutShell workspaceId={workspace_id}>{children}</PlaygroundLayoutShell>;
}

View file

@ -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";

View file

@ -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<HTMLDivElement>) => {
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<RoutedSectionItem[]>(
() => [
{
value: "profile" as const,
label: t("profile_nav_label"),
href: `/dashboard/${workspaceId}/user-settings/profile`,
icon: <CircleUser className="h-4 w-4" />,
},
{
value: "api-key" as const,
label: t("api_key_nav_label"),
href: `/dashboard/${workspaceId}/user-settings/api-key`,
icon: <KeyRound className="h-4 w-4" />,
},
{
value: "prompts" as const,
label: "My Prompts",
href: `/dashboard/${workspaceId}/user-settings/prompts`,
icon: <WandSparkles className="h-4 w-4" />,
},
{
value: "community-prompts" as const,
label: "Community Prompts",
href: `/dashboard/${workspaceId}/user-settings/community-prompts`,
icon: <Library className="h-4 w-4" />,
},
{
value: "agent-permissions" as const,
label: "Agent Permissions",
href: `/dashboard/${workspaceId}/user-settings/agent-permissions`,
icon: <ShieldCheck className="h-4 w-4" />,
},
{
value: "messaging-channels" as const,
label: "Messaging Channels",
href: `/dashboard/${workspaceId}/user-settings/messaging-channels`,
icon: <MessageCircle className="h-4 w-4" />,
},
{
value: "purchases" as const,
label: "Purchase History",
href: `/dashboard/${workspaceId}/user-settings/purchases`,
icon: <ReceiptText className="h-4 w-4" />,
},
...(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: <Monitor className="h-4 w-4" />,
},
{
value: "hotkeys" as const,
label: "Hotkeys",
href: `/dashboard/${workspaceId}/user-settings/hotkeys`,
icon: <Keyboard className="h-4 w-4" />,
},
]
: []),
],
[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 (
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:flex-row">
<div className="md:w-[220px] md:shrink-0">
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
<nav className="hidden flex-col gap-0.5 md:flex">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</nav>
<div
className="overflow-x-auto border-b border-border pb-3 md:hidden"
onScroll={handleTabScroll}
style={{
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
}}
>
<div className="flex gap-1">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</div>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="hidden md:block">
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
<Separator className="mt-4 bg-border" />
</div>
<div className="min-w-0 pt-4 md:max-w-3xl">{children}</div>
</div>
</section>
<RoutedSectionShell
title={t("title")}
items={navItems}
activeValue={activeTab}
selectedLabel={selectedLabel}
contentClassName="md:max-w-3xl"
>
{children}
</RoutedSectionShell>
);
}

View file

@ -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<HTMLDivElement>) => {
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<RoutedSectionItem[]>(
() => [
{
value: "general" as const,
label: t("nav_general"),
href: `/dashboard/${workspaceId}/workspace-settings/general`,
icon: <Settings className="h-4 w-4" />,
},
{
value: "models" as const,
label: t("nav_models"),
href: `/dashboard/${workspaceId}/workspace-settings/models`,
icon: <Cpu className="h-4 w-4" />,
},
{
value: "team-roles" as const,
label: t("nav_team_roles"),
href: `/dashboard/${workspaceId}/workspace-settings/team-roles`,
icon: <UserKey className="h-4 w-4" />,
},
{
value: "prompts" as const,
label: t("nav_system_instructions"),
href: `/dashboard/${workspaceId}/workspace-settings/prompts`,
icon: <BookText className="h-4 w-4" />,
},
{
value: "public-links" as const,
label: t("nav_public_links"),
href: `/dashboard/${workspaceId}/workspace-settings/public-links`,
icon: <Earth className="h-4 w-4" />,
},
],
[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 (
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:flex-row">
<div className="md:w-[220px] md:shrink-0">
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
<nav className="hidden flex-col gap-0.5 md:flex">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</nav>
<div
className="overflow-x-auto border-b border-border pb-3 md:hidden"
onScroll={handleTabScroll}
style={{
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
}}
>
<div className="flex gap-1">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</div>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="hidden md:block">
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
<Separator className="mt-4" />
</div>
<div className="min-w-0 pt-4 md:max-w-3xl">{children}</div>
</div>
</section>
<RoutedSectionShell
title={t("title")}
items={navItems}
activeValue={activeTab}
selectedLabel={selectedLabel}
contentClassName="md:max-w-3xl"
>
{children}
</RoutedSectionShell>
);
}

View file

@ -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<boolean>(true);

View file

@ -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<PlaygroundPlatform[]>(() => {
if (!capabilities?.length) return [];
@ -1014,30 +1012,26 @@ const ConnectedScraperIcons: FC<{ workspaceId: number }> = ({ workspaceId }) =>
return (
<div className="hidden items-center gap-1 sm:flex">
<div aria-hidden className="h-5 w-px shrink-0 bg-border" />
<div className="flex items-center gap-0.5" aria-label="Connected data sources">
<AvatarGroup className="shrink-0">
{platforms.map((platform, i) => {
const Icon = platform.icon;
return (
<Tooltip key={platform.id}>
<TooltipTrigger asChild>
<motion.span
initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.2,
ease: [0.23, 1, 0.32, 1],
delay: reduceMotion ? 0 : i * 0.04,
}}
className="flex size-5 items-center justify-center"
<Avatar
className="size-5"
style={{ zIndex: platforms.length - i }}
>
<Icon className="size-3.5" />
</motion.span>
<AvatarFallback className="bg-popover text-[10px]">
<Icon className="size-3" />
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent side="bottom">{platform.label} · Connected</TooltipContent>
<TooltipContent side="bottom">{platform.label} scraper available</TooltipContent>
</Tooltip>
);
})}
</div>
</AvatarGroup>
</div>
);
};

View file

@ -206,7 +206,7 @@ function buildMcp({ mcpTool }: ApiSample): string {
const config = {
mcpServers: {
surfsense: {
url: "https://mcp.surfsense.com",
url: "https://mcp.surfsense.com/mcp",
headers: { Authorization: "Bearer ${SURFSENSE_API_KEY}" },
},
},

View file

@ -129,6 +129,7 @@ export const FolderNode = React.memo(function FolderNode({
const rowRef = useRef<HTMLDivElement>(null);
const [dropZone, setDropZone] = useState<DropZone | null>(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 && (
<DropdownMenu>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hidden sm:inline-flex h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
className={cn(
"hidden sm:inline-flex h-6 w-6 shrink-0 hover:bg-transparent transition-opacity",
dropdownOpen
? "opacity-100 bg-accent hover:bg-accent"
: "opacity-0 group-hover:opacity-100"
)}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />

View file

@ -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() {

View file

@ -9,10 +9,15 @@ export type {
User,
Workspace,
} from "./types/layout.types";
export type { RoutedSectionGroup, RoutedSectionItem } from "./ui";
export {
ChatListItem,
CreateWorkspaceDialog,
CreditBalanceDisplay,
getPlaygroundActiveValue,
getPlaygroundNavGroups,
getPlaygroundNavItems,
getPlaygroundSelectedLabel,
Header,
IconRail,
LayoutShell,
@ -20,6 +25,8 @@ export {
MobileSidebarTrigger,
NavIcon,
NavSection,
PlaygroundSidebar,
RoutedSectionShell,
Sidebar,
SidebarCollapseButton,
SidebarHeader,

View file

@ -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}

View file

@ -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";
@ -53,6 +51,7 @@ import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import type { ChatItem, NavItem, Workspace } from "../types/layout.types";
import { CreateWorkspaceDialog } from "../ui/dialogs";
import { PlaygroundSidebar } from "../ui/playground/PlaygroundSidebar";
import { LayoutShell } from "../ui/shell";
interface LayoutDataProviderProps {
@ -60,17 +59,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 +67,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 +111,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<SlideoutPanel>(null);
const isInboxSidebarOpen = activeSlideoutPanel === "inbox";
// Search space dialog state
const [isCreateWorkspaceDialogOpen, setIsCreateWorkspaceDialogOpen] = useState(false);
@ -228,17 +208,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 +287,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 +297,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 +313,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 +454,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 +626,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 +678,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
setTheme={setTheme}
isChatPage={isChatPage}
isAllChatsPage={isAllChatsPage}
showPlaygroundSidebar={playgroundSidebarOpen}
useWorkspacePanel={useWorkspacePanel}
workspacePanelViewportClassName={
isUserSettingsPage ||
@ -754,24 +690,14 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
? "items-start justify-center px-6 py-8 md:px-10 md:pb-10 md:pt-16"
: undefined
}
workspacePanelContentClassName={
isAutomationsPage || isPlaygroundPage
? "max-w-none select-none"
: isAllChatsPage
? "max-w-5xl"
: isUserSettingsPage || isWorkspaceSettingsPage || isTeamPage || isArtifactsPage
? "max-w-5xl"
: undefined
}
workspacePanelContentClassName={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 +708,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,
@ -792,6 +719,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
}}
onTabSwitch={handleTabSwitch}
onTabPrefetch={handleTabPrefetch}
playgroundSidebar={<PlaygroundSidebar workspaceId={workspaceId} />}
>
<Fragment key={chatResetKey}>{children}</Fragment>
</LayoutShell>

View file

@ -0,0 +1,278 @@
"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;
desktopNav?: boolean;
}
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 (
<Link
href={item.href}
replace
scroll={false}
prefetch
onClick={onNavigate}
className={cn(
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
isActive
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
<span className="min-w-0 truncate">{item.label}</span>
</Link>
);
}
function SectionNavGroup({
group,
activeValue,
isExpanded,
onToggle,
onNavigate,
}: {
group: RoutedSectionGroup;
activeValue: string;
isExpanded: boolean;
onToggle: () => void;
onNavigate?: () => void;
}) {
return (
<div className="flex flex-col gap-0.5">
<button
type="button"
aria-expanded={isExpanded}
onClick={onToggle}
className={cn(
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
isExpanded
? "text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{group.icon}
<span className="min-w-0 truncate">{group.label}</span>
<ChevronRight
className={cn("ml-auto h-4 w-4 shrink-0 transition-transform", isExpanded && "rotate-90")}
/>
</button>
<div
className={cn(
"grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out",
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
)}
aria-hidden={!isExpanded}
>
<div className="min-h-0 overflow-hidden">
<div className="flex flex-col gap-0.5 pl-10">
{group.items.map((item) => (
<SectionNavLink
key={item.value}
item={item}
activeValue={activeValue}
onNavigate={onNavigate}
/>
))}
</div>
</div>
</div>
</div>
);
}
export function RoutedSectionShell({
title,
items,
activeValue,
selectedLabel,
children,
groups = [],
mobileNav = "scroll",
contentClassName,
desktopNav = true,
}: RoutedSectionShellProps) {
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
const [drawerOpen, setDrawerOpen] = useState(false);
const [expandedGroup, setExpandedGroup] = useState<string | null>(() =>
findActiveGroupValue(groups, activeValue)
);
useEffect(() => {
const activeGroup = findActiveGroupValue(groups, activeValue);
if (activeGroup) {
setExpandedGroup(activeGroup);
}
}, [activeValue, groups]);
const handleTabScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
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) => (
<SectionNavLink
key={item.value}
item={item}
activeValue={activeValue}
onNavigate={onNavigate}
/>
))}
{groups.length > 0 && (
<>
<Separator className="my-3 bg-border" />
<div className="flex flex-col gap-0.5">
{groups.map((group) => (
<SectionNavGroup
key={group.value}
group={group}
activeValue={activeValue}
isExpanded={expandedGroup === group.value}
onToggle={() =>
setExpandedGroup((current) => (current === group.value ? null : group.value))
}
onNavigate={onNavigate}
/>
))}
</div>
</>
)}
</>
);
return (
<section
className={cn(
"flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col",
desktopNav ? "gap-6 md:flex-row" : "gap-4 md:block"
)}
>
<div className={cn("md:w-[220px] md:shrink-0", !desktopNav && "md:hidden")}>
<h1 className="mb-4 px-1 text-xl font-semibold tracking-tight text-foreground md:text-2xl">
{title}
</h1>
{desktopNav ? <nav className="hidden flex-col gap-0.5 md:flex">{renderNav()}</nav> : null}
{mobileNav === "drawer" ? (
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen} shouldScaleBackground={false}>
<DrawerTrigger asChild>
<Button
type="button"
variant="outline"
className="flex h-10 w-full justify-between bg-transparent px-3 hover:bg-accent md:hidden"
>
<span className="truncate">{selectedLabel}</span>
<ChevronRight className="h-4 w-4 rotate-90 text-muted-foreground" />
</Button>
</DrawerTrigger>
<DrawerContent className="h-[88vh] overflow-hidden rounded-t-2xl border bg-popover text-popover-foreground">
<DrawerHandle className="mt-3 h-1.5 w-10" />
<DrawerTitle className="sr-only">{title} navigation</DrawerTitle>
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-4">
{renderNav(() => setDrawerOpen(false))}
</nav>
</DrawerContent>
</Drawer>
) : (
<div
className="overflow-x-auto border-b border-border pb-3 md:hidden"
onScroll={handleTabScroll}
style={{
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
}}
>
<div className="flex gap-1">
{items.map((item) => (
<Link
key={item.value}
href={item.href}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeValue === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</div>
</div>
)}
</div>
<div className="min-w-0 flex-1">
{desktopNav ? (
<div className="hidden md:block">
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
<Separator className="mt-4 bg-border" />
</div>
) : null}
<div className={cn("min-w-0", desktopNav ? "pt-4" : "pt-4 md:pt-0", contentClassName)}>
{children}
</div>
</div>
</section>
);
}

View file

@ -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 ? <NotificationsDropdown notifications={notifications} /> : undefined
}
/>
</div>
);

View file

@ -1,6 +1,15 @@
export { CreateWorkspaceDialog } from "./dialogs";
export { Header } from "./header";
export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail";
export {
getPlaygroundActiveValue,
getPlaygroundNavGroups,
getPlaygroundNavItems,
getPlaygroundSelectedLabel,
PlaygroundSidebar,
} from "./playground/PlaygroundSidebar";
export type { RoutedSectionGroup, RoutedSectionItem } from "./RoutedSectionShell";
export { RoutedSectionShell } from "./RoutedSectionShell";
export { LayoutShell } from "./shell";
export {
ChatListItem,

View file

@ -0,0 +1,221 @@
"use client";
import { ChevronRight, History, KeyRound, LayoutGrid } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Separator } from "@/components/ui/separator";
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
import { cn } from "@/lib/utils";
import type { RoutedSectionGroup, RoutedSectionItem } from "../RoutedSectionShell";
interface PlaygroundSidebarProps {
workspaceId: string;
className?: string;
}
export function getPlaygroundNavItems(base: string): RoutedSectionItem[] {
return [
{
value: "overview",
label: "Overview",
href: base,
icon: <LayoutGrid className="h-4 w-4" />,
},
{
value: "runs",
label: "API Runs",
href: `${base}/runs`,
icon: <History className="h-4 w-4" />,
},
{
value: "api-keys",
label: "API Keys",
href: `${base}/api-keys`,
icon: <KeyRound className="h-4 w-4" />,
},
];
}
export function getPlaygroundNavGroups(base: string): RoutedSectionGroup[] {
return PLAYGROUND_PLATFORMS.map((platform) => {
const Icon = platform.icon;
return {
value: platform.id,
label: platform.label,
icon: <Icon className="h-4 w-4 shrink-0" />,
items: platform.verbs.map((verb) => ({
value: `${platform.id}/${verb.verb}`,
label: verb.label,
href: `${base}/${platform.id}/${verb.verb}`,
})),
};
});
}
export function getPlaygroundActiveValue(
pathname: string | null,
base: string,
items: RoutedSectionItem[]
): string {
if (!pathname?.startsWith(base)) return "";
const rest = pathname.slice(base.length).replace(/^\/+/, "");
if (!rest) return "overview";
const [first, second] = rest.split("/");
if (second) return `${first}/${second}`;
if (items.some((item) => item.value === first)) return first;
return "overview";
}
export function getPlaygroundSelectedLabel(
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";
}
function findActiveGroupValue(groups: RoutedSectionGroup[], activeValue: string): string | null {
return (
groups.find((group) => group.items.some((item) => item.value === activeValue))?.value ?? null
);
}
function PlaygroundNavLink({
item,
activeValue,
}: {
item: RoutedSectionItem;
activeValue: string;
}) {
const isActive = activeValue === item.value;
return (
<Link
href={item.href}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
isActive
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
<span className="min-w-0 truncate">{item.label}</span>
</Link>
);
}
function PlaygroundNavGroup({
group,
activeValue,
isExpanded,
onToggle,
}: {
group: RoutedSectionGroup;
activeValue: string;
isExpanded: boolean;
onToggle: () => void;
}) {
return (
<div className="flex flex-col gap-0.5">
<button
type="button"
aria-expanded={isExpanded}
onClick={onToggle}
className={cn(
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
isExpanded
? "text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{group.icon}
<span className="min-w-0 truncate">{group.label}</span>
<ChevronRight
className={cn("ml-auto h-4 w-4 shrink-0 transition-transform", isExpanded && "rotate-90")}
/>
</button>
<div
className={cn(
"grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out",
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
)}
aria-hidden={!isExpanded}
>
<div className="min-h-0 overflow-hidden">
<div className="flex flex-col gap-0.5 pl-10">
{group.items.map((item) => (
<PlaygroundNavLink key={item.value} item={item} activeValue={activeValue} />
))}
</div>
</div>
</div>
</div>
);
}
export function PlaygroundSidebar({ workspaceId, className }: PlaygroundSidebarProps) {
const pathname = usePathname();
const base = `/dashboard/${workspaceId}/playground`;
const items = useMemo(() => getPlaygroundNavItems(base), [base]);
const groups = useMemo(() => getPlaygroundNavGroups(base), [base]);
const activeValue = getPlaygroundActiveValue(pathname, base, items);
const [expandedGroup, setExpandedGroup] = useState<string | null>(() =>
findActiveGroupValue(groups, activeValue)
);
useEffect(() => {
const activeGroup = findActiveGroupValue(groups, activeValue);
if (activeGroup) {
setExpandedGroup(activeGroup);
}
}, [activeValue, groups]);
return (
<aside
className={cn(
"flex h-full w-[240px] shrink-0 flex-col overflow-hidden border-r bg-panel text-sidebar-foreground select-none",
className
)}
>
<div className="flex h-12 shrink-0 items-center px-3">
<h1 className="truncate text-lg font-semibold tracking-tight text-foreground">
API Playground
</h1>
</div>
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-4 pt-1.5">
<div className="flex flex-col gap-0.5">
{items.map((item) => (
<PlaygroundNavLink key={item.value} item={item} activeValue={activeValue} />
))}
<Separator className="my-3 bg-border" />
{groups.map((group) => (
<PlaygroundNavGroup
key={group.value}
group={group}
activeValue={activeValue}
isExpanded={expandedGroup === group.value}
onToggle={() =>
setExpandedGroup((current) => (current === group.value ? null : group.value))
}
/>
))}
</div>
</nav>
</aside>
);
}

View file

@ -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";
@ -26,15 +24,8 @@ import {
RightPanelExpandButton,
RightPanelToggleButton,
} from "../right-panel/RightPanel";
import {
InboxSidebarContent,
MobileSidebar,
MobileSidebarTrigger,
PlaygroundSidebar,
Sidebar,
SidebarCollapseButton,
} from "../sidebar";
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
import { MobileSidebar, MobileSidebarTrigger, Sidebar, SidebarCollapseButton } from "../sidebar";
import type { NotificationsDropdownData } from "../sidebar/NotificationsDropdown";
import { TabBar } from "../tabs/TabBar";
import { WorkspacePanel } from "./WorkspacePanel";
@ -80,28 +71,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<boolean>;
markAllAsRead: () => Promise<boolean>;
}
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,20 +103,16 @@ 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;
playgroundSidebar?: React.ReactNode;
}
function MainContentPanel({
@ -239,23 +204,22 @@ 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,
playgroundSidebar,
}: LayoutShellProps) {
const isMobile = useIsMobile();
const electronAPI = useElectronAPI();
const isMacDesktop = electronAPI?.versions.platform === "darwin";
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isPlaygroundSidebarCollapsed, setIsPlaygroundSidebarCollapsed] = useState(false);
const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
const {
sidebarWidth,
@ -268,17 +232,9 @@ export function LayoutShell({
() => ({ isCollapsed, setIsCollapsed, toggleCollapsed }),
[isCollapsed, setIsCollapsed, toggleCollapsed]
);
const closeSlideout = useCallback(
(open: boolean) => {
if (!open) onSlideoutPanelChange?.(null);
},
[onSlideoutPanelChange]
);
const anySlideOutOpen = activeSlideoutPanel !== null;
const panelAriaLabel = activeSlideoutPanel === "inbox" ? "Inbox" : "Panel";
const handlePlaygroundSidebarToggle = () => {
setIsPlaygroundSidebarCollapsed((collapsed) => !collapsed);
};
// Mobile layout
if (isMobile) {
@ -316,6 +272,7 @@ export function LayoutShell({
onUserSettings={onUserSettings}
onAnnouncements={onAnnouncements}
announcementUnreadCount={announcementUnreadCount}
notifications={notifications}
onLogout={onLogout}
pageUsage={pageUsage}
theme={theme}
@ -335,34 +292,6 @@ export function LayoutShell({
{children}
</main>
)}
{/* Mobile unified slide-out panel */}
<SidebarSlideOutPanel
open={anySlideOutOpen}
onOpenChange={closeSlideout}
ariaLabel={panelAriaLabel}
>
<AnimatePresence mode="popLayout" initial={false}>
{activeSlideoutPanel === "inbox" && inbox && (
<motion.div
key="inbox"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<InboxSidebarContent
onOpenChange={(open) => closeSlideout(open)}
comments={inbox.comments}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/>
</motion.div>
)}
</AnimatePresence>
</SidebarSlideOutPanel>
</div>
</TooltipProvider>
</SidebarProvider>
@ -400,36 +329,18 @@ export function LayoutShell({
onUserSettings={onUserSettings}
onAnnouncements={onAnnouncements}
announcementUnreadCount={announcementUnreadCount}
notifications={notifications}
onLogout={onLogout}
theme={theme}
setTheme={setTheme}
/>
</div>
{/* 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 && (
<div
className={cn(
"relative hidden md:flex shrink-0 z-20 -mr-2 bg-panel",
isMacDesktop ? "rounded-tl-xl border-t border-r border-l" : "border-r"
)}
>
<PlaygroundSidebar workspaceId={activeWorkspaceId} />
</div>
)}
{/* 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. */}
<div
className={cn(
"relative hidden md:flex shrink-0 z-20 -mr-2 bg-panel",
isMacDesktop
? cn(
"border-t border-r",
!showPlaygroundSidebar && "rounded-tl-xl border-l"
)
: "border-r"
isMacDesktop ? "rounded-tl-xl border-l border-t border-r" : "border-r"
)}
>
<Sidebar
@ -438,6 +349,12 @@ export function LayoutShell({
onToggleCollapse={toggleCollapsed}
navItems={navItems}
onNavItemClick={onNavItemClick}
onPlaygroundItemClick={
playgroundSidebar ? handlePlaygroundSidebarToggle : undefined
}
isPlaygroundSidebarOpen={
playgroundSidebar ? !isPlaygroundSidebarCollapsed : undefined
}
chats={chats}
activeChatId={activeChatId}
onNewChat={onNewChat}
@ -465,10 +382,7 @@ export function LayoutShell({
<Logo disableLink priority className="h-7 w-7 rounded-md" />
) : undefined
}
className={cn(
"flex shrink-0",
isMacDesktop && !showPlaygroundSidebar && "rounded-tl-xl"
)}
className={cn("flex shrink-0", isMacDesktop && "rounded-tl-xl")}
isLoadingChats={isLoadingChats}
sidebarWidth={sidebarWidth}
isResizing={isResizing}
@ -491,35 +405,23 @@ export function LayoutShell({
)}
/>
)}
{/* Unified slide-out panel — shell stays open, content cross-fades */}
<SidebarSlideOutPanel
open={anySlideOutOpen}
onOpenChange={closeSlideout}
ariaLabel={panelAriaLabel}
>
<AnimatePresence mode="popLayout" initial={false}>
{activeSlideoutPanel === "inbox" && inbox && (
<motion.div
key="inbox"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<InboxSidebarContent
onOpenChange={(open) => closeSlideout(open)}
comments={inbox.comments}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
/>
</motion.div>
)}
</AnimatePresence>
</SidebarSlideOutPanel>
</div>
{playgroundSidebar ? (
<div
aria-hidden={isPlaygroundSidebarCollapsed}
className={cn(
"hidden md:flex shrink-0 overflow-hidden -mr-2 bg-panel transition-[width,opacity] duration-200 ease-out",
isPlaygroundSidebarCollapsed
? "w-0 opacity-0 pointer-events-none"
: "w-[240px] opacity-100",
isMacDesktop && !isPlaygroundSidebarCollapsed && "border-t"
)}
>
<div className="w-[240px] shrink-0">{playgroundSidebar}</div>
</div>
) : null}
<DesktopWorkspaceRegion>
{useWorkspacePanel ? (
<WorkspacePanel

View file

@ -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",
})}
>
<span className="min-w-0 flex-1 truncate">{animatedName}</span>
@ -79,7 +80,7 @@ export function ChatListItem({
<div
className={cn(
"pointer-events-none absolute right-0 top-0 bottom-0 flex items-center pr-1 pl-6 rounded-r-md",
isActive
isHighlighted
? "bg-gradient-to-l from-accent from-60% to-transparent"
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
isMobile

View file

@ -27,7 +27,7 @@ function formatUsd(micros: number): string {
* premium-credit meters. Values come from Zero (live-replicated from Postgres)
* as integer micro-USD (1_000_000 == $1.00). A low-balance warning highlights
* the amount when it falls below $0.50 so the user knows to top up or enable
* auto-reload.
* automatic top-ups.
*/
export function CreditBalanceDisplay() {
const isAnonymous = useIsAnonymous();
@ -46,7 +46,7 @@ export function CreditBalanceDisplay() {
"font-medium tabular-nums",
isLow ? "text-amber-600 dark:text-amber-500" : "text-foreground"
)}
title={isLow ? "Low balance — buy credits or enable auto-reload" : undefined}
title={isLow ? "Low balance: add credits or enable top-ups" : undefined}
>
{formatUsd(balanceMicros)}
</span>

View file

@ -3,7 +3,8 @@
import { useQuery } from "@rocicorp/zero/react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
FolderInput,
Check,
FilePlus,
FolderPlus,
FolderSync,
ListFilter,
@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="relative h-7 w-7 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Document actions"
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="relative h-6 w-6 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Document actions"
>
<SlidersVertical className="size-3.5" />
{activeTypes.length > 0 ? (
<span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
) : null}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onSelect={onCreateFolder}>
<FolderPlus className="h-4 w-4" />
New folder
</DropdownMenuItem>
{isMobile ? (
<DropdownMenuItem onSelect={() => setFilterDrawerOpen(true)}>
<ListFilter className="h-4 w-4" />
<span className="flex-1">Filter by type</span>
</DropdownMenuItem>
) : (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ListFilter className="h-4 w-4" />
Filter by type
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52 max-h-72 overflow-y-auto">
{documentTypes.length > 0 ? (
documentTypes.map((type) => (
<DropdownMenuCheckboxItem
key={type}
checked={activeTypes.includes(type)}
onCheckedChange={(checked) => onToggleType(type, checked === true)}
onSelect={(event) => event.preventDefault()}
>
{getDocumentTypeIcon(type, "h-4 w-4")}
<span className="min-w-0 flex-1 truncate">{getDocumentTypeLabel(type)}</span>
<span className="ml-auto text-xs text-muted-foreground">
{typeCounts[type] ?? 0}
</span>
</DropdownMenuCheckboxItem>
))
) : (
<DropdownMenuItem disabled>No document types</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</DropdownMenuContent>
</DropdownMenu>
<Drawer
open={filterDrawerOpen}
onOpenChange={setFilterDrawerOpen}
shouldScaleBackground={false}
>
<DrawerContent
className="z-80 max-h-[75vh] rounded-t-2xl border bg-popover text-popover-foreground"
overlayClassName="z-80"
>
<SlidersVertical className="h-3.5 w-3.5" />
{activeTypes.length > 0 ? (
<span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
) : null}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onSelect={onCreateFolder}>
<FolderPlus className="h-4 w-4" />
New folder
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ListFilter className="h-4 w-4" />
<DrawerHandle className="mt-3 h-1.5 w-10" />
<DrawerTitle className="px-4 pb-2 pt-3 text-center text-base font-semibold">
Filter by type
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52 max-h-72 overflow-y-auto">
</DrawerTitle>
<div className="px-4 pb-6 pt-1">
{documentTypes.length > 0 ? (
documentTypes.map((type) => (
<DropdownMenuCheckboxItem
key={type}
checked={activeTypes.includes(type)}
onCheckedChange={(checked) => onToggleType(type, checked === true)}
onSelect={(event) => event.preventDefault()}
>
{getDocumentTypeIcon(type, "h-4 w-4")}
<span className="min-w-0 flex-1 truncate">{getDocumentTypeLabel(type)}</span>
<span className="ml-auto text-xs text-muted-foreground">
{typeCounts[type] ?? 0}
</span>
</DropdownMenuCheckboxItem>
))
documentTypes.map((type, index) => {
const isActive = activeTypes.includes(type);
return (
<div key={type}>
{index > 0 && <div className="mx-3 h-px bg-popover-border" />}
<button
type="button"
className="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"
onClick={() => onToggleType(type, !isActive)}
>
{getDocumentTypeIcon(type, "h-4 w-4")}
<span className="min-w-0 flex-1 truncate">{getDocumentTypeLabel(type)}</span>
<span className="text-xs text-muted-foreground">{typeCounts[type] ?? 0}</span>
{isActive && <Check className="h-4 w-4 shrink-0 text-primary" />}
</button>
</div>
);
})
) : (
<DropdownMenuItem disabled>No document types</DropdownMenuItem>
<p className="px-3 py-4 text-sm text-muted-foreground">No document types</p>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</div>
</DrawerContent>
</Drawer>
</>
);
}
@ -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"
>
<FolderInput className="h-3.5 w-3.5" />
<FilePlus className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
@ -1213,7 +1267,7 @@ function AuthenticatedDocumentsSidebarBase({
defaultOpen={true}
contentClassName="px-0"
persistentAction={
<div className="flex items-center gap-0.5">
<div className="flex items-center gap-1.5">
<EmbeddedImportMenu onFolderWatched={refreshWatchedIds} />
<EmbeddedDocumentsMenu
typeCounts={typeCounts}
@ -1389,7 +1443,7 @@ function AnonymousDocumentsSidebar({ embedded = false }: DocumentsSidebarProps)
defaultOpen={true}
contentClassName="px-0"
persistentAction={
<div className="flex items-center gap-0.5">
<div className="flex items-center gap-1.5">
<EmbeddedImportMenu gate={gate} />
<EmbeddedDocumentsMenu
typeCounts={hasDoc ? { FILE: 1 } : {}}

File diff suppressed because it is too large Load diff

View file

@ -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 ? (
<NotificationsDropdown
notifications={notifications}
onCloseMobileSidebar={() => onOpenChange(false)}
/>
) : undefined
}
/>
</div>
{/* Sidebar Content - right side */}
<div className="flex-1 overflow-hidden flex flex-col [&>*]:!w-full">
<div className="flex-1 overflow-hidden flex flex-col border-r [&>*]:!w-full">
<Sidebar
workspace={workspace}
isCollapsed={false}

View file

@ -0,0 +1,460 @@
"use client";
import { useAtom } from "jotai";
import { Bell } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
isCommentReplyMetadata,
isInsufficientCreditsMetadata,
isNewMentionMetadata,
} from "@/contracts/types/inbox.types";
import type { InboxItem } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
export interface NotificationsDataSource {
items: InboxItem[];
unreadCount: number;
totalCount?: number;
loading: boolean;
loadingMore?: boolean;
hasMore?: boolean;
loadMore?: () => void;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
}
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<NotificationFilter>("all");
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
const [markingAllAsRead, setMarkingAllAsRead] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const loadMoreTriggerRef = useRef<HTMLDivElement>(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 = (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Notifications"
className={cn(
"relative h-10 w-10 rounded-lg text-muted-foreground hover:bg-accent hover:text-accent-foreground",
open && "bg-accent text-accent-foreground"
)}
>
<Bell className="h-4 w-4" />
{notifications.totalUnreadCount > 0 ? (
<span className="absolute right-1 top-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-none text-destructive-foreground">
{unreadLabel}
</span>
) : null}
</Button>
);
const panelContent = (
<>
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0">
<h2 className="text-base font-semibold">Notifications</h2>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleMarkAllAsRead}
disabled={visibleUnreadCount === 0 || markingAllAsRead}
className="h-8 shrink-0 gap-1.5 px-2 text-xs text-muted-foreground hover:text-accent-foreground"
>
{markingAllAsRead ? <Spinner size="xs" /> : null}
Mark all read
</Button>
</div>
<div className="relative flex shrink-0 items-end gap-4 px-4 after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-px after:bg-muted-foreground/25 after:content-['']">
{tabs.map((tab) => {
const isActive = activeFilter === tab.value;
return (
<button
key={tab.value}
type="button"
aria-pressed={isActive}
onClick={() => 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"
)}
>
<span>{tab.label}</span>
<span
className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-muted px-1.5 text-[11px] font-semibold text-muted-foreground"
>
{formatNotificationCount(tab.count)}
</span>
</button>
);
})}
</div>
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-y-auto p-2">
{isLoading ? (
<div className="space-y-1">
{[82, 64, 74].map((width) => (
<div key={width} className="flex h-[72px] items-center rounded-lg px-2 py-2">
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-3 rounded" style={{ width: `${width}%` }} />
<Skeleton className="h-2.5 w-1/2 rounded" />
</div>
</div>
))}
</div>
) : items.length > 0 ? (
<div className="space-y-1">
{items.map((item) => {
const isMarkingAsRead = markingAsReadId === item.id;
return (
<Button
key={item.id}
type="button"
variant="ghost"
disabled={isMarkingAsRead}
onClick={() => 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" }}
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start gap-2">
<p
className={cn(
"line-clamp-1 flex-1 text-sm font-medium",
!item.read && "font-semibold"
)}
>
{item.title}
</p>
{!item.read ? (
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
) : null}
</div>
<p className="mt-0.5 line-clamp-2 text-xs font-normal text-muted-foreground group-hover:text-accent-foreground/80">
{item.message}
</p>
<p className="mt-1 text-[11px] font-normal text-muted-foreground/70">
{formatTime(item.created_at)}
</p>
</div>
{isMarkingAsRead ? <Spinner size="xs" className="shrink-0" /> : null}
</Button>
);
})}
{hasMore ? (
<div
ref={loadMoreTriggerRef}
className="flex min-h-10 items-center justify-center py-2"
>
{isLoadingMore ? <Spinner size="xs" /> : null}
</div>
) : null}
</div>
) : (
<div className="flex min-h-full flex-col items-center justify-center px-6 py-10 text-center">
<p className="text-sm font-medium">{emptyStateCopy.title}</p>
<p className="mt-1 text-xs text-muted-foreground">{emptyStateCopy.description}</p>
{hasMore ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={loadMoreForActiveFilter}
disabled={isLoadingMore}
className="mt-3 text-xs"
>
{isLoadingMore ? <Spinner size="xs" /> : null}
Load more
</Button>
) : null}
</div>
)}
</div>
</>
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen} shouldScaleBackground={false}>
<DrawerTrigger asChild>{triggerButton}</DrawerTrigger>
<DrawerContent
className="z-80 h-[78vh] max-h-[90vh] overflow-hidden rounded-t-2xl border bg-popover text-popover-foreground"
overlayClassName="z-80"
>
<DrawerHandle className="mt-3 h-1.5 w-10" />
<DrawerTitle className="sr-only">Notifications</DrawerTitle>
<div className="flex min-h-0 flex-1 select-none flex-col">{panelContent}</div>
</DrawerContent>
</Drawer>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Notifications
</TooltipContent>
</Tooltip>
<PopoverContent
side="right"
align="end"
sideOffset={10}
className="z-80 flex h-[min(420px,calc(100vh-2rem))] w-[360px] select-none flex-col overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-lg"
>
{panelContent}
</PopoverContent>
</Popover>
);
}

View file

@ -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 (
<Link
href={href}
aria-current={isActive ? "page" : undefined}
className={cn(
"group/link relative flex h-9 items-center gap-2 rounded-md mx-2 px-2 text-sm text-left",
"transition-colors hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
indented && "pl-8",
isActive && "bg-accent text-accent-foreground"
)}
>
{Icon ? <Icon className="h-3.5 w-3.5 shrink-0" /> : null}
<span className="min-w-0 flex-1 truncate">{label}</span>
</Link>
);
}
export function PlaygroundSidebar({ workspaceId }: PlaygroundSidebarProps) {
const pathname = usePathname();
const base = `/dashboard/${workspaceId}/playground`;
return (
<div className="relative flex h-full w-[240px] flex-col bg-panel text-sidebar-foreground overflow-hidden select-none">
<div className="flex h-12 shrink-0 items-center px-4">
<span className="text-sm font-semibold">API Playground</span>
</div>
<div className="flex flex-col gap-0.5 pt-1.5 pb-1.5 after:mx-3 after:mt-1.5 after:block after:h-px after:bg-border">
<PlaygroundNavLink
href={`${base}/runs`}
label="Runs"
icon={History}
isActive={pathname === `${base}/runs`}
/>
<PlaygroundNavLink
href={`${base}/api-keys`}
label="API Keys"
icon={KeyRound}
isActive={pathname === `${base}/api-keys`}
/>
</div>
<div className="flex-1 w-full min-h-0 overflow-y-auto overflow-x-hidden scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent pb-2">
{PLAYGROUND_PLATFORMS.map((platform) => (
<div key={platform.id} className="flex flex-col gap-0.5 pt-2">
<div className="flex items-center gap-2 pl-4 pr-2.5 py-1 text-xs font-medium text-muted-foreground">
<platform.icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{platform.label}</span>
</div>
{platform.verbs.map((verb) => {
const href = `${base}/${platform.id}/${verb.verb}`;
return (
<PlaygroundNavLink
key={verb.name}
href={href}
label={verb.label}
isActive={pathname === href}
indented
/>
);
})}
</div>
))}
</div>
<div className="shrink-0 py-1.5 before:mx-3 before:mb-1.5 before:block before:h-px before:bg-border">
<ConnectAgentDialog />
</div>
</div>
);
}

View file

@ -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,27 +46,14 @@ function ChatListSkeletonRows() {
);
}
function CollapsedInboxIcon({ item }: { item: NavItem }) {
const Icon = item.icon;
return (
<span className="relative flex h-3.5 w-3.5 items-center justify-center">
<Icon className="h-3.5 w-3.5" />
{typeof item.badge === "string" ? (
<span className="absolute right-0 top-0 flex min-w-3.5 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-medium leading-3 text-destructive-foreground">
{item.badge}
</span>
) : null}
</span>
);
}
interface SidebarProps {
workspace: Workspace | null;
isCollapsed?: boolean;
onToggleCollapse?: () => void;
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
onPlaygroundItemClick?: (item: NavItem) => void;
isPlaygroundSidebarOpen?: boolean;
chats: ChatItem[];
activeChatId?: number | null;
onNewChat: () => void;
@ -104,6 +91,8 @@ export function Sidebar({
onToggleCollapse,
navItems,
onNavItemClick,
onPlaygroundItemClick,
isPlaygroundSidebarOpen,
chats,
activeChatId,
onNewChat,
@ -138,10 +127,9 @@ export function Sidebar({
const [openDropdownChatId, setOpenDropdownChatId] = useState<number | null>(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 +146,6 @@ export function Sidebar({
() =>
navItems.filter(
(item) =>
item.url !== "#inbox" &&
!item.url.endsWith("/automations") &&
!item.url.endsWith("/artifacts") &&
!item.url.endsWith("/playground")
@ -218,7 +205,7 @@ export function Sidebar({
<div
className={cn(
"relative flex flex-col gap-0.5 pt-1.5 pb-0 after:absolute after:inset-x-3 after:bottom-0 after:h-px after:bg-border after:transition-opacity",
"relative flex flex-col gap-0.5 pt-1.5 pb-0 after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border after:transition-opacity",
isSidebarNavScrolled ? "after:opacity-100" : "after:opacity-0"
)}
>
@ -235,23 +222,6 @@ export function Sidebar({
onScroll={(event) => setIsSidebarNavScrolled(event.currentTarget.scrollTop > 0)}
>
<div className="flex flex-col gap-0.5 pt-0.5 pb-1.5">
{inboxItem && (
<SidebarButton
icon={inboxItem.icon}
label={inboxItem.title}
onClick={() => onNavItemClick?.(inboxItem)}
isCollapsed={isCollapsed}
isActive={inboxItem.isActive}
badge={inboxItem.badge}
collapsedIconNode={<CollapsedInboxIcon item={inboxItem} />}
tooltipContent={isCollapsed ? inboxItem.title : undefined}
buttonProps={
{
"data-joyride": "inbox-sidebar",
} as React.ButtonHTMLAttributes<HTMLButtonElement>
}
/>
)}
{automationsItem && (
<SidebarButton
icon={automationsItem.icon}
@ -276,9 +246,10 @@ export function Sidebar({
<SidebarButton
icon={playgroundItem.icon}
label={playgroundItem.title}
onClick={() => onNavItemClick?.(playgroundItem)}
onClick={() => (onPlaygroundItemClick ?? onNavItemClick)?.(playgroundItem)}
isCollapsed={isCollapsed}
isActive={playgroundItem.isActive}
isActive={isPlaygroundSidebarOpen ?? playgroundItem.isActive}
badge={<SidebarButtonBadge>New</SidebarButtonBadge>}
tooltipContent={isCollapsed ? playgroundItem.title : undefined}
/>
)}
@ -356,10 +327,16 @@ export function Sidebar({
/>
)}
{!isCollapsed && (
<div className="shrink-0 py-1.5">
<ConnectAgentDialog className="w-[calc(100%-1rem)]" />
</div>
)}
<SidebarUsageFooter
pageUsage={pageUsage}
isCollapsed={isCollapsed}
hasNavSectionAbove={footerNavItems.length > 0}
hasNavSectionAbove={footerNavItems.length > 0 || !isCollapsed}
onNavigate={onNavigate}
/>
@ -436,29 +413,25 @@ function SidebarUsageFooter({
return (
<div className={containerClass}>
<CreditBalanceDisplay />
<div className="space-y-0.5">
<div className="relative grid grid-cols-2 before:absolute before:inset-y-1 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-border">
<Link
href={`/dashboard/${workspaceId}/earn-credits`}
onClick={onNavigate}
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 transition-colors hover:bg-accent"
className="group relative z-10 mx-0.5 flex min-w-0 items-center justify-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
<Zap className="h-3 w-3 shrink-0" />
Earn credits
</span>
<Badge className="h-4 rounded px-1 text-[10px] font-semibold leading-none bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
<Zap className="h-3 w-3 shrink-0" />
<span className="truncate">Earn</span>
<SidebarButtonBadge className="h-4 px-1 text-[10px] bg-emerald-600 text-white hover:bg-emerald-600">
FREE
</Badge>
</SidebarButtonBadge>
</Link>
<Link
href={`/dashboard/${workspaceId}/buy-more`}
onClick={onNavigate}
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 transition-colors hover:bg-accent"
className="group relative z-10 mx-0.5 flex min-w-0 items-center justify-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
<CreditCard className="h-3 w-3 shrink-0" />
Buy credits
</span>
<CreditCard className="h-3 w-3 shrink-0" />
<span className="truncate">Buy</span>
</Link>
</div>
</div>

View file

@ -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 (
<Badge
variant="secondary"
className={cn(
"h-5 shrink-0 rounded-sm border-0 bg-popover-foreground/10 px-1.5 text-[11px] text-popover-foreground hover:bg-popover-foreground/10",
className
)}
>
{children}
</Badge>
);
}
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"
)}
>
<span className="block truncate">{label}</span>
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{label}</span>
{!isCollapsed && badge && typeof badge !== "string" ? badge : null}
{!isCollapsed && badge && typeof badge === "string" ? (
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium text-white">
{badge}
</span>
) : null}
</span>
</span>
</span>
{!isCollapsed && trailingContent}
{!isCollapsed && badge && typeof badge !== "string" ? badge : null}
{!isCollapsed && badge && typeof badge === "string" ? (
<span className="ml-1 inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
{badge}
</span>
) : null}
{collapsedOverlay && (
<span

View file

@ -1,115 +0,0 @@
"use client";
import { useSetAtom } from "jotai";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect } from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import { slideoutOpenedTickAtom } from "@/lib/layout-events";
interface SidebarSlideOutPanelProps {
open: boolean;
onOpenChange: (open: boolean) => 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 (
<AnimatePresence>
{open && (
<div className="absolute left-0 inset-y-0 z-30 w-full overflow-hidden pointer-events-none">
<motion.div
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className="h-full w-full bg-sidebar text-sidebar-foreground flex flex-col pointer-events-auto select-none"
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
>
{children}
</motion.div>
</div>
)}
</AnimatePresence>
);
}
return (
<AnimatePresence initial={false}>
{open && (
<>
{/* Blur backdrop covering the main content area (right of sidebar) */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute z-10 bg-black/30 backdrop-blur-sm rounded-xl"
style={{ top: -9, bottom: -9, left: "calc(100% + 1px)", width: "200vw" }}
onClick={() => onOpenChange(false)}
aria-hidden="true"
/>
{/* Panel extending from sidebar's right edge, flush with the wrapper border */}
<motion.div
initial={{ width: 0 }}
animate={{ width }}
exit={{ width: 0 }}
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className="absolute z-20 overflow-hidden"
style={{ left: "100%", top: -1, bottom: -1 }}
>
<div
style={{ width }}
className="h-full bg-sidebar text-sidebar-foreground flex flex-col select-none border shadow-xl"
role="dialog"
aria-label={ariaLabel}
>
{children}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}

Some files were not shown because too many files have changed in this diff Show more