Merge remote-tracking branch 'upstream/dev' into fixes-playground-ui

# Conflicts:
#	surfsense_web/lib/apis/base-api.service.ts
This commit is contained in:
CREDO23 2026-07-24 08:29:39 +02:00
commit 06c7e27c72
87 changed files with 2882 additions and 1673 deletions

View file

@ -392,6 +392,15 @@ STT_SERVICE=local/base
# OTEL_HTTP_PORT=4318 # OTEL_HTTP_PORT=4318
# OTEL_HEALTH_PORT=13133 # OTEL_HEALTH_PORT=13133
# PostHog product analytics (server-side). Opt-in like OTel: leave
# POSTHOG_API_KEY unset for zero telemetry. Passed to backend/worker/beat via
# env_file, so no compose changes are needed. Use the SAME project key as the
# frontend's NEXT_PUBLIC_POSTHOG_KEY so server events merge onto the persons the
# web app already identifies by user id.
# POSTHOG_API_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# POSTHOG_HOST=https://us.i.posthog.com
# POSTHOG_AI_PRIVACY_MODE=true # false ships LLM prompt/completion bodies to PostHog
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Advanced (optional) # Advanced (optional)
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------

View file

@ -555,6 +555,14 @@ LANGSMITH_PROJECT=surfsense
# OTEL_METRIC_EXPORT_INTERVAL=300000 # ms; 5 minutes # OTEL_METRIC_EXPORT_INTERVAL=300000 # ms; 5 minutes
# OTEL_SDK_DISABLED=true # emergency kill-switch # OTEL_SDK_DISABLED=true # emergency kill-switch
# Observability - PostHog product analytics (server-side)
# Opt-in like OTel: leave POSTHOG_API_KEY unset for zero telemetry. Use the
# SAME project key as the frontend's NEXT_PUBLIC_POSTHOG_KEY so server events
# merge onto the persons the web app already identifies by user id.
# POSTHOG_API_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# POSTHOG_HOST=https://us.i.posthog.com
# POSTHOG_AI_PRIVACY_MODE=true # false ships LLM prompt/completion bodies to PostHog
# Skills + subagents # Skills + subagents
# SURFSENSE_ENABLE_SKILLS=false # SURFSENSE_ENABLE_SKILLS=false
# SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS=false # SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS=false

View file

@ -51,6 +51,7 @@ from app.gateway.inbox_worker import (
start_gateway_inbox_worker, start_gateway_inbox_worker,
stop_gateway_inbox_worker, stop_gateway_inbox_worker,
) )
from app.observability import analytics as ph_analytics
from app.observability import metrics as ot_metrics from app.observability import metrics as ot_metrics
from app.observability.bootstrap import init_otel, shutdown_otel from app.observability.bootstrap import init_otel, shutdown_otel
from app.rate_limiter import get_real_client_ip, limiter from app.rate_limiter import get_real_client_ip, limiter
@ -714,6 +715,7 @@ async def lifespan(app: FastAPI):
await stop_gateway_inbox_worker() await stop_gateway_inbox_worker()
_stop_openrouter_background_refresh() _stop_openrouter_background_refresh()
await close_checkpointer() await close_checkpointer()
ph_analytics.shutdown()
shutdown_otel() shutdown_otel()
@ -820,6 +822,56 @@ class RequestPerfMiddleware(BaseHTTPMiddleware):
app.add_middleware(RequestPerfMiddleware) app.add_middleware(RequestPerfMiddleware)
# ---------------------------------------------------------------------------
# PAT / MCP API attribution middleware
# ---------------------------------------------------------------------------
# Emits a PostHog ``pat_api_request`` event for any request authenticated by a
# Personal Access Token, so "documents added via MCP", "searches via MCP" etc.
# are queryable without instrumenting each route. Relies on ``get_auth_context``
# stashing the resolved principal on ``request.state.auth_context``; requests
# that never resolve a PAT principal are silently skipped. No-op when PostHog
# is unconfigured.
class PatApiAnalyticsMiddleware(BaseHTTPMiddleware):
"""Capture PAT-authenticated API usage (incl. MCP) after each response."""
async def dispatch(
self, request: StarletteRequest, call_next: RequestResponseEndpoint
) -> StarletteResponse:
response = await call_next(request)
with contextlib.suppress(Exception):
ctx = getattr(request.state, "auth_context", None)
if (
ctx is not None
and ctx.method == "pat"
and ph_analytics.is_enabled()
):
# Use the route *template* (e.g. /documents/{id}) to keep the
# ``route`` property low-cardinality; fall back to the raw path.
route = request.scope.get("route")
route_path = getattr(route, "path", None) or request.url.path
client = (
"mcp"
if request.headers.get("X-SurfSense-Client") == "mcp"
else "pat_script"
)
ph_analytics.capture_for(
ctx,
"pat_api_request",
{
"route": route_path,
"method": request.method,
"status_code": response.status_code,
"client": client,
},
)
return response
app.add_middleware(PatApiAnalyticsMiddleware)
# Add SlowAPI middleware for automatic rate limiting # Add SlowAPI middleware for automatic rate limiting
# Uses Starlette BaseHTTPMiddleware (not the raw ASGI variant) to avoid # Uses Starlette BaseHTTPMiddleware (not the raw ASGI variant) to avoid
# corrupting StreamingResponse — SlowAPIASGIMiddleware re-sends # corrupting StreamingResponse — SlowAPIASGIMiddleware re-sends

View file

@ -15,11 +15,38 @@ from app.automations.schemas.definition.envelope import (
) )
from app.automations.schemas.definition.plan_step import PlanStep from app.automations.schemas.definition.plan_step import PlanStep
from app.automations.templating import build_run_context from app.automations.templating import build_run_context
from app.observability import analytics as ph_analytics
from . import repository from . import repository
from .step import execute_step from .step import execute_step
def _capture_run_outcome(run: AutomationRun, status: str) -> None:
"""Emit ``automation_run_completed`` — headless runs the frontend never sees.
No-op when PostHog is unconfigured or the owning user is unknown.
"""
automation = run.automation
creator_id = getattr(automation, "created_by_user_id", None)
if not creator_id:
return
workspace_id = getattr(automation, "workspace_id", None)
ph_analytics.capture(
"automation_run_completed",
distinct_id=str(creator_id),
properties={
"automation_id": run.automation_id,
"run_id": run.id,
"workspace_id": workspace_id,
"status": status,
"trigger_type": run.trigger.type.value if run.trigger else None,
},
groups={"workspace": str(workspace_id)}
if workspace_id is not None
else None,
)
async def execute_run(session: AsyncSession, run_id: int) -> None: async def execute_run(session: AsyncSession, run_id: int) -> None:
"""Load run ``run_id`` and execute its snapshot plan to a terminal state.""" """Load run ``run_id`` and execute its snapshot plan to a terminal state."""
run = await repository.load_run(session, run_id) run = await repository.load_run(session, run_id)
@ -41,6 +68,7 @@ async def execute_run(session: AsyncSession, run_id: int) -> None:
}, },
) )
await session.commit() await session.commit()
_capture_run_outcome(run, "failed")
return return
await repository.mark_running(session, run) await repository.mark_running(session, run)
@ -66,6 +94,7 @@ async def execute_run(session: AsyncSession, run_id: int) -> None:
await _run_on_failure(session, run, definition) await _run_on_failure(session, run, definition)
await repository.mark_failed(session, run, result.get("error")) await repository.mark_failed(session, run, result.get("error"))
await session.commit() await session.commit()
_capture_run_outcome(run, "failed")
return return
if result["status"] == "succeeded": if result["status"] == "succeeded":
@ -73,6 +102,7 @@ async def execute_run(session: AsyncSession, run_id: int) -> None:
await repository.mark_succeeded(session, run) await repository.mark_succeeded(session, run)
await session.commit() await session.commit()
_capture_run_outcome(run, "succeeded")
async def _run_on_failure( async def _run_on_failure(

View file

@ -29,6 +29,7 @@ from app.automations.services.model_policy import (
from app.automations.triggers import get_trigger from app.automations.triggers import get_trigger
from app.automations.triggers.builtin.schedule import compute_next_fire_at from app.automations.triggers.builtin.schedule import compute_next_fire_at
from app.db import Permission, Workspace, get_async_session from app.db import Permission, Workspace, get_async_session
from app.observability import analytics as ph_analytics
from app.users import get_auth_context from app.users import get_auth_context
from app.utils.rbac import check_permission from app.utils.rbac import check_permission
@ -75,6 +76,18 @@ class AutomationService:
self.session.add(automation) self.session.add(automation)
await self.session.commit() await self.session.commit()
# Authoritative creation (migrated from automations-mutation.atoms.ts).
ph_analytics.capture_for(
self.auth,
"automation_created",
{
"automation_id": automation.id,
"workspace_id": automation.workspace_id,
"trigger_count": len(payload.triggers),
},
groups={"workspace": str(automation.workspace_id)},
)
return await self._get_with_triggers_or_raise(automation.id) return await self._get_with_triggers_or_raise(automation.id)
async def list( async def list(
@ -150,6 +163,31 @@ class AutomationService:
automation.version += 1 automation.version += 1
await self.session.commit() await self.session.commit()
# Migrated from automations-mutation.atoms.ts: a status-only change is a
# distinct event; other field edits are ``automation_updated``.
if "status" in data:
ph_analytics.capture_for(
self.auth,
"automation_status_changed",
{
"automation_id": automation.id,
"workspace_id": automation.workspace_id,
"next_status": str(data["status"]),
},
groups={"workspace": str(automation.workspace_id)},
)
if any(k in data for k in ("name", "description", "definition")):
ph_analytics.capture_for(
self.auth,
"automation_updated",
{
"automation_id": automation.id,
"workspace_id": automation.workspace_id,
"has_definition_change": "definition" in data,
},
groups={"workspace": str(automation.workspace_id)},
)
return await self._get_with_triggers_or_raise(automation_id) return await self._get_with_triggers_or_raise(automation_id)
async def delete(self, automation_id: int) -> None: async def delete(self, automation_id: int) -> None:
@ -158,9 +196,18 @@ class AutomationService:
await self._authorize( await self._authorize(
automation.workspace_id, Permission.AUTOMATIONS_DELETE.value automation.workspace_id, Permission.AUTOMATIONS_DELETE.value
) )
workspace_id = automation.workspace_id
await self.session.delete(automation) await self.session.delete(automation)
await self.session.commit() await self.session.commit()
# Authoritative deletion (migrated from automations-mutation.atoms.ts).
ph_analytics.capture_for(
self.auth,
"automation_deleted",
{"automation_id": automation_id, "workspace_id": workspace_id},
groups={"workspace": str(workspace_id)},
)
async def _get_or_raise(self, automation_id: int) -> Automation: async def _get_or_raise(self, automation_id: int) -> Automation:
automation = await self.session.get(Automation, automation_id) automation = await self.session.get(Automation, automation_id)
if automation is None: if automation is None:

View file

@ -16,6 +16,7 @@ from app.automations.schemas.api import TriggerCreate, TriggerUpdate
from app.automations.triggers import get_trigger from app.automations.triggers import get_trigger
from app.automations.triggers.builtin.schedule import compute_next_fire_at from app.automations.triggers.builtin.schedule import compute_next_fire_at
from app.db import Permission, get_async_session from app.db import Permission, get_async_session
from app.observability import analytics as ph_analytics
from app.users import get_auth_context from app.users import get_auth_context
from app.utils.rbac import check_permission from app.utils.rbac import check_permission
@ -48,6 +49,18 @@ class TriggerService:
self.session.add(trigger) self.session.add(trigger)
await self.session.commit() await self.session.commit()
await self.session.refresh(trigger) await self.session.refresh(trigger)
# Migrated from automations-mutation.atoms.ts.
ph_analytics.capture_for(
self.auth,
"automation_trigger_added",
{
"automation_id": automation_id,
"trigger_id": trigger.id,
"trigger_type": getattr(trigger.type, "value", str(trigger.type)),
"enabled": trigger.enabled,
},
)
return trigger return trigger
async def update( async def update(
@ -82,6 +95,26 @@ class TriggerService:
await self.session.commit() await self.session.commit()
await self.session.refresh(trigger) await self.session.refresh(trigger)
# Migrated from automations-mutation.atoms.ts. ``change`` mirrors the
# frontend's coarse categorisation.
_change = (
"enabled"
if "enabled" in data and "params" not in data
else "params"
if "params" in data
else "other"
)
ph_analytics.capture_for(
self.auth,
"automation_trigger_updated",
{
"automation_id": automation_id,
"trigger_id": trigger_id,
"change": _change,
"enabled": trigger.enabled,
},
)
return trigger return trigger
async def remove(self, *, automation_id: int, trigger_id: int) -> None: async def remove(self, *, automation_id: int, trigger_id: int) -> None:
@ -92,6 +125,13 @@ class TriggerService:
await self.session.delete(trigger) await self.session.delete(trigger)
await self.session.commit() await self.session.commit()
# Migrated from automations-mutation.atoms.ts.
ph_analytics.capture_for(
self.auth,
"automation_trigger_removed",
{"automation_id": automation_id, "trigger_id": trigger_id},
)
async def _authorize_automation( async def _authorize_automation(
self, automation_id: int, permission: str self, automation_id: int, permission: str
) -> Automation: ) -> Automation:

View file

@ -1,4 +1,4 @@
"""Scraper capability registry — typed, stateless verbs. See plans/backend/04-capabilities.md.""" """Scraper capability registry — typed, stateless verbs."""
from __future__ import annotations from __future__ import annotations

View file

@ -45,6 +45,7 @@ from app.capabilities.core.store import all_capabilities
from app.capabilities.core.types import Capability, CapabilityContext from app.capabilities.core.types import Capability, CapabilityContext
from app.db import Run, async_session_maker, get_async_session from app.db import Run, async_session_maker, get_async_session
from app.exceptions import ExternalServiceError, SurfSenseError from app.exceptions import ExternalServiceError, SurfSenseError
from app.observability import analytics as ph_analytics
from app.services.web_crawl_credit_service import InsufficientCreditsError from app.services.web_crawl_credit_service import InsufficientCreditsError
from app.users import get_auth_context from app.users import get_auth_context
from app.utils.rbac import check_workspace_access from app.utils.rbac import check_workspace_access
@ -107,6 +108,41 @@ def _origin_for(auth: AuthContext) -> str:
return "ui" if getattr(auth, "method", None) == "session" else "api" return "ui" if getattr(auth, "method", None) == "session" else "api"
def _capture_scraper_run(
*,
capability: str,
status: str,
user_id,
origin: str,
duration_ms: int | None = None,
item_count: int | None = None,
cost_micros: int | None = None,
) -> None:
"""Emit ``scraper_run_completed`` — scrapers are the highest-value MCP surface.
Covers both sync and async runs; the async background task never flows
through the PAT middleware, so this is the only place its outcome is
captured. No-op when PostHog is unconfigured.
"""
if not ph_analytics.is_enabled() or not user_id:
return
platform, _, verb = capability.partition(".")
ph_analytics.capture(
"scraper_run_completed",
distinct_id=str(user_id),
properties={
"platform": platform,
"verb": verb,
"capability": capability,
"status": status,
"origin": origin,
"duration_ms": duration_ms,
"item_count": item_count,
"cost_micros": cost_micros,
},
)
def _now_ms() -> int: def _now_ms() -> int:
return int(time.time() * 1000) return int(time.time() * 1000)
@ -185,6 +221,8 @@ async def _execute_async_run(
unit, unit,
executor, executor,
payload, payload,
user_id=None,
origin: str = "api",
) -> None: ) -> None:
"""Run a scrape in the background: stream progress, charge, finalize the row. """Run a scrape in the background: stream progress, charge, finalize the row.
@ -218,6 +256,13 @@ async def _execute_async_run(
progress=reporter.coarse, progress=reporter.coarse,
) )
_publish_finished(run_id, "error", error=str(exc)) _publish_finished(run_id, "error", error=str(exc))
_capture_scraper_run(
capability=capability,
status="error",
user_id=user_id,
origin=origin,
duration_ms=int((time.perf_counter() - started) * 1000),
)
return return
except Exception: except Exception:
logger.exception("async run %s failed with an upstream error", run_id) logger.exception("async run %s failed with an upstream error", run_id)
@ -229,6 +274,13 @@ async def _execute_async_run(
progress=reporter.coarse, progress=reporter.coarse,
) )
_publish_finished(run_id, "error", error="upstream error") _publish_finished(run_id, "error", error="upstream error")
_capture_scraper_run(
capability=capability,
status="error",
user_id=user_id,
origin=origin,
duration_ms=int((time.perf_counter() - started) * 1000),
)
return return
duration_ms = int((time.perf_counter() - started) * 1000) duration_ms = int((time.perf_counter() - started) * 1000)
@ -252,6 +304,15 @@ async def _execute_async_run(
progress=reporter.coarse, progress=reporter.coarse,
) )
_publish_finished(run_id, "success", item_count=serialized.item_count) _publish_finished(run_id, "success", item_count=serialized.item_count)
_capture_scraper_run(
capability=capability,
status="success",
user_id=user_id,
origin=origin,
duration_ms=duration_ms,
item_count=serialized.item_count,
cost_micros=cost_micros,
)
async def _finalize_async( async def _finalize_async(
@ -358,6 +419,8 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
unit=unit, unit=unit,
executor=executor, executor=executor,
payload=payload, payload=payload,
user_id=user_id,
origin=origin,
) )
) )
run_event_bus.register_task(run_id, task) run_event_bus.register_task(run_id, task)
@ -373,6 +436,7 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
try: try:
output = await executor(payload) output = await executor(payload)
except (SurfSenseError, HTTPException) as exc: except (SurfSenseError, HTTPException) as exc:
_sync_err_duration = int((time.perf_counter() - started) * 1000)
await _record_rest_run( await _record_rest_run(
workspace_id=workspace_id, workspace_id=workspace_id,
capability=name, capability=name,
@ -381,11 +445,19 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
input=input_dump, input=input_dump,
user_id=user_id, user_id=user_id,
error=str(exc), error=str(exc),
duration_ms=int((time.perf_counter() - started) * 1000), duration_ms=_sync_err_duration,
progress=reporter.coarse, progress=reporter.coarse,
) )
_capture_scraper_run(
capability=name,
status="error",
user_id=user_id,
origin=origin,
duration_ms=_sync_err_duration,
)
raise raise
except Exception as exc: except Exception as exc:
_sync_err_duration = int((time.perf_counter() - started) * 1000)
await _record_rest_run( await _record_rest_run(
workspace_id=workspace_id, workspace_id=workspace_id,
capability=name, capability=name,
@ -394,9 +466,16 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
input=input_dump, input=input_dump,
user_id=user_id, user_id=user_id,
error=str(exc), error=str(exc),
duration_ms=int((time.perf_counter() - started) * 1000), duration_ms=_sync_err_duration,
progress=reporter.coarse, progress=reporter.coarse,
) )
_capture_scraper_run(
capability=name,
status="error",
user_id=user_id,
origin=origin,
duration_ms=_sync_err_duration,
)
raise ExternalServiceError( raise ExternalServiceError(
f"The '{name}' capability failed due to an upstream error.", f"The '{name}' capability failed due to an upstream error.",
code="CAPABILITY_UPSTREAM_ERROR", code="CAPABILITY_UPSTREAM_ERROR",
@ -418,6 +497,15 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
cost_micros=cost_micros, cost_micros=cost_micros,
progress=reporter.coarse, progress=reporter.coarse,
) )
_capture_scraper_run(
capability=name,
status="success",
user_id=user_id,
origin=origin,
duration_ms=duration_ms,
item_count=serialized.item_count,
cost_micros=cost_micros,
)
if run_id is not None: if run_id is not None:
response.headers["X-Run-Id"] = f"run_{run_id}" response.headers["X-Run-Id"] = f"run_{run_id}"
return output return output

View file

@ -10,6 +10,7 @@ from celery.signals import (
task_postrun, task_postrun,
task_prerun, task_prerun,
worker_process_init, worker_process_init,
worker_process_shutdown,
) )
from dotenv import load_dotenv from dotenv import load_dotenv
@ -123,6 +124,18 @@ def init_worker(**kwargs):
initialize_image_gen_router() initialize_image_gen_router()
@worker_process_shutdown.connect
def shutdown_worker(**kwargs):
"""Flush queued PostHog events before a Celery worker process exits.
The analytics client init is lazy (fork-safe), so there is nothing to
start here only a flush to avoid dropping events captured by tasks.
"""
from app.observability import analytics as ph_analytics
ph_analytics.shutdown()
# Celery configuration, sourced from the central Config singleton # Celery configuration, sourced from the central Config singleton
CELERY_BROKER_URL = config.CELERY_BROKER_URL CELERY_BROKER_URL = config.CELERY_BROKER_URL
CELERY_RESULT_BACKEND = config.CELERY_RESULT_BACKEND CELERY_RESULT_BACKEND = config.CELERY_RESULT_BACKEND

View file

@ -1179,6 +1179,19 @@ class Config:
os.getenv("CRAWL_HEADED_XVFB_ENABLED", "FALSE").upper() == "TRUE" os.getenv("CRAWL_HEADED_XVFB_ENABLED", "FALSE").upper() == "TRUE"
) )
# PostHog server-side product analytics (opt-in, mirrors the OTel pattern:
# no key set => the analytics wrapper is a silent no-op). Use the SAME
# project key as the frontend's NEXT_PUBLIC_POSTHOG_KEY so server events
# merge onto the persons the web app already identifies by user id.
POSTHOG_API_KEY = os.getenv("POSTHOG_API_KEY")
POSTHOG_HOST = os.getenv("POSTHOG_HOST", "https://us.i.posthog.com")
# When true (default), the LLM-analytics LangChain handler suppresses
# prompt/completion bodies ($ai_input / $ai_output_choices) and captures
# only metrics — chat content includes users' private documents.
POSTHOG_AI_PRIVACY_MODE = (
os.getenv("POSTHOG_AI_PRIVACY_MODE", "TRUE").upper() == "TRUE"
)
# Litellm TTS Configuration # Litellm TTS Configuration
TTS_SERVICE = os.getenv("TTS_SERVICE") TTS_SERVICE = os.getenv("TTS_SERVICE")
TTS_SERVICE_API_BASE = os.getenv("TTS_SERVICE_API_BASE") TTS_SERVICE_API_BASE = os.getenv("TTS_SERVICE_API_BASE")

View file

@ -6,4 +6,4 @@ wrapper is a no-op when OTEL is not configured, so importing it from
performance-critical paths is safe. performance-critical paths is safe.
""" """
__all__ = ["bootstrap", "metrics", "otel"] __all__ = ["analytics", "bootstrap", "metrics", "otel"]

View file

@ -0,0 +1,202 @@
"""Server-side PostHog product analytics for SurfSense.
Opt-in, mirroring the OpenTelemetry bootstrap contract: when
``POSTHOG_API_KEY`` is unset every function here is a silent no-op, so it is
safe to call from hot paths (including async request handlers) and from
self-hosted installs that never configure telemetry.
Design notes:
- The underlying ``posthog`` client enqueues events onto a background
consumer thread, so ``capture()`` is a non-blocking queue append; the only
network I/O happens off-thread. ``shutdown()`` flushes and joins that thread
and MUST run before a process exits or queued events are lost.
- The client is created lazily on first use, never at import time. This keeps
it fork-safe under Celery's prefork pool: a client (and its consumer thread)
created in the parent would not survive ``fork()``, so each worker process
builds its own on first capture.
- ``distinct_id`` is always ``str(user.id)`` so server events merge onto the
same PostHog persons the web frontend identifies (see
``surfsense_web/components/providers/PostHogIdentify.tsx``).
- Every event passes ``disable_geoip=True``; without it PostHog would resolve
the *server's* IP and overwrite each person's real (client-derived) location.
"""
from __future__ import annotations
import logging
import threading
from typing import TYPE_CHECKING, Any
from app.config import config
if TYPE_CHECKING:
from app.auth.context import AuthContext
logger = logging.getLogger(__name__)
_client: Any | None = None
_init_attempted = False
_lock = threading.Lock()
# Stamped on every backend event so client-observed (frontend) and
# server-truth events are always distinguishable in PostHog.
_SOURCE = "backend"
def _get_client() -> Any | None:
"""Return the process-local PostHog client, or ``None`` when disabled.
Lazy + fork-safe: built on first use inside whichever process (web worker
or Celery worker) calls it, never at import time.
"""
global _client, _init_attempted
if _init_attempted:
return _client
with _lock:
if _init_attempted:
return _client
_init_attempted = True
api_key = config.POSTHOG_API_KEY
if not api_key:
# ponytail: opt-in like OTel — no key means telemetry is off, not
# a misconfiguration. Stay silent so self-hosters see no noise.
return None
try:
from posthog import Posthog
_client = Posthog(
project_api_key=api_key,
host=config.POSTHOG_HOST,
)
except Exception:
logger.warning("PostHog analytics init failed; disabling", exc_info=True)
_client = None
return _client
def is_enabled() -> bool:
"""True when a PostHog client is configured and available."""
return _get_client() is not None
def get_client() -> Any | None:
"""Raw PostHog client for integrations that need it (e.g. the LLM handler)."""
return _get_client()
def _client_label(auth: AuthContext) -> str:
"""Best-effort ``client`` property derived from the auth principal.
``session`` can't be split into web vs desktop from auth alone, so callers
that know better may override ``client`` in ``properties``.
"""
if auth.method == "system":
return auth.source or "system"
if auth.method == "pat":
return "pat"
return "web"
def capture(
event: str,
*,
distinct_id: str,
properties: dict[str, Any] | None = None,
groups: dict[str, str] | None = None,
) -> None:
"""Capture a product event. No-op (and never raises) when disabled.
Wrapped in try/except like the frontend ``safeCapture`` analytics must
never break a request. ``posthog`` v6 signature is ``capture(event,
distinct_id=..., properties=...)`` (event first, distinct_id a kwarg).
"""
client = _get_client()
if client is None:
return
try:
props = {"source": _SOURCE, **(properties or {})}
client.capture(
event,
distinct_id=distinct_id,
properties=props,
groups=groups,
disable_geoip=True,
)
except Exception:
logger.debug("PostHog capture failed for %s", event, exc_info=True)
def capture_for(
auth: AuthContext,
event: str,
properties: dict[str, Any] | None = None,
groups: dict[str, str] | None = None,
) -> None:
"""Capture an event attributed to an ``AuthContext`` principal.
Derives ``distinct_id`` from the user id and stamps ``auth_method`` and a
best-effort ``client`` so events are attributable to their surface
(web/desktop/pat/gateway/automation).
"""
if _get_client() is None:
return
props = {
"auth_method": auth.method,
"client": _client_label(auth),
**(properties or {}),
}
capture(
event,
distinct_id=str(auth.user.id),
properties=props,
groups=groups,
)
def group_identify(
group_type: str,
group_key: str,
properties: dict[str, Any] | None = None,
) -> None:
"""Upsert group properties (e.g. per-workspace metadata). No-op when disabled."""
client = _get_client()
if client is None:
return
try:
client.group_identify(
group_type=group_type,
group_key=group_key,
properties=properties or {},
)
except Exception:
logger.debug("PostHog group_identify failed for %s", group_type, exc_info=True)
def shutdown() -> None:
"""Flush queued events and stop the consumer thread. Safe to call always."""
global _client
client = _client
if client is None:
return
try:
client.shutdown()
except Exception:
logger.debug("PostHog shutdown failed", exc_info=True)
__all__ = [
"capture",
"capture_for",
"get_client",
"group_identify",
"is_enabled",
"shutdown",
]

View file

@ -11,7 +11,10 @@ import logging
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from sqlalchemy import select
from app.celery_app import celery_app from app.celery_app import celery_app
from app.observability import analytics as ph_analytics
from app.podcasts.persistence import PodcastRepository from app.podcasts.persistence import PodcastRepository
from app.podcasts.rendering import PodcastRenderer from app.podcasts.rendering import PodcastRenderer
from app.podcasts.service import ( from app.podcasts.service import (
@ -76,6 +79,30 @@ async def _render_audio(podcast_id: int) -> dict:
podcast, storage_backend=backend_name, storage_key=key podcast, storage_backend=backend_name, storage_key=key
) )
await session.commit() await session.commit()
# Credit-consuming deliverable; the frontend never confirms the
# render finished. Owner (workspace.user_id) resolved lazily so
# disabled installs pay nothing for the extra query.
if ph_analytics.is_enabled():
# Local import: app.db <-> app.podcasts.persistence have a
# module-init cycle; deferring keeps this task importable.
from app.db import Workspace
owner_id = await session.scalar(
select(Workspace.user_id).where(
Workspace.id == podcast.workspace_id
)
)
if owner_id:
ph_analytics.capture(
"podcast_generated",
distinct_id=str(owner_id),
properties={
"workspace_id": podcast.workspace_id,
"podcast_id": podcast_id,
},
groups={"workspace": str(podcast.workspace_id)},
)
except InvalidTransitionError: except InvalidTransitionError:
# A user back-out won the race (e.g. the regeneration was # A user back-out won the race (e.g. the regeneration was
# reverted): drop the stale render and leave the row alone. # reverted): drop the stale render and leave the row alone.

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import logging import logging
import secrets import secrets
import uuid import uuid
@ -13,6 +14,10 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.config import config from app.config import config
from app.observability import analytics as ph_analytics
from app.tasks.chat.streaming.flows.shared.analytics import (
build_llm_callback_handler,
)
from app.etl_pipeline.file_classifier import ( from app.etl_pipeline.file_classifier import (
DIRECT_CONVERT_EXTENSIONS, DIRECT_CONVERT_EXTENSIONS,
PLAINTEXT_EXTENSIONS, PLAINTEXT_EXTENSIONS,
@ -354,6 +359,7 @@ async def stream_anonymous_chat(
accumulator = start_turn() accumulator = start_turn()
streaming_service = VercelStreamingService() streaming_service = VercelStreamingService()
anon_outcome = "success"
try: try:
async with shielded_async_session(): async with shielded_async_session():
@ -394,6 +400,21 @@ async def stream_anonymous_chat(
"recursion_limit": 40, "recursion_limit": 40,
} }
# PostHog LLM analytics for the free tier — model spend per
# model is the highest-value cost insight. distinct_id is the
# anon session id (not joined to any registered person).
_anon_llm_handler = build_llm_callback_handler(
distinct_id=session_id,
trace_id=anon_thread_id,
properties={
"client": "anonymous",
"model_slug": body.model_slug,
"$ai_session_id": session_id,
},
)
if _anon_llm_handler is not None:
langgraph_config["callbacks"] = [_anon_llm_handler]
yield streaming_service.format_message_start() yield streaming_service.format_message_start()
yield streaming_service.format_start_step() yield streaming_service.format_start_step()
@ -465,6 +486,7 @@ async def stream_anonymous_chat(
except Exception as e: except Exception as e:
logger.exception("Anonymous chat stream error") logger.exception("Anonymous chat stream error")
anon_outcome = "error"
await TokenQuotaService.anon_release(session_key, ip_key, request_id) await TokenQuotaService.anon_release(session_key, ip_key, request_id)
_, error_code, _, _, user_message, extra = classify_stream_exception( _, error_code, _, _, user_message, extra = classify_stream_exception(
e, e,
@ -479,6 +501,26 @@ async def stream_anonymous_chat(
finally: finally:
await TokenQuotaService.anon_release_stream_slot(client_ip) await TokenQuotaService.anon_release_stream_slot(client_ip)
# Server-truth free-tier volume/model/outcome/token-burn. distinct_id
# is the anon session id — deliberately NOT joined to the frontend's
# PostHog anonymous id (the point is server truth, not funnel merge).
with contextlib.suppress(Exception):
if ph_analytics.is_enabled():
ph_analytics.capture(
"anon_chat_turn_completed",
distinct_id=session_id,
properties={
"client": "anonymous",
"outcome": anon_outcome,
"model_slug": body.model_slug,
"model_name": model_cfg.get("model_name"),
"total_tokens": accumulator.grand_total,
"prompt_tokens": accumulator.total_prompt_tokens,
"completion_tokens": accumulator.total_completion_tokens,
"cost_micros": accumulator.total_cost_micros,
},
)
return StreamingResponse( return StreamingResponse(
_generate(), _generate(),
media_type="text/event-stream", media_type="text/event-stream",

View file

@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.auth.context import AuthContext from app.auth.context import AuthContext
from app.observability import analytics as ph_analytics
from app.config import config from app.config import config
from app.db import ( from app.db import (
ImageGeneration, ImageGeneration,
@ -327,6 +328,20 @@ async def create_image_generation(
await session.commit() await session.commit()
await session.refresh(db_image_gen) await session.refresh(db_image_gen)
# Credit-consuming deliverable; the frontend never confirms
# completion. ``error_message`` set => provider call failed.
ph_analytics.capture_for(
auth,
"image_generated",
{
"workspace_id": data.workspace_id,
"model": data.model,
"n": data.n,
"status": "error" if db_image_gen.error_message else "success",
},
groups={"workspace": str(data.workspace_id)},
)
return db_image_gen return db_image_gen
except HTTPException: except HTTPException:

View file

@ -15,6 +15,7 @@ from app.db import (
UserIncentiveTask, UserIncentiveTask,
get_async_session, get_async_session,
) )
from app.observability import analytics as ph_analytics
from app.schemas.incentive_tasks import ( from app.schemas.incentive_tasks import (
CompleteTaskResponse, CompleteTaskResponse,
IncentiveTaskInfo, IncentiveTaskInfo,
@ -125,6 +126,20 @@ async def complete_task(
await session.commit() await session.commit()
await session.refresh(user) await session.refresh(user)
# Authoritative reward grant (migrated from earn-credits-content.tsx).
# Placed after the already-completed early-return so retries never
# double-count.
ph_analytics.capture_for(
auth,
"incentive_task_completed",
{
"task_type": task_type.value
if hasattr(task_type, "value")
else str(task_type),
"credit_micros_rewarded": credit_micros_reward,
},
)
return CompleteTaskResponse( return CompleteTaskResponse(
success=True, success=True,
message=f"Task completed! You earned ${credit_micros_reward / 1_000_000:.2f} of credit.", message=f"Task completed! You earned ${credit_micros_reward / 1_000_000:.2f} of credit.",

View file

@ -51,6 +51,7 @@ from app.db import (
get_async_session, get_async_session,
shielded_async_session, shielded_async_session,
) )
from app.observability import analytics as ph_analytics
from app.schemas.new_chat import ( from app.schemas.new_chat import (
AgentToolInfo, AgentToolInfo,
CancelActiveTurnResponse, CancelActiveTurnResponse,
@ -823,6 +824,15 @@ async def create_thread(
session.add(db_thread) session.add(db_thread)
await session.commit() await session.commit()
await session.refresh(db_thread) await session.refresh(db_thread)
# Authoritative thread creation (migrated from stream-engine/engine.ts).
# get_auth_context => covers PAT/MCP callers the frontend never sees.
ph_analytics.capture_for(
auth,
"chat_created",
{"workspace_id": db_thread.workspace_id, "chat_id": db_thread.id},
groups={"workspace": str(db_thread.workspace_id)},
)
return db_thread return db_thread
except HTTPException: except HTTPException:

View file

@ -8,6 +8,7 @@ from sqlalchemy.future import select
from app.auth.context import AuthContext from app.auth.context import AuthContext
from app.config import config from app.config import config
from app.db import PersonalAccessToken, get_async_session from app.db import PersonalAccessToken, get_async_session
from app.observability import analytics as ph_analytics
from app.schemas.pat import PATCreate, PATCreated, PATRead from app.schemas.pat import PATCreate, PATCreated, PATRead
from app.users import require_session_context from app.users import require_session_context
from app.utils.pat import generate_pat, hash_pat, token_prefix from app.utils.pat import generate_pat, hash_pat, token_prefix
@ -57,6 +58,13 @@ async def create_personal_access_token(
await session.commit() await session.commit()
await session.refresh(pat) await session.refresh(pat)
# Leading indicator of MCP / programmatic-API adoption.
ph_analytics.capture_for(
auth,
"pat_created",
{"pat_id": pat.id, "has_expiry": pat.expires_at is not None},
)
return PATCreated( return PATCreated(
id=pat.id, id=pat.id,
label=pat.label, label=pat.label,
@ -102,3 +110,5 @@ async def delete_personal_access_token(
) )
) )
await session.commit() await session.commit()
ph_analytics.capture_for(auth, "pat_revoked", {"pat_id": pat_id})

View file

@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext from app.auth.context import AuthContext
from app.db import get_async_session from app.db import get_async_session
from app.observability import analytics as ph_analytics
from app.schemas.new_chat import ( from app.schemas.new_chat import (
CloneResponse, CloneResponse,
PublicChatResponse, PublicChatResponse,
@ -56,7 +57,20 @@ async def clone_public_chat(
Creates thread and copies messages. Creates thread and copies messages.
Requires authentication. Requires authentication.
""" """
return await clone_from_snapshot(session, share_token, user) result = await clone_from_snapshot(session, share_token, user)
# Share-link conversion — only observable server-side.
ph_analytics.capture_for(
auth,
"public_chat_cloned",
{
"workspace_id": result.workspace_id,
"chat_id": result.thread_id,
},
groups={"workspace": str(result.workspace_id)},
)
return result
@router.get("/{share_token}/podcasts/{podcast_id}") @router.get("/{share_token}/podcasts/{podcast_id}")

View file

@ -28,6 +28,7 @@ from app.db import (
WorkspaceRole, WorkspaceRole,
get_async_session, get_async_session,
) )
from app.observability import analytics as ph_analytics
from app.schemas import ( from app.schemas import (
InviteAcceptRequest, InviteAcceptRequest,
InviteAcceptResponse, InviteAcceptResponse,
@ -782,6 +783,19 @@ async def create_invite(
) )
db_invite = result.scalars().first() db_invite = result.scalars().first()
# Authoritative invite creation (migrated from team-content.tsx).
ph_analytics.capture_for(
auth,
"workspace_invite_sent",
{
"workspace_id": workspace_id,
"role_name": db_invite.role.name if db_invite.role else None,
"has_expiry": db_invite.expires_at is not None,
"has_max_uses": db_invite.max_uses is not None,
},
groups={"workspace": str(workspace_id)},
)
return db_invite return db_invite
except HTTPException: except HTTPException:
@ -1091,6 +1105,16 @@ async def accept_invite(
role_name = invite.role.name if invite.role else "Default" role_name = invite.role.name if invite.role else "Default"
workspace_name = invite.workspace.name if invite.workspace else "" workspace_name = invite.workspace.name if invite.workspace else ""
# Authoritative join (migrated from app/invite/[invite_code]/page.tsx,
# which fired both events). workspace_name dropped — user content.
for _evt in ("workspace_invite_accepted", "workspace_user_added"):
ph_analytics.capture_for(
auth,
_evt,
{"workspace_id": invite.workspace_id, "role_name": role_name},
groups={"workspace": str(invite.workspace_id)},
)
return InviteAcceptResponse( return InviteAcceptResponse(
message="Successfully joined the workspace", message="Successfully joined the workspace",
workspace_id=invite.workspace_id, workspace_id=invite.workspace_id,

View file

@ -29,6 +29,7 @@ import redis
from dateutil.parser import isoparse from dateutil.parser import isoparse
from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import event as sa_event
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
@ -44,6 +45,7 @@ from app.db import (
get_async_session, get_async_session,
) )
from app.notifications.service import NotificationService from app.notifications.service import NotificationService
from app.observability import analytics as ph_analytics
from app.observability import metrics as ot_metrics, otel as ot from app.observability import metrics as ot_metrics, otel as ot
from app.schemas import ( from app.schemas import (
GoogleDriveIndexRequest, GoogleDriveIndexRequest,
@ -135,6 +137,45 @@ def _get_heartbeat_key(notification_id: int) -> str:
router = APIRouter() router = APIRouter()
def _connector_type_value(connector_type) -> str:
"""Low-cardinality connector_type string for analytics (enum -> its value)."""
return getattr(connector_type, "value", None) or str(connector_type)
def _emit_connector_connected(_mapper, _connection, target) -> None:
"""SQLAlchemy after_insert hook: one guard for every connector-creation path.
Fires ``connector_connected`` for the form route here AND all 15 OAuth
callback routes (each builds ``SearchSourceConnector(...)`` inline with no
shared helper), so we don't instrument 16 call sites. ``distinct_id`` comes
off the row itself (``user_id``) the same person the web app identifies.
ponytail: fires during flush, so a post-flush rollback would over-count
(rare; connector creation commits immediately after add). No-op without a
PostHog key. Upgrade path: move to an after_commit collector if over-count
ever shows up in the data.
"""
if not ph_analytics.is_enabled():
return
user_id = getattr(target, "user_id", None)
if not user_id:
return
workspace_id = getattr(target, "workspace_id", None)
ph_analytics.capture(
"connector_connected",
distinct_id=str(user_id),
properties={
"workspace_id": workspace_id,
"connector_id": target.id,
"connector_type": _connector_type_value(target.connector_type),
},
groups={"workspace": str(workspace_id)} if workspace_id is not None else None,
)
sa_event.listen(SearchSourceConnector, "after_insert", _emit_connector_connected)
# Use Pydantic's BaseModel here # Use Pydantic's BaseModel here
class GitHubPATRequest(BaseModel): class GitHubPATRequest(BaseModel):
github_pat: str = Field(..., description="GitHub Personal Access Token") github_pat: str = Field(..., description="GitHub Personal Access Token")
@ -244,6 +285,10 @@ async def create_search_source_connector(
await session.commit() await session.commit()
await session.refresh(db_connector) await session.refresh(db_connector)
# ``connector_connected`` is emitted by the after_insert listener below,
# so it fires once for this form route AND all 15 OAuth callback routes
# without instrumenting each — see _emit_connector_connected.
# Create periodic schedule if periodic indexing is enabled # Create periodic schedule if periodic indexing is enabled
if ( if (
db_connector.periodic_indexing_enabled db_connector.periodic_indexing_enabled
@ -679,10 +724,23 @@ async def delete_search_source_connector(
# Delete the connector record # Delete the connector record
workspace_id = db_connector.workspace_id workspace_id = db_connector.workspace_id
deleted_connector_type = db_connector.connector_type
is_mcp = db_connector.connector_type == SearchSourceConnectorType.MCP_CONNECTOR is_mcp = db_connector.connector_type == SearchSourceConnectorType.MCP_CONNECTOR
await session.delete(db_connector) await session.delete(db_connector)
await session.commit() await session.commit()
# Authoritative deletion (migrated from use-connector-dialog.ts).
ph_analytics.capture_for(
auth,
"connector_deleted",
{
"workspace_id": workspace_id,
"connector_id": connector_id,
"connector_type": _connector_type_value(deleted_connector_type),
},
groups={"workspace": str(workspace_id)},
)
if is_mcp: if is_mcp:
from app.agents.chat.multi_agent_chat.shared.tools.mcp.tool import ( from app.agents.chat.multi_agent_chat.shared.tools.mcp.tool import (
invalidate_mcp_tools_cache, invalidate_mcp_tools_cache,

View file

@ -27,6 +27,7 @@ from app.db import (
User, User,
get_async_session, get_async_session,
) )
from app.observability import analytics as ph_analytics
from app.schemas.stripe import ( from app.schemas.stripe import (
AutoReloadSettingsResponse, AutoReloadSettingsResponse,
CreateAutoReloadSetupSessionRequest, CreateAutoReloadSetupSessionRequest,
@ -47,6 +48,26 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/stripe", tags=["stripe"]) router = APIRouter(prefix="/stripe", tags=["stripe"])
def _capture_credits_purchased(user: User, purchase: CreditPurchase) -> None:
"""Emit ``credits_purchased`` — revenue events only exist server-side.
Call only from the idempotent grant paths (which early-return on already
COMPLETED / non-PENDING rows) so Stripe retries never double-count. No-op
when PostHog is unconfigured.
"""
ph_analytics.capture(
"credits_purchased",
distinct_id=str(user.id),
properties={
"credit_micros_granted": purchase.credit_micros_granted,
"quantity": purchase.quantity,
"amount_total": purchase.amount_total,
"currency": purchase.currency,
"source": purchase.source,
},
)
def get_stripe_client() -> StripeClient: def get_stripe_client() -> StripeClient:
"""Return a configured Stripe client or raise if Stripe is disabled.""" """Return a configured Stripe client or raise if Stripe is disabled."""
if not config.STRIPE_SECRET_KEY: if not config.STRIPE_SECRET_KEY:
@ -309,6 +330,7 @@ async def _fulfill_completed_credit_purchase(
) )
await db_session.commit() await db_session.commit()
_capture_credits_purchased(user, purchase)
return StripeWebhookResponse() return StripeWebhookResponse()
@ -448,6 +470,8 @@ async def _reconcile_auto_reload_payment_intent(
purchase.status = CreditPurchaseStatus.FAILED purchase.status = CreditPurchaseStatus.FAILED
await db_session.commit() await db_session.commit()
if succeeded:
_capture_credits_purchased(user, purchase)
return StripeWebhookResponse() return StripeWebhookResponse()

View file

@ -15,6 +15,7 @@ from app.db import (
get_async_session, get_async_session,
get_default_roles_config, get_default_roles_config,
) )
from app.observability import analytics as ph_analytics
from app.routes.model_connections_routes import compute_llm_setup_status from app.routes.model_connections_routes import compute_llm_setup_status
from app.schemas import ( from app.schemas import (
WorkspaceApiAccessUpdate, WorkspaceApiAccessUpdate,
@ -112,6 +113,16 @@ async def create_workspace(
await session.commit() await session.commit()
await session.refresh(db_workspace) await session.refresh(db_workspace)
# Authoritative creation event (migrated from the frontend
# CreateWorkspaceDialog). Workspace name is intentionally NOT sent —
# it's user content with no aggregation value.
ph_analytics.capture_for(
auth,
"workspace_created",
{"workspace_id": db_workspace.id},
groups={"workspace": str(db_workspace.id)},
)
response = WorkspaceRead.model_validate(db_workspace) response = WorkspaceRead.model_validate(db_workspace)
response.llm_setup = await compute_llm_setup_status( response.llm_setup = await compute_llm_setup_status(
session, auth, db_workspace.id session, auth, db_workspace.id

View file

@ -1,5 +1,6 @@
"""Celery tasks for connector indexing.""" """Celery tasks for connector indexing."""
import contextlib
import logging import logging
import time import time
import traceback import traceback
@ -8,6 +9,7 @@ from collections.abc import Awaitable, Callable
from celery import current_task from celery import current_task
from app.celery_app import celery_app from app.celery_app import celery_app
from app.observability import analytics as ph_analytics
from app.observability import metrics as ot_metrics, otel as ot from app.observability import metrics as ot_metrics, otel as ot
from app.tasks.celery_tasks import ( from app.tasks.celery_tasks import (
get_celery_session_maker, get_celery_session_maker,
@ -17,8 +19,18 @@ from app.tasks.celery_tasks import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def run_async_celery_task[T](coro_factory: Callable[[], Awaitable[T]]) -> T: def run_async_celery_task[T](
"""Run connector sync work and record aggregate connector metrics.""" coro_factory: Callable[[], Awaitable[T]],
*,
workspace_id: int | None = None,
user_id: str | None = None,
) -> T:
"""Run connector sync work and record aggregate connector metrics.
When ``workspace_id``/``user_id`` are provided, also emits the PostHog
``connector_indexing_completed``/``_failed`` outcome (the frontend only
knows indexing *started*, never whether it worked). No-op without a key.
"""
task_name = getattr(current_task, "name", None) or "unknown" task_name = getattr(current_task, "name", None) or "unknown"
t0 = time.perf_counter() t0 = time.perf_counter()
status = "failed" status = "failed"
@ -45,6 +57,29 @@ def run_async_celery_task[T](coro_factory: Callable[[], Awaitable[T]]) -> T:
status=status, status=status,
error_category=error_category, error_category=error_category,
) )
if user_id and ph_analytics.is_enabled():
event = (
"connector_indexing_completed"
if status == "success"
else "connector_indexing_failed"
)
with contextlib.suppress(Exception):
ph_analytics.capture(
event,
distinct_id=str(user_id),
properties={
"workspace_id": workspace_id,
# ``connector_type`` is the Celery task name, e.g.
# index_notion_pages / index_github_repos.
"connector_type": task_name,
"status": status,
"error_category": error_category,
"duration_ms": int(elapsed_s * 1000),
},
groups={"workspace": str(workspace_id)}
if workspace_id is not None
else None,
)
def _handle_greenlet_error(e: Exception, task_name: str, connector_id: int) -> None: def _handle_greenlet_error(e: Exception, task_name: str, connector_id: int) -> None:
@ -91,7 +126,9 @@ def index_notion_pages_task(
return run_async_celery_task( return run_async_celery_task(
lambda: _index_notion_pages( lambda: _index_notion_pages(
connector_id, workspace_id, user_id, start_date, end_date connector_id, workspace_id, user_id, start_date, end_date
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
except Exception as e: except Exception as e:
_handle_greenlet_error(e, "index_notion_pages", connector_id) _handle_greenlet_error(e, "index_notion_pages", connector_id)
@ -129,7 +166,9 @@ def index_github_repos_task(
return run_async_celery_task( return run_async_celery_task(
lambda: _index_github_repos( lambda: _index_github_repos(
connector_id, workspace_id, user_id, start_date, end_date connector_id, workspace_id, user_id, start_date, end_date
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
@ -164,7 +203,9 @@ def index_confluence_pages_task(
return run_async_celery_task( return run_async_celery_task(
lambda: _index_confluence_pages( lambda: _index_confluence_pages(
connector_id, workspace_id, user_id, start_date, end_date connector_id, workspace_id, user_id, start_date, end_date
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
@ -200,7 +241,9 @@ def index_google_calendar_events_task(
return run_async_celery_task( return run_async_celery_task(
lambda: _index_google_calendar_events( lambda: _index_google_calendar_events(
connector_id, workspace_id, user_id, start_date, end_date connector_id, workspace_id, user_id, start_date, end_date
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
except Exception as e: except Exception as e:
_handle_greenlet_error(e, "index_google_calendar_events", connector_id) _handle_greenlet_error(e, "index_google_calendar_events", connector_id)
@ -238,7 +281,9 @@ def index_google_gmail_messages_task(
return run_async_celery_task( return run_async_celery_task(
lambda: _index_google_gmail_messages( lambda: _index_google_gmail_messages(
connector_id, workspace_id, user_id, start_date, end_date connector_id, workspace_id, user_id, start_date, end_date
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
@ -275,7 +320,9 @@ def index_google_drive_files_task(
workspace_id, workspace_id,
user_id, user_id,
items_dict, items_dict,
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
@ -315,7 +362,9 @@ def index_onedrive_files_task(
workspace_id, workspace_id,
user_id, user_id,
items_dict, items_dict,
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
@ -355,7 +404,9 @@ def index_dropbox_files_task(
workspace_id, workspace_id,
user_id, user_id,
items_dict, items_dict,
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
@ -393,7 +444,9 @@ def index_elasticsearch_documents_task(
return run_async_celery_task( return run_async_celery_task(
lambda: _index_elasticsearch_documents( lambda: _index_elasticsearch_documents(
connector_id, workspace_id, user_id, start_date, end_date connector_id, workspace_id, user_id, start_date, end_date
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
@ -428,7 +481,9 @@ def index_bookstack_pages_task(
return run_async_celery_task( return run_async_celery_task(
lambda: _index_bookstack_pages( lambda: _index_bookstack_pages(
connector_id, workspace_id, user_id, start_date, end_date connector_id, workspace_id, user_id, start_date, end_date
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
@ -463,7 +518,9 @@ def index_composio_connector_task(
return run_async_celery_task( return run_async_celery_task(
lambda: _index_composio_connector( lambda: _index_composio_connector(
connector_id, workspace_id, user_id, start_date, end_date connector_id, workspace_id, user_id, start_date, end_date
) ),
workspace_id=workspace_id,
user_id=user_id,
) )

View file

@ -4,11 +4,13 @@ import asyncio
import contextlib import contextlib
import logging import logging
import os import os
import time
from uuid import UUID from uuid import UUID
from app.celery_app import celery_app from app.celery_app import celery_app
from app.config import config from app.config import config
from app.notifications.service import NotificationService from app.notifications.service import NotificationService
from app.observability import analytics as ph_analytics
from app.observability import metrics as ot_metrics from app.observability import metrics as ot_metrics
from app.services.task_logging_service import TaskLoggingService from app.services.task_logging_service import TaskLoggingService
from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task
@ -22,6 +24,44 @@ from app.tasks.document_processors import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _capture_doc_processing(
status: str,
*,
user_id: str | None,
workspace_id: int,
doc_type: str,
file_size: int | None = None,
duration_ms: int | None = None,
) -> None:
"""Emit ``document_processing_completed``/``_failed`` from a Celery task.
The frontend only knows the upload POST succeeded, never whether ingestion
actually worked this is the authoritative outcome. No-op when PostHog is
unconfigured. ``distinct_id`` is the owning user's id so it joins the same
person the web app identifies.
"""
if not ph_analytics.is_enabled() or not user_id:
return
event = (
"document_processing_completed"
if status == "success"
else "document_processing_failed"
)
ph_analytics.capture(
event,
distinct_id=str(user_id),
properties={
"workspace_id": workspace_id,
"doc_type": doc_type,
"file_size": file_size,
"duration_ms": duration_ms,
"status": status,
},
groups={"workspace": str(workspace_id)},
)
# ===== Redis heartbeat for document processing tasks ===== # ===== Redis heartbeat for document processing tasks =====
# Same mechanism as connector indexing heartbeats (search_source_connectors_routes.py). # Same mechanism as connector indexing heartbeats (search_source_connectors_routes.py).
# A background coroutine refreshes a Redis key every 60s with a 2-min TTL. # A background coroutine refreshes a Redis key every 60s with a 2-min TTL.
@ -268,11 +308,30 @@ def process_extension_document_task(
workspace_id: ID of the workspace workspace_id: ID of the workspace
user_id: ID of the user user_id: ID of the user
""" """
return run_async_celery_task( _t0 = time.perf_counter()
lambda: _process_extension_document( try:
individual_document_dict, workspace_id, user_id result = run_async_celery_task(
lambda: _process_extension_document(
individual_document_dict, workspace_id, user_id
)
) )
except Exception:
_capture_doc_processing(
"failed",
user_id=user_id,
workspace_id=workspace_id,
doc_type="extension",
duration_ms=int((time.perf_counter() - _t0) * 1000),
)
raise
_capture_doc_processing(
"success",
user_id=user_id,
workspace_id=workspace_id,
doc_type="extension",
duration_ms=int((time.perf_counter() - _t0) * 1000),
) )
return result
async def _process_extension_document( async def _process_extension_document(
@ -430,12 +489,14 @@ def process_file_upload_task(
) )
return return
file_size: int | None = None
try: try:
file_size = os.path.getsize(file_path) file_size = os.path.getsize(file_path)
logger.info(f"[process_file_upload] File size: {file_size} bytes") logger.info(f"[process_file_upload] File size: {file_size} bytes")
except Exception as e: except Exception as e:
logger.warning(f"[process_file_upload] Could not get file size: {e}") logger.warning(f"[process_file_upload] Could not get file size: {e}")
_t0 = time.perf_counter()
try: try:
run_async_celery_task( run_async_celery_task(
lambda: _process_file_upload(file_path, filename, workspace_id, user_id) lambda: _process_file_upload(file_path, filename, workspace_id, user_id)
@ -448,7 +509,23 @@ def process_file_upload_task(
f"[process_file_upload] Task failed for {filename}: {e}\n" f"[process_file_upload] Task failed for {filename}: {e}\n"
f"Traceback:\n{traceback.format_exc()}" f"Traceback:\n{traceback.format_exc()}"
) )
_capture_doc_processing(
"failed",
user_id=user_id,
workspace_id=workspace_id,
doc_type="file_upload",
file_size=file_size,
duration_ms=int((time.perf_counter() - _t0) * 1000),
)
raise raise
_capture_doc_processing(
"success",
user_id=user_id,
workspace_id=workspace_id,
doc_type="file_upload",
file_size=file_size,
duration_ms=int((time.perf_counter() - _t0) * 1000),
)
async def _process_file_upload( async def _process_file_upload(
@ -682,6 +759,7 @@ def process_file_upload_with_document_task(
) )
return return
_t0 = time.perf_counter()
try: try:
run_async_celery_task( run_async_celery_task(
lambda: _process_file_with_document( lambda: _process_file_with_document(
@ -702,7 +780,21 @@ def process_file_upload_with_document_task(
f"[process_file_upload_with_document] Task failed for {filename}: {e}\n" f"[process_file_upload_with_document] Task failed for {filename}: {e}\n"
f"Traceback:\n{traceback.format_exc()}" f"Traceback:\n{traceback.format_exc()}"
) )
_capture_doc_processing(
"failed",
user_id=user_id,
workspace_id=workspace_id,
doc_type="file_upload_2phase",
duration_ms=int((time.perf_counter() - _t0) * 1000),
)
raise raise
_capture_doc_processing(
"success",
user_id=user_id,
workspace_id=workspace_id,
doc_type="file_upload_2phase",
duration_ms=int((time.perf_counter() - _t0) * 1000),
)
async def _mark_document_failed(document_id: int, reason: str): async def _mark_document_failed(document_id: int, reason: str):

View file

@ -12,6 +12,7 @@ from app.agents.video_presentation.state import State as VideoPresentationState
from app.celery_app import celery_app from app.celery_app import celery_app
from app.config import config as app_config from app.config import config as app_config
from app.db import VideoPresentation, VideoPresentationStatus from app.db import VideoPresentation, VideoPresentationStatus
from app.observability import analytics as ph_analytics
from app.services.billable_calls import ( from app.services.billable_calls import (
BillingSettlementError, BillingSettlementError,
QuotaInsufficientError, QuotaInsufficientError,
@ -239,6 +240,20 @@ async def _generate_video_presentation(
logger.info(f"Successfully generated video presentation: {video_pres.id}") logger.info(f"Successfully generated video presentation: {video_pres.id}")
# Credit-consuming deliverable — the frontend never confirms
# completion. Attributed to the workspace owner resolved above.
if owner_user_id:
ph_analytics.capture(
"video_presentation_generated",
distinct_id=str(owner_user_id),
properties={
"workspace_id": workspace_id,
"video_presentation_id": video_pres.id,
"slide_count": len(serializable_slides),
},
groups={"workspace": str(workspace_id)},
)
return { return {
"status": "ready", "status": "ready",
"video_presentation_id": video_pres.id, "video_presentation_id": video_pres.id,

View file

@ -97,6 +97,10 @@ from app.tasks.chat.streaming.flows.shared.rate_limit_recovery import (
log_rate_limit_recovered, log_rate_limit_recovered,
reroute_to_next_auto_pin, reroute_to_next_auto_pin,
) )
from app.tasks.chat.streaming.flows.shared.analytics import (
build_llm_callback_handler,
capture_chat_turn_completed,
)
from app.tasks.chat.streaming.flows.shared.span import ( from app.tasks.chat.streaming.flows.shared.span import (
close_chat_request_span, close_chat_request_span,
open_chat_request_span, open_chat_request_span,
@ -487,6 +491,23 @@ async def stream_new_chat(
"recursion_limit": 10_000, "recursion_limit": 10_000,
} }
# PostHog LLM analytics: attach a callback so the full agent trace
# tree (LLM calls, tools, subagents) is captured per turn. No-op when
# PostHog is unconfigured.
_llm_handler = build_llm_callback_handler(
distinct_id=user_id,
trace_id=stream_result.turn_id,
properties={
"workspace_id": workspace_id,
"chat_id": chat_id,
"$ai_session_id": str(chat_id),
"flow": flow,
},
groups={"workspace": str(workspace_id)},
)
if _llm_handler is not None:
config["callbacks"] = [_llm_handler]
# --- Block 4: First SSE frames --- # --- Block 4: First SSE frames ---
for sse in iter_initial_frames( for sse in iter_initial_frames(
@ -830,6 +851,27 @@ async def stream_new_chat(
log_prefix="stream_new_chat", log_prefix="stream_new_chat",
) )
# Authoritative server-side product event. Inside the shield so it
# survives client-disconnect cancellation (abandoned tabs still
# produce a turn event), and while ``stream_result`` is still live
# (it's dropped to None below for GC).
capture_chat_turn_completed(
flow=flow,
outcome=chat_outcome,
error_category=chat_error_category,
workspace_id=workspace_id,
chat_id=chat_id,
user_id=user_id,
auth_context=auth_context,
agent_mode=chat_agent_mode,
client_platform=fs_platform,
filesystem_mode=fs_mode,
turn_id=getattr(stream_result, "turn_id", None),
request_id=request_id,
duration_ms=int((time.perf_counter() - _t_total) * 1000),
accumulator=accumulator,
)
# Persist any sandbox-produced files to local storage so they remain # Persist any sandbox-produced files to local storage so they remain
# downloadable after the Daytona sandbox auto-deletes. # downloadable after the Daytona sandbox auto-deletes.
if stream_result and stream_result.sandbox_files: if stream_result and stream_result.sandbox_files:

View file

@ -76,6 +76,10 @@ from app.tasks.chat.streaming.flows.shared.rate_limit_recovery import (
log_rate_limit_recovered, log_rate_limit_recovered,
reroute_to_next_auto_pin, reroute_to_next_auto_pin,
) )
from app.tasks.chat.streaming.flows.shared.analytics import (
build_llm_callback_handler,
capture_chat_turn_completed,
)
from app.tasks.chat.streaming.flows.shared.span import ( from app.tasks.chat.streaming.flows.shared.span import (
close_chat_request_span, close_chat_request_span,
open_chat_request_span, open_chat_request_span,
@ -387,6 +391,22 @@ async def stream_resume_chat(
"recursion_limit": 10_000, "recursion_limit": 10_000,
} }
# PostHog LLM analytics — same trace as the original turn's
# conversation via ``$ai_session_id``. No-op when unconfigured.
_llm_handler = build_llm_callback_handler(
distinct_id=user_id,
trace_id=stream_result.turn_id,
properties={
"workspace_id": workspace_id,
"chat_id": chat_id,
"$ai_session_id": str(chat_id),
"flow": "resume",
},
groups={"workspace": str(workspace_id)},
)
if _llm_handler is not None:
config["callbacks"] = [_llm_handler]
# --- First SSE frames --- # --- First SSE frames ---
for sse in iter_initial_frames( for sse in iter_initial_frames(
@ -599,6 +619,25 @@ async def stream_resume_chat(
log_prefix="stream_resume", log_prefix="stream_resume",
) )
# Authoritative server-side product event (see new_chat
# orchestrator for rationale). ``flow="resume"``.
capture_chat_turn_completed(
flow="resume",
outcome=chat_outcome,
error_category=chat_error_category,
workspace_id=workspace_id,
chat_id=chat_id,
user_id=user_id,
auth_context=auth_context,
agent_mode=chat_agent_mode,
client_platform=fs_platform,
filesystem_mode=fs_mode,
turn_id=getattr(stream_result, "turn_id", None),
request_id=request_id,
duration_ms=int((time.perf_counter() - _t_total) * 1000),
accumulator=accumulator,
)
# Release the lock from the original interrupted turn or any # Release the lock from the original interrupted turn or any
# re-interrupt/bailout. Skip on ``BusyError`` (lock not held here). # re-interrupt/bailout. Skip on ``BusyError`` (lock not held here).
if not busy_error_raised: if not busy_error_raised:

View file

@ -0,0 +1,117 @@
"""PostHog chat-turn analytics for streaming flows.
Emits a single authoritative ``chat_turn_completed`` product event per turn,
shared by the new-chat and resume orchestrators so every chat source (web,
desktop, PAT scripts, gateway, automations) is tracked identically
including sources the frontend can never observe. No-op when PostHog is
unconfigured.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from app.config import config
from app.observability import analytics
if TYPE_CHECKING:
from app.auth.context import AuthContext
from app.services.token_tracking_service import TurnTokenAccumulator
logger = logging.getLogger(__name__)
def build_llm_callback_handler(
*,
distinct_id: str | None,
trace_id: str | None,
properties: dict[str, Any] | None = None,
groups: dict[str, str] | None = None,
) -> Any | None:
"""Build a PostHog LangChain ``CallbackHandler`` for a chat turn.
Attaching this to the LangGraph ``config["callbacks"]`` captures the full
agent trace tree ($ai_trace / $ai_span / $ai_generation) every LLM call,
tool, subagent, and retriever joined to the ``chat_turn_completed`` event
via ``trace_id`` (the turn id) and grouped per conversation via
``$ai_session_id`` (in ``properties``).
Returns ``None`` when PostHog is disabled or the package/handler is
unavailable, so callers can simply skip attaching callbacks. ``privacy_mode``
(default on) suppresses prompt/completion bodies chat content includes
users' private documents.
"""
client_obj = analytics.get_client()
if client_obj is None or not distinct_id:
return None
try:
from posthog.ai.langchain import CallbackHandler
return CallbackHandler(
client=client_obj,
distinct_id=distinct_id,
trace_id=trace_id,
properties=properties or {},
privacy_mode=config.POSTHOG_AI_PRIVACY_MODE,
groups=groups,
)
except Exception:
logger.debug("PostHog LLM callback handler unavailable", exc_info=True)
return None
def capture_chat_turn_completed(
*,
flow: str,
outcome: str,
error_category: str | None,
workspace_id: int,
chat_id: int,
user_id: str | None,
auth_context: AuthContext | None,
agent_mode: str,
client_platform: str,
filesystem_mode: str,
turn_id: str | None,
request_id: str | None,
duration_ms: int,
accumulator: TurnTokenAccumulator,
) -> None:
"""Capture ``chat_turn_completed``. Best-effort; never raises."""
if not analytics.is_enabled() or not user_id:
return
props: dict[str, Any] = {
"flow": flow,
"outcome": outcome,
"error_category": error_category,
"workspace_id": workspace_id,
"chat_id": chat_id,
"agent_mode": agent_mode,
"client_platform": client_platform,
"filesystem_mode": filesystem_mode,
"turn_id": turn_id,
"request_id": request_id,
"duration_ms": duration_ms,
# Cost is micro-USD (integer), matching TurnTokenAccumulator; do not
# convert to float dollars.
"total_tokens": accumulator.grand_total,
"prompt_tokens": accumulator.total_prompt_tokens,
"completion_tokens": accumulator.total_completion_tokens,
"cost_micros": accumulator.total_cost_micros,
}
groups = {"workspace": str(workspace_id)}
if auth_context is not None:
analytics.capture_for(
auth_context, "chat_turn_completed", props, groups=groups
)
else:
analytics.capture(
"chat_turn_completed",
distinct_id=user_id,
properties=props,
groups=groups,
)

View file

@ -20,6 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext from app.auth.context import AuthContext
from app.auth.session_cookies import access_expires_at, write_session from app.auth.session_cookies import access_expires_at, write_session
from app.config import config from app.config import config
from app.observability import analytics as ph_analytics
from app.db import ( from app.db import (
Prompt, Prompt,
User, User,
@ -144,6 +145,9 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
except Exception as e: except Exception as e:
logger.warning(f"Failed to update last_login for user {user.id}: {e}") logger.warning(f"Failed to update last_login for user {user.id}: {e}")
# Authoritative login event (vs. the frontend's optimistic capture).
ph_analytics.capture("auth_login_success", distinct_id=str(user.id))
async def on_after_register(self, user: User, request: Request | None = None): async def on_after_register(self, user: User, request: Request | None = None):
""" """
Called after a user registers. Creates a default workspace for the user Called after a user registers. Creates a default workspace for the user
@ -206,6 +210,17 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
logger.info( logger.info(
f"Created default workspace (ID: {default_workspace.id}) for user {user.id}" f"Created default workspace (ID: {default_workspace.id}) for user {user.id}"
) )
# Authoritative registration + auto-created default workspace.
ph_analytics.capture(
"auth_registration_success", distinct_id=str(user.id)
)
ph_analytics.capture(
"workspace_created",
distinct_id=str(user.id),
properties={"client": "auto_register"},
groups={"workspace": str(default_workspace.id)},
)
except Exception as e: except Exception as e:
logger.error(f"Failed to create default workspace for user {user.id}: {e}") logger.error(f"Failed to create default workspace for user {user.id}: {e}")
@ -338,6 +353,13 @@ async def get_auth_context(
FastAPI-Users still handles JWT mechanics; PATs are resolved here so RBAC FastAPI-Users still handles JWT mechanics; PATs are resolved here so RBAC
receives the full SurfSense principal instead of a bare User. receives the full SurfSense principal instead of a bare User.
""" """
def _stash(ctx: AuthContext) -> AuthContext:
# Expose the resolved principal on request.state so downstream
# middleware (e.g. PostHog pat_api_request attribution) can read it
# without re-resolving auth.
request.state.auth_context = ctx
return ctx
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
if auth_header: if auth_header:
scheme, _, credential = auth_header.partition(" ") scheme, _, credential = auth_header.partition(" ")
@ -348,7 +370,7 @@ async def get_auth_context(
pat = await resolve_pat(session, token) pat = await resolve_pat(session, token)
if pat and pat.user and pat.user.is_active: if pat and pat.user and pat.user.is_active:
maybe_touch_last_used(pat) maybe_touch_last_used(pat)
return AuthContext.pat_auth(pat.user, pat) return _stash(AuthContext.pat_auth(pat.user, pat))
if is_bearer and _token_meets_epoch(token): if is_bearer and _token_meets_epoch(token):
try: try:
@ -358,7 +380,7 @@ async def get_auth_context(
user = None user = None
if user and user.is_active: if user and user.is_active:
return AuthContext.session(user) return _stash(AuthContext.session(user))
cookie_token = request.cookies.get(config.SESSION_COOKIE_NAME) cookie_token = request.cookies.get(config.SESSION_COOKIE_NAME)
if cookie_token and _token_meets_epoch(cookie_token): if cookie_token and _token_meets_epoch(cookie_token):
@ -369,7 +391,7 @@ async def get_auth_context(
user = None user = None
if user and user.is_active: if user and user.is_active:
return AuthContext.session(user) return _stash(AuthContext.session(user))
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,

View file

@ -86,6 +86,7 @@ dependencies = [
"python-telegram-bot>=22.7", "python-telegram-bot>=22.7",
"croniter>=2.0.0", "croniter>=2.0.0",
"scrapling[fetchers]>=0.4.11", "scrapling[fetchers]>=0.4.11",
"posthog>=6.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View file

@ -0,0 +1,149 @@
"""Unit tests for the PostHog analytics wrapper.
Covers the two properties the rest of the codebase relies on:
1. Opt-in no-op: with no client configured, every entry point is silent and
never raises (analytics must never break a request).
2. Correct stamping when a client IS present: ``source="backend"`` and
``disable_geoip=True`` on every capture, plus ``auth_method`` / ``client``
derived from the ``AuthContext`` principal by ``capture_for``.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.observability import analytics
pytestmark = pytest.mark.unit
class _FakeClient:
"""Records capture()/group_identify() calls instead of hitting the network."""
def __init__(self) -> None:
self.captures: list[dict] = []
self.groups: list[dict] = []
def capture(self, event, *, distinct_id, properties, groups, disable_geoip):
self.captures.append(
{
"event": event,
"distinct_id": distinct_id,
"properties": properties,
"groups": groups,
"disable_geoip": disable_geoip,
}
)
def group_identify(self, *, group_type, group_key, properties):
self.groups.append(
{"group_type": group_type, "group_key": group_key, "properties": properties}
)
@pytest.fixture(autouse=True)
def _reset_module_state():
"""Isolate the module-level lazy-client singleton between tests."""
orig_client = analytics._client
orig_attempted = analytics._init_attempted
yield
analytics._client = orig_client
analytics._init_attempted = orig_attempted
def _use_fake_client() -> _FakeClient:
"""Inject a fake client, bypassing lazy init (no posthog import, no key)."""
fake = _FakeClient()
analytics._client = fake
analytics._init_attempted = True
return fake
def _disable_client() -> None:
analytics._client = None
analytics._init_attempted = True
# ---- No-op behaviour when disabled -------------------------------------------------
def test_capture_is_noop_without_client():
_disable_client()
assert analytics.is_enabled() is False
# Must not raise even though there is no client.
analytics.capture("some_event", distinct_id="u1", properties={"a": 1})
def test_capture_for_is_noop_without_client():
_disable_client()
auth = SimpleNamespace(method="session", source=None, user=SimpleNamespace(id="u1"))
analytics.capture_for(auth, "some_event", {"a": 1}) # no raise
def test_shutdown_is_noop_without_client():
_disable_client()
analytics.shutdown() # no raise
# ---- Stamping when a client is present ---------------------------------------------
def test_capture_stamps_source_and_disables_geoip():
fake = _use_fake_client()
analytics.capture(
"chat_turn_completed",
distinct_id="user-123",
properties={"workspace_id": 7},
groups={"workspace": "7"},
)
assert len(fake.captures) == 1
call = fake.captures[0]
assert call["event"] == "chat_turn_completed"
assert call["distinct_id"] == "user-123"
# source is always stamped so backend vs frontend events are separable.
assert call["properties"]["source"] == "backend"
assert call["properties"]["workspace_id"] == 7
assert call["groups"] == {"workspace": "7"}
# Without this the server IP would overwrite each person's real location.
assert call["disable_geoip"] is True
def test_capture_never_raises_when_client_errors():
class _Boom(_FakeClient):
def capture(self, *a, **k):
raise RuntimeError("network down")
boom = _Boom()
analytics._client = boom
analytics._init_attempted = True
# Swallowed like the frontend safeCapture — analytics never breaks a request.
analytics.capture("evt", distinct_id="u1")
@pytest.mark.parametrize(
("method", "source", "expected_client"),
[
("session", None, "web"),
("pat", None, "pat"),
("system", "gateway", "gateway"),
("system", None, "system"),
],
)
def test_capture_for_stamps_auth_method_and_client(method, source, expected_client):
fake = _use_fake_client()
auth = SimpleNamespace(
method=method, source=source, user=SimpleNamespace(id="user-abc")
)
analytics.capture_for(auth, "workspace_created", {"workspace_id": 1})
assert len(fake.captures) == 1
props = fake.captures[0]["properties"]
assert fake.captures[0]["distinct_id"] == "user-abc"
assert props["auth_method"] == method
assert props["client"] == expected_client
assert props["source"] == "backend" # capture_for delegates to capture
assert props["workspace_id"] == 1

View file

@ -48,6 +48,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -94,6 +97,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -140,6 +146,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -186,6 +195,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -232,6 +244,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -278,6 +293,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -324,6 +342,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -370,6 +391,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -416,6 +440,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -462,6 +489,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -523,6 +553,10 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -569,6 +603,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -615,6 +652,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -661,6 +701,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -707,6 +750,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -753,6 +799,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -799,6 +848,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -845,6 +897,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -891,6 +946,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -937,6 +995,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -983,6 +1044,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1044,6 +1108,10 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1090,6 +1158,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1136,6 +1207,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1182,6 +1256,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1228,6 +1305,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1274,6 +1354,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1320,6 +1403,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1366,6 +1452,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1412,6 +1501,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1458,6 +1550,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1504,6 +1599,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1565,6 +1663,10 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1611,6 +1713,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1702,6 +1807,12 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1748,6 +1859,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1794,6 +1908,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1885,6 +2002,12 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1976,6 +2099,12 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -2022,6 +2151,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
] ]
conflicts = [[ conflicts = [[
@ -2557,6 +2689,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845 }, { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845 },
] ]
[[package]]
name = "backoff"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148 },
]
[[package]] [[package]]
name = "banks" name = "banks"
version = "2.4.1" version = "2.4.1"
@ -8650,6 +8791,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424 }, { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424 },
] ]
[[package]]
name = "posthog"
version = "7.28.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backoff" },
{ name = "distro" },
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/36/f0/3af875ac3fd5863ed4874c9618d85eab3f7fd24b395827d99da0ef90aca6/posthog-7.28.0.tar.gz", hash = "sha256:9e048dee58f27373db622c0744be30c1a2b7f1df31049956d6341c9646fb3833", size = 356360 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/8b/d41d98e64bd6ce650d45690cffc718274b1eda65e3bfd79575a1cf95b45c/posthog-7.28.0-py3-none-any.whl", hash = "sha256:4cff10062807bbd8ae6ec2804a71584831a75b390ce7ba3e736bf4cc0fb25d28", size = 425147 },
]
[[package]] [[package]]
name = "preshed" name = "preshed"
version = "3.0.13" version = "3.0.13"
@ -10926,6 +11082,7 @@ dependencies = [
{ name = "opentelemetry-sdk" }, { name = "opentelemetry-sdk" },
{ name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-semantic-conventions" },
{ name = "pgvector" }, { name = "pgvector" },
{ name = "posthog" },
{ name = "psycopg", extra = ["binary", "pool"] }, { name = "psycopg", extra = ["binary", "pool"] },
{ name = "pyarrow" }, { name = "pyarrow" },
{ name = "pyjwt" }, { name = "pyjwt" },
@ -11041,6 +11198,7 @@ requires-dist = [
{ name = "opentelemetry-sdk", specifier = ">=1.40.0" }, { name = "opentelemetry-sdk", specifier = ">=1.40.0" },
{ name = "opentelemetry-semantic-conventions", specifier = ">=0.61b0" }, { name = "opentelemetry-semantic-conventions", specifier = ">=0.61b0" },
{ name = "pgvector", specifier = ">=0.3.6" }, { name = "pgvector", specifier = ">=0.3.6" },
{ name = "posthog", specifier = ">=6.0.0" },
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.2" }, { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.2" },
{ name = "pyarrow", specifier = ">=15.0.0,<19.0.0" }, { name = "pyarrow", specifier = ">=15.0.0,<19.0.0" },
{ name = "pyjwt", specifier = ">=2.12.0" }, { name = "pyjwt", specifier = ">=2.12.0" },

View file

@ -37,7 +37,11 @@ class SurfSenseClient:
self._fallback_api_key = fallback_api_key self._fallback_api_key = fallback_api_key
self._http = httpx.AsyncClient( self._http = httpx.AsyncClient(
base_url=api_base, base_url=api_base,
headers={"Accept": "application/json"}, # ``X-SurfSense-Client`` lets the backend distinguish PAT traffic
# originating from this MCP server vs. raw PAT scripts, so
# "documents added via MCP" / "searches via MCP" are queryable.
# Server-to-server, so no CORS implications.
headers={"Accept": "application/json", "X-SurfSense-Client": "mcp"},
timeout=timeout, timeout=timeout,
) )

View file

@ -13,7 +13,7 @@ import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors"; import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
import { getPostLoginRedirectPath } from "@/lib/auth-utils"; import { getPostLoginRedirectPath } from "@/lib/auth-utils";
import { ValidationError } from "@/lib/error"; import { ValidationError } from "@/lib/error";
import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events"; import { trackLoginAttempt, trackLoginFailure } from "@/lib/posthog/events";
export function LocalLoginForm() { export function LocalLoginForm() {
const t = useTranslations("auth"); const t = useTranslations("auth");
@ -45,8 +45,8 @@ export function LocalLoginForm() {
grant_type: "password", grant_type: "password",
}); });
// Track successful login // auth_login_success is now emitted server-side
trackLoginSuccess("local"); // (UserManager.on_after_login) — authoritative vs. optimistic.
// Small delay to show success message // Small delay to show success message
setTimeout(() => { setTimeout(() => {

View file

@ -15,11 +15,7 @@ import { Spinner } from "@/components/ui/spinner";
import { useSession } from "@/hooks/use-session"; import { useSession } from "@/hooks/use-session";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AppError, ValidationError } from "@/lib/error"; import { AppError, ValidationError } from "@/lib/error";
import { import { trackRegistrationAttempt, trackRegistrationFailure } from "@/lib/posthog/events";
trackRegistrationAttempt,
trackRegistrationFailure,
trackRegistrationSuccess,
} from "@/lib/posthog/events";
import { AmbientBackground } from "../login/AmbientBackground"; import { AmbientBackground } from "../login/AmbientBackground";
export default function RegisterPage() { export default function RegisterPage() {
@ -81,8 +77,8 @@ export default function RegisterPage() {
is_verified: false, is_verified: false,
}); });
// Track successful registration // auth_registration_success is now emitted server-side
trackRegistrationSuccess(); // (UserManager.on_after_register) — authoritative vs. optimistic.
// Success toast // Success toast
toast.success(t("register_success"), { toast.success(t("register_success"), {

View file

@ -8,7 +8,6 @@ import { useEffect } from "react";
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom"; import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms"; import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms";
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms"; import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup"; import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
import { LayoutDataProvider } from "@/components/layout"; import { LayoutDataProvider } from "@/components/layout";
import { OnboardingTour } from "@/components/onboarding-tour"; import { OnboardingTour } from "@/components/onboarding-tour";
@ -169,7 +168,6 @@ export function DashboardClientLayout({
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed} initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
> >
{children} {children}
<ConnectorIndicator showTrigger={false} />
</LayoutDataProvider> </LayoutDataProvider>
</DocumentUploadDialogProvider> </DocumentUploadDialogProvider>
); );

View file

@ -19,7 +19,9 @@ export async function GET(
const response = new NextResponse(null, { const response = new NextResponse(null, {
status: 302, status: 302,
headers: { headers: {
Location: `/dashboard/${workspace_id}/new-chat`, // Land on the connectors panel so `useConnectorDialog` (mounted there)
// consumes the result cookie and continues the indexing/edit flow.
Location: `/dashboard/${workspace_id}/connectors`,
}, },
}); });
response.cookies.set(OAUTH_RESULT_COOKIE, result, { response.cookies.set(OAUTH_RESULT_COOKIE, result, {

View file

@ -0,0 +1,5 @@
import { ConnectorsSection } from "@/components/assistant-ui/connector-popup/connectors-panel";
export default function ConnectorsPage() {
return <ConnectorsSection />;
}

View file

@ -96,7 +96,6 @@ import type { Role } from "@/contracts/types/roles.types";
import { invitesApiService } from "@/lib/apis/invites-api.service"; import { invitesApiService } from "@/lib/apis/invites-api.service";
import { rolesApiService } from "@/lib/apis/roles-api.service"; import { rolesApiService } from "@/lib/apis/roles-api.service";
import { formatRelativeDate } from "@/lib/format-date"; import { formatRelativeDate } from "@/lib/format-date";
import { trackWorkspaceInviteSent, trackWorkspaceUsersViewed } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -226,12 +225,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
const canPrev = pageIndex > 0; const canPrev = pageIndex > 0;
const canNext = displayEnd < totalItems; const canNext = displayEnd < totalItems;
useEffect(() => { // workspace_users_viewed removed — redundant with $pageview.
if (members.length > 0 && !membersLoading) {
const ownerCount = members.filter((m) => m.is_owner).length;
trackWorkspaceUsersViewed(workspaceId, members.length, ownerCount);
}
}, [members, membersLoading, workspaceId]);
if (accessLoading || membersLoading) { if (accessLoading || membersLoading) {
return ( return (
@ -342,11 +336,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
Invite members Invite members
</Button> </Button>
) : ( ) : (
<CreateInviteDialog <CreateInviteDialog roles={roles} onCreateInvite={handleCreateInvite} />
roles={roles}
onCreateInvite={handleCreateInvite}
workspaceId={workspaceId}
/>
)} )}
{invitesLoading ? ( {invitesLoading ? (
<Button <Button
@ -617,11 +607,9 @@ function MemberRow({
function CreateInviteDialog({ function CreateInviteDialog({
roles, roles,
onCreateInvite, onCreateInvite,
workspaceId,
}: { }: {
roles: Role[]; roles: Role[];
onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>; onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>;
workspaceId: number;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@ -653,12 +641,7 @@ function CreateInviteDialog({
const invite = await onCreateInvite(data); const invite = await onCreateInvite(data);
setCreatedInvite(invite); setCreatedInvite(invite);
const roleName = roleId ? roles.find((r) => r.id.toString() === roleId)?.name : undefined; // workspace_invite_sent is now emitted server-side (rbac_routes.create_invite).
trackWorkspaceInviteSent(workspaceId, {
roleName,
hasExpiry: !!expiresAt,
hasMaxUses: !!maxUses,
});
} catch (error) { } catch (error) {
console.error("Failed to create invite:", error); console.error("Failed to create invite:", error);
toast.error("Failed to create invite. Please try again."); toast.error("Failed to create invite. Please try again.");

View file

@ -43,21 +43,15 @@ export default function DashboardError({
<Button type="button" onClick={reset}> <Button type="button" onClick={reset}>
Try again Try again
</Button> </Button>
<Link <Button asChild variant="ghost">
href="/dashboard" <Link href="/dashboard">Back to dashboard</Link>
className="rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors" </Button>
> <Button asChild variant="ghost">
Go to dashboard home <a href={issueUrl} target="_blank" rel="noopener noreferrer">
</Link> Report Issue
<a <ExternalLink className="h-3.5 w-3.5" />
href={issueUrl} </a>
target="_blank" </Button>
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
Report Issue
</a>
</div> </div>
</div> </div>
); );

View file

@ -16,7 +16,7 @@ import { motion } from "motion/react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { use, useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { acceptInviteMutationAtom } from "@/atoms/invites/invites-mutation.atoms"; import { acceptInviteMutationAtom } from "@/atoms/invites/invites-mutation.atoms";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -33,11 +33,7 @@ import type { AcceptInviteResponse } from "@/contracts/types/invites.types";
import { useSession } from "@/hooks/use-session"; import { useSession } from "@/hooks/use-session";
import { invitesApiService } from "@/lib/apis/invites-api.service"; import { invitesApiService } from "@/lib/apis/invites-api.service";
import { setRedirectPath } from "@/lib/auth-utils"; import { setRedirectPath } from "@/lib/auth-utils";
import { import { trackWorkspaceInviteDeclined } from "@/lib/posthog/events";
trackWorkspaceInviteAccepted,
trackWorkspaceInviteDeclined,
trackWorkspaceUserAdded,
} from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
export default function InviteAcceptPage() { export default function InviteAcceptPage() {
@ -96,9 +92,9 @@ export default function InviteAcceptPage() {
setAccepted(true); setAccepted(true);
setAcceptedData(result); setAcceptedData(result);
// Track invite accepted and user added events // workspace_invite_accepted + workspace_user_added are now emitted
trackWorkspaceInviteAccepted(result.workspace_id, result.workspace_name, result.role_name); // server-side (rbac_routes.accept_invite) — the server redirect is
trackWorkspaceUserAdded(result.workspace_id, result.workspace_name, result.role_name); // the authoritative join point.
} }
} catch (err: any) { } catch (err: any) {
setError(err.message || "Failed to accept invite"); setError(err.message || "Failed to accept invite");

View file

@ -8,18 +8,11 @@ import type {
} from "@/contracts/types/automation.types"; } from "@/contracts/types/automation.types";
import { automationsApiService } from "@/lib/apis/automations-api.service"; import { automationsApiService } from "@/lib/apis/automations-api.service";
import { import {
trackAutomationCreated,
trackAutomationCreateFailed, trackAutomationCreateFailed,
trackAutomationDeleted,
trackAutomationDeleteFailed, trackAutomationDeleteFailed,
trackAutomationStatusChanged,
trackAutomationTriggerAdded,
trackAutomationTriggerAddFailed, trackAutomationTriggerAddFailed,
trackAutomationTriggerRemoved,
trackAutomationTriggerRemoveFailed, trackAutomationTriggerRemoveFailed,
trackAutomationTriggerUpdated,
trackAutomationTriggerUpdateFailed, trackAutomationTriggerUpdateFailed,
trackAutomationUpdated,
trackAutomationUpdateFailed, trackAutomationUpdateFailed,
} from "@/lib/posthog/events"; } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
@ -48,20 +41,10 @@ export const createAutomationMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: AutomationCreateRequest) => { mutationFn: async (request: AutomationCreateRequest) => {
return automationsApiService.createAutomation(request); return automationsApiService.createAutomation(request);
}, },
onSuccess: (automation, variables) => { onSuccess: (_automation, variables) => {
invalidateList(variables.workspace_id); invalidateList(variables.workspace_id);
toast.success("Automation created"); toast.success("Automation created");
trackAutomationCreated({ // automation_created is now emitted server-side (AutomationService.create).
workspace_id: variables.workspace_id,
automation_id: automation.id,
task_count: variables.definition.plan.length,
trigger_type: variables.triggers?.[0]?.type ?? "none",
has_schedule: (variables.triggers?.length ?? 0) > 0,
chat_model_id: variables.definition.models?.chat_model_id,
image_gen_model_id: variables.definition.models?.image_gen_model_id,
vision_model_id: variables.definition.models?.vision_model_id,
tags_count: variables.definition.metadata?.tags?.length,
});
}, },
onError: (error: Error, variables) => { onError: (error: Error, variables) => {
console.error("Error creating automation:", error); console.error("Error creating automation:", error);
@ -82,24 +65,8 @@ export const updateAutomationMutationAtom = atomWithMutation(() => ({
invalidateDetail(vars.automationId); invalidateDetail(vars.automationId);
invalidateList(automation.workspace_id); invalidateList(automation.workspace_id);
toast.success("Automation updated"); toast.success("Automation updated");
// A status-only patch (pause/resume/archive) is a distinct action from a // automation_updated / automation_status_changed are now emitted
// definition/name edit, so split it into its own event. // server-side (AutomationService.update).
if (vars.patch.status && !vars.patch.definition) {
trackAutomationStatusChanged({
automation_id: vars.automationId,
workspace_id: automation.workspace_id,
next_status: vars.patch.status,
});
} else {
trackAutomationUpdated({
automation_id: vars.automationId,
workspace_id: automation.workspace_id,
has_definition_change: !!vars.patch.definition,
has_name_change: vars.patch.name != null,
has_description_change: vars.patch.description !== undefined,
task_count: vars.patch.definition?.plan?.length,
});
}
}, },
onError: (error: Error, vars) => { onError: (error: Error, vars) => {
console.error("Error updating automation:", error); console.error("Error updating automation:", error);
@ -121,10 +88,7 @@ export const deleteAutomationMutationAtom = atomWithMutation(() => ({
invalidateList(vars.workspaceId); invalidateList(vars.workspaceId);
invalidateDetail(vars.automationId); invalidateDetail(vars.automationId);
toast.success("Automation deleted"); toast.success("Automation deleted");
trackAutomationDeleted({ // automation_deleted is now emitted server-side (AutomationService.delete).
automation_id: vars.automationId,
workspace_id: vars.workspaceId,
});
}, },
onError: (error: Error, vars) => { onError: (error: Error, vars) => {
console.error("Error deleting automation:", error); console.error("Error deleting automation:", error);
@ -141,16 +105,10 @@ export const addTriggerMutationAtom = atomWithMutation(() => ({
mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => { mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => {
return automationsApiService.addTrigger(vars.automationId, vars.payload); return automationsApiService.addTrigger(vars.automationId, vars.payload);
}, },
onSuccess: (trigger, vars) => { onSuccess: (_trigger, vars) => {
invalidateDetail(vars.automationId); invalidateDetail(vars.automationId);
toast.success("Trigger added"); toast.success("Trigger added");
trackAutomationTriggerAdded({ // automation_trigger_added is now emitted server-side (TriggerService.add).
automation_id: vars.automationId,
trigger_id: trigger.id,
trigger_type: trigger.type,
enabled: trigger.enabled,
has_cron: !!trigger.params?.cron,
});
}, },
onError: (error: Error, vars) => { onError: (error: Error, vars) => {
console.error("Error adding trigger:", error); console.error("Error adding trigger:", error);
@ -174,17 +132,7 @@ export const updateTriggerMutationAtom = atomWithMutation(() => ({
onSuccess: (_, vars) => { onSuccess: (_, vars) => {
invalidateDetail(vars.automationId); invalidateDetail(vars.automationId);
toast.success("Trigger updated"); toast.success("Trigger updated");
const change: "enabled" | "params" | "other" = vars.patch.params // automation_trigger_updated is now emitted server-side (TriggerService.update).
? "params"
: vars.patch.enabled !== undefined && vars.patch.enabled !== null
? "enabled"
: "other";
trackAutomationTriggerUpdated({
automation_id: vars.automationId,
trigger_id: vars.triggerId,
change,
enabled: vars.patch.enabled ?? undefined,
});
}, },
onError: (error: Error, vars) => { onError: (error: Error, vars) => {
console.error("Error updating trigger:", error); console.error("Error updating trigger:", error);
@ -206,10 +154,7 @@ export const removeTriggerMutationAtom = atomWithMutation(() => ({
onSuccess: (vars) => { onSuccess: (vars) => {
invalidateDetail(vars.automationId); invalidateDetail(vars.automationId);
toast.success("Trigger removed"); toast.success("Trigger removed");
trackAutomationTriggerRemoved({ // automation_trigger_removed is now emitted server-side (TriggerService.remove).
automation_id: vars.automationId,
trigger_id: vars.triggerId,
});
}, },
onError: (error: Error, vars) => { onError: (error: Error, vars) => {
console.error("Error removing trigger:", error); console.error("Error removing trigger:", error);

View file

@ -0,0 +1,349 @@
"use client";
import {
ChevronLeft,
ChevronRight,
LayoutGrid,
Settings2,
TriangleAlert,
Unplug,
Upload,
Wrench,
} from "lucide-react";
import Image from "next/image";
import { type ReactNode, useCallback, useRef, useState } from "react";
import type { ConnectorRow } from "@/components/assistant-ui/connector-popup/hooks/use-connector-rows";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import {
CONNECTOR_TOOL_ICON_PATHS,
getToolDisplayName,
getToolIcon,
} from "@/contracts/enums/toolIcons";
import { cn } from "@/lib/utils";
/** Minimal shape of a grouped agent-tool section (mirrors thread.tsx). */
export interface ToolGroupView {
label: string;
tools: { name: string }[];
connectorIcon?: string;
}
interface ComposerAddMenuDrawerProps {
/** The `+` button; rendered as the drawer trigger. */
trigger: ReactNode;
onUploadFiles: () => void;
/** Connected connectors: one row per type, with live indexing health (`useConnectorRows`). */
connectorRows: ConnectorRow[];
/** Open a connector's manage view (deep-links via importConnectorRequestAtom). */
onSelectConnector: (row: ConnectorRow) => void;
/** Navigate to the full connectors catalog. */
onBrowseConnectors: () => void;
regularToolGroups: ToolGroupView[];
connectorToolGroups: ToolGroupView[];
otherToolGroup?: ToolGroupView;
disabledToolsSet: Set<string>;
onToggleTool: (name: string) => void;
onToggleToolGroup: (names: string[]) => void;
/** True while the tool list is still loading (shows a skeleton). */
toolsLoading: boolean;
}
/**
* Mobile "+" menu. A single vaul drawer that behaves like a flat list at the
* root and drills into submenus in place (each level replaces the previous,
* with a back button) rather than nesting overlays the touch-friendly
* equivalent of the desktop dropdown submenus. Screen state is a small push/pop
* stack; closing the drawer resets it to the root.
*/
type Screen =
| { kind: "root" }
| { kind: "connectors" }
| { kind: "tools" }
| { kind: "toolGroup"; label: string };
const ROW = "flex w-full items-center gap-3 px-4 py-3 text-sm hover:bg-accent hover:text-accent-foreground transition-colors";
export function ComposerAddMenuDrawer({
trigger,
onUploadFiles,
connectorRows,
onSelectConnector,
onBrowseConnectors,
regularToolGroups,
connectorToolGroups,
otherToolGroup,
disabledToolsSet,
onToggleTool,
onToggleToolGroup,
toolsLoading,
}: ComposerAddMenuDrawerProps) {
const [open, setOpen] = useState(false);
const [stack, setStack] = useState<Screen[]>([{ kind: "root" }]);
// Slide direction: forward on push, back on pop — drives the enter animation.
const dirRef = useRef<"forward" | "back">("forward");
const current = stack[stack.length - 1];
const push = useCallback((screen: Screen) => {
dirRef.current = "forward";
setStack((prev) => [...prev, screen]);
}, []);
const pop = useCallback(() => {
dirRef.current = "back";
setStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev));
}, []);
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next);
// Reset to root when closed so the next open starts fresh.
if (!next) {
dirRef.current = "forward";
setStack([{ kind: "root" }]);
}
}, []);
const close = useCallback(() => handleOpenChange(false), [handleOpenChange]);
const title =
current.kind === "connectors"
? "MCP Connectors"
: current.kind === "tools"
? "Manage Tools"
: current.kind === "toolGroup"
? current.label
: "Add";
const renderToolRow = (name: string) => {
const isDisabled = disabledToolsSet.has(name);
const ToolIcon = getToolIcon(name);
return (
<div key={name} className={ROW}>
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium">{getToolDisplayName(name)}</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => onToggleTool(name)}
className="shrink-0"
/>
</div>
);
};
const renderBody = () => {
if (current.kind === "root") {
return (
<>
<button
type="button"
className={ROW}
onClick={() => {
onUploadFiles();
close();
}}
>
<Upload className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-left">Upload Files</span>
</button>
<button
type="button"
className={ROW}
onClick={() => push({ kind: "connectors" })}
>
<Unplug className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-left">MCP Connectors</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</button>
<button type="button" className={ROW} onClick={() => push({ kind: "tools" })}>
<Settings2 className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-left">Manage Tools</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</button>
</>
);
}
if (current.kind === "connectors") {
return (
<>
{connectorRows.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
No connectors yet.
</p>
) : (
connectorRows.map((row) => (
<button
type="button"
key={row.type}
className={ROW}
onClick={() => {
onSelectConnector(row);
close();
}}
>
{getConnectorIcon(row.type, "size-4 shrink-0 text-muted-foreground")}
<span className="min-w-0 flex-1 truncate text-left">{row.title}</span>
{row.health === "syncing" ? (
<Spinner size="xs" className="shrink-0" />
) : row.health === "failed" ? (
<TriangleAlert
className="size-4 shrink-0 text-destructive"
aria-label={row.errorMessage ?? "Indexing failed"}
/>
) : row.accountCount > 1 ? (
<span className="shrink-0 text-xs text-muted-foreground">{row.accountCount}</span>
) : null}
</button>
))
)}
<Separator className="my-1" />
<button
type="button"
className={ROW}
onClick={() => {
onBrowseConnectors();
close();
}}
>
<LayoutGrid className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-left">Manage connectors</span>
</button>
</>
);
}
if (current.kind === "toolGroup") {
const group = connectorToolGroups.find((g) => g.label === current.label);
return <>{group?.tools.map((t) => renderToolRow(t.name))}</>;
}
// current.kind === "tools"
if (toolsLoading) {
return (
<div className="px-4 pt-3 pb-2">
<Skeleton className="h-3 w-16 mb-2" />
{["t1", "t2", "t3", "t4"].map((k) => (
<div key={k} className="flex items-center gap-3 py-2">
<Skeleton className="size-4 rounded shrink-0" />
<Skeleton className="h-3.5 flex-1" />
<Skeleton className="h-5 w-9 rounded-full shrink-0" />
</div>
))}
</div>
);
}
return (
<>
{regularToolGroups.map((group) => (
<div key={group.label}>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
{group.label}
</div>
{group.tools.map((t) => renderToolRow(t.name))}
</div>
))}
{connectorToolGroups.length > 0 && (
<div>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
Connector Actions
</div>
{connectorToolGroups.map((group) => {
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[group.connectorIcon ?? ""];
const toolNames = group.tools.map((t) => t.name);
const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
return (
<div key={group.label} className={ROW}>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3"
onClick={() => push({ kind: "toolGroup", label: group.label })}
>
{iconInfo ? (
<Image
src={iconInfo.src}
alt={iconInfo.alt}
width={18}
height={18}
className="size-[18px] shrink-0 select-none pointer-events-none"
draggable={false}
/>
) : (
<Wrench className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate text-left font-medium">
{group.label}
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</button>
<Switch
checked={!allDisabled}
onCheckedChange={() => onToggleToolGroup(toolNames)}
className="shrink-0"
/>
</div>
);
})}
</div>
)}
{otherToolGroup && (
<div>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
{otherToolGroup.label}
</div>
{otherToolGroup.tools.map((t) => renderToolRow(t.name))}
</div>
)}
</>
);
};
return (
<Drawer open={open} onOpenChange={handleOpenChange} shouldScaleBackground={false}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="h-[85vh] max-h-[85vh] z-80" overlayClassName="z-80">
<DrawerHandle />
<DrawerHeader className="flex flex-row items-center gap-1 px-2 pb-2 pt-1">
{stack.length > 1 ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0"
onClick={pop}
aria-label="Back"
>
<ChevronLeft className="size-5" />
</Button>
) : (
<span className="size-9 shrink-0" aria-hidden />
)}
<DrawerTitle className="flex-1 text-center text-base font-semibold">{title}</DrawerTitle>
<span className="size-9 shrink-0" aria-hidden />
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin pb-6">
<div
key={current.kind === "toolGroup" ? `toolGroup:${current.label}` : current.kind}
className={cn(
"animate-in fade-in-0 duration-200",
dirRef.current === "forward" ? "slide-in-from-right-4" : "slide-in-from-left-4"
)}
>
{renderBody()}
</div>
</div>
</DrawerContent>
</Drawer>
);
}

View file

@ -1,388 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useConnectorsSync } from "@/hooks/use-connectors-sync";
import { PICKER_CLOSE_EVENT, PICKER_OPEN_EVENT } from "@/hooks/use-google-picker";
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
import {
COMPOSIO_CONNECTORS,
OAUTH_CONNECTORS,
} from "./connector-popup/constants/connector-constants";
import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog";
import { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-connectors";
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
export interface ConnectorIndicatorHandle {
open: () => void;
}
interface ConnectorIndicatorProps {
showTrigger?: boolean;
}
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
(_props, ref) => {
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
// Real-time document type counts via Zero (updates instantly as docs are indexed)
const documentTypeCounts = useZeroDocumentTypeCounts(workspaceId);
// Read status inbox items from shared atom (populated by LayoutDataProvider)
// instead of creating a duplicate useInbox("status") hook.
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
const inboxItems = useMemo(
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
[statusInboxItems]
);
// Use the custom hook for dialog state management
const {
isOpen,
activeTab,
connectingId,
isScrolled,
searchQuery,
indexingConfig,
indexingConnector,
indexingConnectorConfig,
editingConnector,
connectingConnectorType,
isCreatingConnector,
startDate,
endDate,
isStartingIndexing,
isSaving,
isDisconnecting,
periodicEnabled,
frequencyMinutes,
enableVisionLlm,
allConnectors,
viewingAccountsType,
viewingMCPList,
isYouTubeView,
isFromOAuth,
setSearchQuery,
setStartDate,
setEndDate,
setPeriodicEnabled,
setFrequencyMinutes,
setEnableVisionLlm,
handleOpenChange,
handleTabChange,
handleScroll,
handleConnectOAuth,
handleConnectNonOAuth,
handleCreateWebcrawler,
handleCreateYouTubeCrawler,
handleSubmitConnectForm,
handleStartIndexing,
handleSkipIndexing,
handleStartEdit,
handleSaveConnector,
handleDisconnectConnector,
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,
handleViewAccountsList,
handleBackFromAccountsList,
handleBackFromMCPList,
handleAddNewMCPFromList,
handleQuickIndexConnector,
connectorConfig,
setConnectorConfig,
setIndexingConnectorConfig,
setConnectorName,
} = useConnectorDialog();
const [pickerOpen, setPickerOpen] = useState(false);
useEffect(() => {
const onOpen = () => setPickerOpen(true);
const onClose = () => setPickerOpen(false);
window.addEventListener(PICKER_OPEN_EVENT, onOpen);
window.addEventListener(PICKER_CLOSE_EVENT, onClose);
return () => {
window.removeEventListener(PICKER_OPEN_EVENT, onOpen);
window.removeEventListener(PICKER_CLOSE_EVENT, onClose);
};
}, []);
const {
connectors: connectorsFromSync = [],
loading: connectorsLoading,
error: connectorsError,
refreshConnectors: refreshConnectorsSync,
} = useConnectorsSync(workspaceId);
const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError);
const connectors = useSyncData ? connectorsFromSync : allConnectors || [];
const refreshConnectors = async () => {
if (useSyncData) {
await refreshConnectorsSync();
}
};
// Track indexing state locally - clears automatically when last_indexed_at changes via real-time sync
// Also clears when failed notifications are detected
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
connectors as SearchSourceConnector[],
inboxItems
);
// Get document types that have documents in the workspace
const activeDocumentTypes = documentTypeCounts
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
: [];
const hasConnectors = connectors.length > 0;
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
const totalSourceCount = connectors.length + activeDocumentTypes.length;
const activeConnectorsCount = connectors.length;
// Check which connectors are already connected
// Real-time connector updates via Zero sync
const connectedTypes = new Set<string>(
(connectors || []).map((c: SearchSourceConnector) => c.connector_type)
);
useImperativeHandle(ref, () => ({
open: () => handleOpenChange(true),
}));
if (!workspaceId) return null;
return (
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
{isOpen &&
createPortal(
<div
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
aria-hidden="true"
onClick={() => {
if (!pickerOpen) handleOpenChange(false);
}}
/>,
document.body
)}
<DialogContent
onFocusOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (pickerOpen) e.preventDefault();
}}
onPointerDownOutside={(e) => {
if (pickerOpen) e.preventDefault();
}}
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden ring-0 dark:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-accent [&>button]:hover:text-accent-foreground [&>button>svg]:size-5 select-none"
>
<DialogTitle className="sr-only">MCP Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && workspaceId ? (
<YouTubeCrawlerView workspaceId={workspaceId} onBack={handleBackFromYouTube} />
) : viewingMCPList ? (
<ConnectorAccountsListView
connectorType="MCP_CONNECTOR"
connectorTitle="MCP Connectors"
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromMCPList}
onManage={handleStartEdit}
onAddAccount={handleAddNewMCPFromList}
addButtonText="Add New MCP Server"
/>
) : viewingAccountsType ? (
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={(connectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onAddAccount={() => {
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
) ||
COMPOSIO_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
);
if (oauthConnector) {
handleConnectOAuth(oauthConnector);
}
}}
isConnecting={connectingId !== null}
/>
) : connectingConnectorType ? (
<ConnectorConnectView
connectorType={connectingConnectorType}
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
onBack={handleBackFromConnect}
isSubmitting={isCreatingConnector}
/>
) : editingConnector ? (
<ConnectorEditView
connector={{
...editingConnector,
config: connectorConfig || editingConnector.config,
name: editingConnector.name,
// Sync last_indexed_at with live data from real-time sync
last_indexed_at:
(connectors as SearchSourceConnector[]).find((c) => c.id === editingConnector.id)
?.last_indexed_at ?? editingConnector.last_indexed_at,
}}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableVisionLlm={enableVisionLlm}
isSaving={isSaving}
isDisconnecting={isDisconnecting}
isIndexing={indexingConnectorIds.has(editingConnector.id)}
workspaceId={workspaceId?.toString()}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableVisionLlmChange={setEnableVisionLlm}
onSave={() => {
startIndexing(editingConnector.id);
handleSaveConnector(() => refreshConnectors());
}}
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
onBack={handleBackFromEdit}
onQuickIndex={(() => {
const cfg = connectorConfig || editingConnector.config;
const isDriveOrOneDrive =
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
editingConnector.connector_type === "DROPBOX_CONNECTOR";
const hasDriveItems = isDriveOrOneDrive
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
((cfg?.selected_files as unknown[]) ?? []).length > 0
: true;
if (!hasDriveItems) return undefined;
return () => {
startIndexing(editingConnector.id);
handleQuickIndexConnector(
editingConnector.id,
editingConnector.connector_type,
stopIndexing,
startDate,
endDate
);
};
})()}
onConfigChange={setConnectorConfig}
onNameChange={setConnectorName}
/>
) : indexingConfig ? (
<IndexingConfigurationView
config={indexingConfig}
connector={
indexingConnector
? {
...indexingConnector,
config: indexingConnectorConfig || indexingConnector.config,
}
: undefined
}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableVisionLlm={enableVisionLlm}
isStartingIndexing={isStartingIndexing}
isFromOAuth={isFromOAuth}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableVisionLlmChange={setEnableVisionLlm}
onConfigChange={setIndexingConnectorConfig}
onStartIndexing={() => {
if (indexingConfig.connectorId) {
startIndexing(indexingConfig.connectorId);
}
handleStartIndexing(() => refreshConnectors());
}}
onSkip={handleSkipIndexing}
/>
) : (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex-1 flex flex-col min-h-0"
>
{/* Header */}
<ConnectorDialogHeader
activeTab={activeTab}
totalSourceCount={activeConnectorsCount}
searchQuery={searchQuery}
onTabChange={handleTabChange}
onSearchChange={setSearchQuery}
isScrolled={isScrolled}
/>
{/* Content */}
<div className="flex-1 min-h-0 relative overflow-hidden">
<div className="h-full overflow-y-auto" onScroll={handleScroll}>
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
<TabsContent value="all" className="m-0">
<AllConnectorsTab
searchQuery={searchQuery}
workspaceId={workspaceId}
connectedTypes={connectedTypes}
connectingId={connectingId}
allConnectors={connectors}
documentTypeCounts={documentTypeCounts}
indexingConnectorIds={indexingConnectorIds}
onConnectOAuth={handleConnectOAuth}
onConnectNonOAuth={handleConnectNonOAuth}
onCreateWebcrawler={handleCreateWebcrawler}
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/>
</TabsContent>
<ActiveConnectorsTab
searchQuery={searchQuery}
hasSources={hasSources}
totalSourceCount={totalSourceCount}
activeDocumentTypes={activeDocumentTypes}
connectors={connectors as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onTabChange={handleTabChange}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/>
</div>
</div>
{/* Bottom fade shadow */}
<div className="absolute bottom-0 left-0 right-0 h-7 bg-linear-to-t from-popover via-popover/80 to-transparent pointer-events-none z-10" />
</div>
</Tabs>
)}
</DialogContent>
</Dialog>
);
}
);
ConnectorIndicator.displayName = "ConnectorIndicator";

View file

@ -23,7 +23,6 @@ interface ConnectorCardProps {
accountCount?: number; accountCount?: number;
connectorCount?: number; connectorCount?: number;
isIndexing?: boolean; isIndexing?: boolean;
deprecated?: boolean;
onConnect?: () => void; onConnect?: () => void;
onManage?: () => void; onManage?: () => void;
} }
@ -53,15 +52,11 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
accountCount, accountCount,
connectorCount, connectorCount,
isIndexing = false, isIndexing = false,
deprecated = false,
onConnect, onConnect,
onManage, onManage,
}) => { }) => {
const isMCP = connectorType === EnumConnectorName.MCP_CONNECTOR; const isMCP = connectorType === EnumConnectorName.MCP_CONNECTOR;
const isLive = !!connectorType && LIVE_CONNECTOR_TYPES.has(connectorType); const isLive = !!connectorType && LIVE_CONNECTOR_TYPES.has(connectorType);
// Deprecated connectors can no longer be connected, but existing rows stay
// manageable (so users can disconnect them).
const isDeprecatedForConnect = deprecated && !isConnected;
// Get connector status // Get connector status
const { getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings } = const { getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings } =
useConnectorStatus(); useConnectorStatus();
@ -78,10 +73,6 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
return null; return null;
} }
if (isDeprecatedForConnect) {
return "Deprecated. No longer available to connect.";
}
return description; return description;
}; };
@ -102,16 +93,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5" : "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
)} )}
> >
{isMCP ? ( {connectorType ? (
<span
aria-hidden="true"
className="size-6 bg-current"
style={{
mask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
WebkitMask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
}}
/>
) : connectorType ? (
getConnectorIcon(connectorType, "size-6") getConnectorIcon(connectorType, "size-6")
) : id === "youtube-crawler" ? ( ) : id === "youtube-crawler" ? (
<IconBrandYoutube className="size-6" /> <IconBrandYoutube className="size-6" />
@ -169,20 +151,18 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
!isConnected && "shadow-xs" !isConnected && "shadow-xs"
)} )}
onClick={isConnected ? onManage : onConnect} onClick={isConnected ? onManage : onConnect}
disabled={isConnecting || !isEnabled || isDeprecatedForConnect} disabled={isConnecting || !isEnabled}
> >
<span className={isConnecting ? "opacity-0" : ""}> <span className={isConnecting ? "opacity-0" : ""}>
{isDeprecatedForConnect {!isEnabled
? "Deprecated" ? "Unavailable"
: !isEnabled : isConnected
? "Unavailable" ? "Manage"
: isConnected : id === "youtube-crawler"
? "Manage" ? "Add"
: id === "youtube-crawler" : connectorType
? "Add" ? "Connect"
: connectorType : "Add"}
? "Connect"
: "Add"}
</span> </span>
{isConnecting && <Spinner size="xs" className="absolute" />} {isConnecting && <Spinner size="xs" className="absolute" />}
</Button> </Button>

View file

@ -139,7 +139,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3"> <Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Server className="h-4 w-4 shrink-0" /> <Server className="h-4 w-4 shrink-0" />
<AlertDescription className="text-[10px] sm:text-xs"> <AlertDescription className="text-[10px] sm:text-xs">
Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate Connect to a Model Context Protocol server. Each MCP server is added as a separate
connector. connector.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@ -275,8 +275,8 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
<div className="mt-3 pt-3 border-t border-green-500/20"> <div className="mt-3 pt-3 border-t border-green-500/20">
<p className="font-semibold mb-2">Available tools:</p> <p className="font-semibold mb-2">Available tools:</p>
<ul className="list-disc list-inside text-xs space-y-0.5"> <ul className="list-disc list-inside text-xs space-y-0.5">
{testResult.tools.map((tool, i) => ( {testResult.tools.map((tool) => (
<li key={i}>{tool.name}</li> <li key={tool.name}>{tool.name}</li>
))} ))}
</ul> </ul>
</div> </div>

View file

@ -1,14 +0,0 @@
"use client";
import type { FC } from "react";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
interface ComposioCalendarConfigProps {
connector: SearchSourceConnector;
onConfigChange?: (config: Record<string, unknown>) => void;
onNameChange?: (name: string) => void;
}
export const ComposioCalendarConfig: FC<ComposioCalendarConfigProps> = () => {
return <div className="space-y-6" />;
};

View file

@ -1,14 +0,0 @@
"use client";
import type { FC } from "react";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
interface ComposioGmailConfigProps {
connector: SearchSourceConnector;
onConfigChange?: (config: Record<string, unknown>) => void;
onNameChange?: (name: string) => void;
}
export const ComposioGmailConfig: FC<ComposioGmailConfigProps> = () => {
return <div className="space-y-6" />;
};

View file

@ -0,0 +1,48 @@
"use client";
import { CheckCircle2 } from "lucide-react";
import type { FC } from "react";
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
import {
COMPOSIO_CONNECTORS,
CRAWLERS,
OAUTH_CONNECTORS,
OTHER_CONNECTORS,
} from "../../constants/connector-constants";
import type { ConnectorConfigProps } from "../index";
// Catalog descriptions keyed by connector type, reused as the capability line so
// the copy stays in sync with the catalog cards.
const DESCRIPTION_BY_TYPE = new Map<string, string>(
[...OAUTH_CONNECTORS, ...COMPOSIO_CONNECTORS, ...OTHER_CONNECTORS, ...CRAWLERS].map((c) => [
c.connectorType,
c.description,
])
);
/**
* Fallback manage view for live connectors that are neither MCP-backed nor have
* a dedicated config component (native/Composio Gmail & Calendar). There is
* nothing to configure, so we just confirm the connection and echo what the
* agent can do no Trusted Tools (that feature is MCP-only, see the backend
* `_ensure_mcp_connector_for_user`).
*/
export const LiveConnectorConnectedCard: FC<ConnectorConfigProps> = ({ connector }) => {
const capability =
DESCRIPTION_BY_TYPE.get(connector.connector_type) ??
`connect to ${getConnectorTypeDisplay(connector.connector_type)}`;
return (
<div className="space-y-6">
<div className="rounded-xl border border-border bg-emerald-500/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/10 shrink-0 mt-0.5">
<CheckCircle2 className="size-4 text-emerald-500" />
</div>
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Connected</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">{capability}.</p>
</div>
</div>
</div>
);
};

View file

@ -55,12 +55,8 @@ const configMap: Record<string, () => Promise<{ default: FC<ConnectorConfigProps
import("./components/obsidian-config").then((m) => ({ default: m.ObsidianConfig })), import("./components/obsidian-config").then((m) => ({ default: m.ObsidianConfig })),
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: () => COMPOSIO_GOOGLE_DRIVE_CONNECTOR: () =>
import("./components/composio-drive-config").then((m) => ({ default: m.ComposioDriveConfig })), import("./components/composio-drive-config").then((m) => ({ default: m.ComposioDriveConfig })),
COMPOSIO_GMAIL_CONNECTOR: () => // Composio Gmail/Calendar have nothing to configure; the edit view falls back
import("./components/composio-gmail-config").then((m) => ({ default: m.ComposioGmailConfig })), // to LiveConnectorConnectedCard for live connectors without a config here.
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: () =>
import("./components/composio-calendar-config").then((m) => ({
default: m.ComposioCalendarConfig,
})),
}; };
const componentCache = new Map<string, ConnectorConfigComponent>(); const componentCache = new Map<string, ConnectorConfigComponent>();

View file

@ -89,7 +89,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
return ( return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden"> <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Header */} {/* Header */}
<div className="flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10"> <div className="flex-shrink-0">
<Button <Button
variant="ghost" variant="ghost"
type="button" type="button"
@ -123,7 +123,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
{/* Form Content - Scrollable */} {/* Form Content - Scrollable */}
<div <div
ref={formContainerRef} ref={formContainerRef}
className="connector-connect-form-root flex-1 min-h-0 overflow-y-auto px-6 sm:px-12" className="connector-connect-form-root flex-1 min-h-0 overflow-y-auto"
> >
<ConnectFormComponent <ConnectFormComponent
onSubmit={onSubmit} onSubmit={onSubmit}
@ -134,15 +134,15 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
</div> </div>
{/* Fixed Footer - Action buttons */} {/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-popover"> <div className="flex-shrink-0 flex items-center justify-end py-6 bg-transparent">
<Button {/* <Button
variant="ghost" variant="ghost"
onClick={onBack} onClick={onBack}
disabled={isSubmitting} disabled={isSubmitting}
className="text-xs sm:text-sm" className="text-xs sm:text-sm"
> >
Cancel Cancel
</Button> </Button> */}
<Button <Button
type="button" type="button"
onClick={handleFormSubmit} onClick={handleFormSubmit}

View file

@ -20,6 +20,7 @@ import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import { VisionLLMConfig } from "../../components/vision-llm-config"; import { VisionLLMConfig } from "../../components/vision-llm-config";
import { LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants"; import { LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab"; import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { LiveConnectorConnectedCard } from "../components/live-connector-connected-card";
import { MCPServiceConfig } from "../components/mcp-service-config"; import { MCPServiceConfig } from "../components/mcp-service-config";
import { getConnectorConfigComponent } from "../index"; import { getConnectorConfigComponent } from "../index";
@ -124,8 +125,13 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
// Get connector-specific config component (MCP-backed connectors use a generic view) // Get connector-specific config component (MCP-backed connectors use a generic view)
const ConnectorConfigComponent = useMemo(() => { const ConnectorConfigComponent = useMemo(() => {
if (isMCPBacked) return MCPServiceConfig; if (isMCPBacked) return MCPServiceConfig;
return getConnectorConfigComponent(connector.connector_type); // Live connectors without a dedicated config (native/Composio Gmail &
}, [connector.connector_type, isMCPBacked]); // Calendar) fall back to a simple "Connected" card instead of a blank body.
return (
getConnectorConfigComponent(connector.connector_type) ??
(isLive ? LiveConnectorConnectedCard : null)
);
}, [connector.connector_type, isMCPBacked, isLive]);
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [hasMoreContent, setHasMoreContent] = useState(false); const [hasMoreContent, setHasMoreContent] = useState(false);
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false); const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
@ -201,7 +207,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{/* Fixed Header */} {/* Fixed Header */}
<div <div
className={cn( className={cn(
"flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10", "flex-shrink-0 transition-shadow duration-200 relative z-10",
isScrolled && "shadow-sm" isScrolled && "shadow-sm"
)} )}
> >
@ -262,7 +268,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
<div className="flex-1 min-h-0 relative overflow-hidden"> <div className="flex-1 min-h-0 relative overflow-hidden">
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
className="h-full overflow-y-auto px-6 sm:px-12" className="h-full overflow-y-auto"
onScroll={handleScroll} onScroll={handleScroll}
> >
<div className="space-y-6 pb-6 pt-2"> <div className="space-y-6 pb-6 pt-2">
@ -362,7 +368,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
</div> </div>
{/* Fixed Footer - Action buttons */} {/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 sm:gap-0 px-6 sm:px-12 py-6 sm:py-6 bg-popover"> <div className="flex-shrink-0 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 sm:gap-0 py-6 sm:py-6 bg-transparent">
{showDisconnectConfirm ? ( {showDisconnectConfirm ? (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 sm:flex-initial"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 sm:flex-initial">
<span className="text-xs sm:text-sm text-muted-foreground sm:whitespace-nowrap"> <span className="text-xs sm:text-sm text-muted-foreground sm:whitespace-nowrap">

View file

@ -120,7 +120,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
{/* Fixed Header */} {/* Fixed Header */}
<div <div
className={cn( className={cn(
"shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10", "shrink-0 transition-shadow duration-200 relative z-10",
isScrolled && "shadow-sm" isScrolled && "shadow-sm"
)} )}
> >
@ -166,7 +166,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
<div className="flex-1 min-h-0 relative overflow-hidden"> <div className="flex-1 min-h-0 relative overflow-hidden">
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
className="h-full overflow-y-auto px-6 sm:px-12" className="h-full overflow-y-auto"
onScroll={handleScroll} onScroll={handleScroll}
> >
<div className="space-y-6 pb-6 pt-2"> <div className="space-y-6 pb-6 pt-2">
@ -240,7 +240,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
</div> </div>
{/* Fixed Footer - Action buttons */} {/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex items-center justify-end px-6 sm:px-12 py-6 bg-popover"> <div className="flex-shrink-0 flex items-center justify-end py-6 bg-transparent">
{isLive ? ( {isLive ? (
<Button onClick={onSkip} className="text-xs sm:text-sm"> <Button onClick={onSkip} className="text-xs sm:text-sm">
Done Done

View file

@ -0,0 +1,483 @@
"use client";
import { useAtomValue } from "jotai";
import { ChevronRight, LayoutGrid, Search, TriangleAlert, X } from "lucide-react";
import { type ReactNode, useMemo, useState } from "react";
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useConnectorsSync } from "@/hooks/use-connectors-sync";
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
import { cn } from "@/lib/utils";
import { ConnectorConnectView } from "./connector-configs/views/connector-connect-view";
import { ConnectorEditView } from "./connector-configs/views/connector-edit-view";
import { IndexingConfigurationView } from "./connector-configs/views/indexing-configuration-view";
import {
COMPOSIO_CONNECTORS,
getConnectorTitle,
OAUTH_CONNECTORS,
} from "./constants/connector-constants";
import { type ConnectorRow, useConnectorRows } from "./hooks/use-connector-rows";
import { useConnectorDialog } from "./hooks/use-connector-dialog";
import { AllConnectorsTab } from "./tabs/all-connectors-tab";
import { ConnectorAccountsListView } from "./views/connector-accounts-list-view";
import { YouTubeCrawlerView } from "./views/youtube-crawler-view";
/**
* Connector management surface (single `/connectors` route) rendered inside the
* workspace panel. Stateful masterdetail:
* - sub-rail: Overview + a flat "Your connectors" list (only what's connected,
* each with a live status glyph) + an "Add MCP server" action;
* - detail pane: the flat catalog (search + cards) OR one of the existing flow
* views (connect / edit / indexing / accounts / YouTube), reused verbatim
* from the former dialog.
*
* Unconnected connectors are one click from the catalog cards, so they never
* clutter the rail. The hook's internal `isOpen`/`connectorDialogOpenAtom` is
* inert on a route and ignored.
*/
export function ConnectorsSection() {
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
const documentTypeCounts = useZeroDocumentTypeCounts(workspaceId);
const [drawerOpen, setDrawerOpen] = useState(false);
const {
connectingId,
searchQuery,
indexingConfig,
indexingConnector,
indexingConnectorConfig,
editingConnector,
connectingConnectorType,
isCreatingConnector,
startDate,
endDate,
isStartingIndexing,
isSaving,
isDisconnecting,
periodicEnabled,
frequencyMinutes,
enableVisionLlm,
allConnectors,
viewingAccountsType,
viewingMCPList,
isYouTubeView,
isFromOAuth,
setSearchQuery,
setStartDate,
setEndDate,
setPeriodicEnabled,
setFrequencyMinutes,
setEnableVisionLlm,
handleOpenChange,
handleConnectOAuth,
handleConnectNonOAuth,
handleCreateWebcrawler,
handleCreateYouTubeCrawler,
handleSubmitConnectForm,
handleStartIndexing,
handleSkipIndexing,
handleStartEdit,
handleSaveConnector,
handleDisconnectConnector,
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,
handleViewAccountsList,
handleBackFromAccountsList,
handleViewMCPList,
handleBackFromMCPList,
handleAddNewMCPFromList,
handleQuickIndexConnector,
connectorConfig,
setConnectorConfig,
setIndexingConnectorConfig,
setConnectorName,
} = useConnectorDialog();
const {
connectors: connectorsFromSync = [],
loading: connectorsLoading,
error: connectorsError,
refreshConnectors: refreshConnectorsSync,
} = useConnectorsSync(workspaceId);
const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError);
const connectors = (useSyncData ? connectorsFromSync : allConnectors || []) as SearchSourceConnector[];
const refreshConnectors = async () => {
if (useSyncData) {
await refreshConnectorsSync();
}
};
// "Your connectors" rows with live indexing health, plus the shared indexing
// controls the flow views reuse (single source of truth via useConnectorRows).
const {
rows: connectedRows,
indexingConnectorIds,
startIndexing,
stopIndexing,
} = useConnectorRows(connectors);
const connectedTypes = useMemo(
() => new Set<string>(connectors.map((c) => c.connector_type)),
[connectors]
);
if (!workspaceId) return null;
const activeConnectorType = editingConnector
? editingConnector.connector_type
: connectingConnectorType ||
viewingAccountsType?.connectorType ||
(viewingMCPList ? EnumConnectorName.MCP_CONNECTOR : null);
const flowView = ((): ReactNode => {
if (isYouTubeView) {
return <YouTubeCrawlerView workspaceId={workspaceId} onBack={handleBackFromYouTube} />;
}
if (viewingMCPList) {
return (
<ConnectorAccountsListView
connectorType="MCP_CONNECTOR"
connectorTitle="MCP Connectors"
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromMCPList}
onManage={handleStartEdit}
onAddAccount={handleAddNewMCPFromList}
addButtonText="Add New MCP Server"
/>
);
}
if (viewingAccountsType) {
return (
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={connectors}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onAddAccount={() => {
const oauthConnector =
OAUTH_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
) ||
COMPOSIO_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
);
if (oauthConnector) {
handleConnectOAuth(oauthConnector);
}
}}
isConnecting={connectingId !== null}
/>
);
}
if (connectingConnectorType) {
return (
<ConnectorConnectView
connectorType={connectingConnectorType}
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
onBack={handleBackFromConnect}
isSubmitting={isCreatingConnector}
/>
);
}
if (editingConnector) {
return (
<ConnectorEditView
connector={{
...editingConnector,
config: connectorConfig || editingConnector.config,
name: editingConnector.name,
last_indexed_at:
connectors.find((c) => c.id === editingConnector.id)?.last_indexed_at ??
editingConnector.last_indexed_at,
}}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableVisionLlm={enableVisionLlm}
isSaving={isSaving}
isDisconnecting={isDisconnecting}
isIndexing={indexingConnectorIds.has(editingConnector.id)}
workspaceId={workspaceId?.toString()}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableVisionLlmChange={setEnableVisionLlm}
onSave={() => {
startIndexing(editingConnector.id);
handleSaveConnector(() => refreshConnectors());
}}
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
onBack={handleBackFromEdit}
onQuickIndex={(() => {
const cfg = connectorConfig || editingConnector.config;
const isDriveOrOneDrive =
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
editingConnector.connector_type === "DROPBOX_CONNECTOR";
const hasDriveItems = isDriveOrOneDrive
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
((cfg?.selected_files as unknown[]) ?? []).length > 0
: true;
if (!hasDriveItems) return undefined;
return () => {
startIndexing(editingConnector.id);
handleQuickIndexConnector(
editingConnector.id,
editingConnector.connector_type,
stopIndexing,
startDate,
endDate
);
};
})()}
onConfigChange={setConnectorConfig}
onNameChange={setConnectorName}
/>
);
}
if (indexingConfig) {
return (
<IndexingConfigurationView
config={indexingConfig}
connector={
indexingConnector
? {
...indexingConnector,
config: indexingConnectorConfig || indexingConnector.config,
}
: undefined
}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableVisionLlm={enableVisionLlm}
isStartingIndexing={isStartingIndexing}
isFromOAuth={isFromOAuth}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableVisionLlmChange={setEnableVisionLlm}
onConfigChange={setIndexingConnectorConfig}
onStartIndexing={() => {
if (indexingConfig.connectorId) {
startIndexing(indexingConfig.connectorId);
}
handleStartIndexing(() => refreshConnectors());
}}
onSkip={handleSkipIndexing}
/>
);
}
return null;
})();
const isFlowActive = flowView !== null;
const detailTitle =
isFlowActive && activeConnectorType ? getConnectorTitle(activeConnectorType) : "Overview";
const hasConnected = connectedRows.length > 0;
// Rail navigation: clear any open flow first (handleOpenChange(false) is the
// hook's full reset), then apply the target management action.
const resetFlow = () => handleOpenChange(false);
const goOverview = () => {
setDrawerOpen(false);
resetFlow();
};
const manageRow = (row: ConnectorRow) => {
setDrawerOpen(false);
resetFlow();
if (row.type === EnumConnectorName.MCP_CONNECTOR) {
handleViewMCPList();
return;
}
if (row.accountCount > 1) {
handleViewAccountsList(row.type, row.title);
return;
}
handleStartEdit(row.connectors[0]);
};
const addMcpServer = () => {
setDrawerOpen(false);
resetFlow();
handleConnectNonOAuth?.(EnumConnectorName.MCP_CONNECTOR);
};
const navBtnClass = (active: boolean) =>
cn(
"inline-flex w-full 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",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
);
const railLabel = (text: string) => (
<p className="px-3 pb-1 pt-1 text-xs font-semibold text-muted-foreground">{text}</p>
);
const renderNav = () => (
<>
<button type="button" onClick={goOverview} className={navBtnClass(!isFlowActive)}>
<LayoutGrid className="h-4 w-4" />
<span className="min-w-0 truncate">Overview</span>
</button>
{hasConnected && (
<>
<Separator className="my-3 bg-border" />
{railLabel("Your connectors")}
<div className="flex flex-col gap-0.5">
{connectedRows.map((row) => {
const isActive = isFlowActive && activeConnectorType === row.type;
return (
<button
key={row.type}
type="button"
onClick={() => manageRow(row)}
className={cn(
"inline-flex w-full items-center justify-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors duration-150 focus:outline-none",
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<span className="flex size-4 shrink-0 items-center justify-center">
{getConnectorIcon(row.type, "size-4")}
</span>
<span className="min-w-0 truncate">{row.title}</span>
{row.health === "syncing" ? (
<Spinner size="xs" className="ml-auto shrink-0" />
) : row.health === "failed" ? (
<TriangleAlert
className="ml-auto h-3.5 w-3.5 shrink-0 text-destructive"
aria-label={row.errorMessage ?? "Indexing failed"}
/>
) : row.accountCount > 1 ? (
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-muted-foreground">
{row.accountCount}
</span>
) : null}
</button>
);
})}
</div>
</>
)}
<Separator className="my-3 bg-border" />
<button type="button" onClick={addMcpServer} className={navBtnClass(false)}>
{getConnectorIcon(EnumConnectorName.MCP_CONNECTOR, "h-4 w-4")}
<span className="min-w-0 truncate">Add MCP server</span>
</button>
</>
);
const catalog = (
<div className="flex flex-col gap-8">
<div className="w-full">
<div className="relative">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4.5 w-4.5 text-muted-foreground" />
<Input
type="text"
autoComplete="off"
placeholder="Search connectors"
className="h-10 border-0 bg-muted pl-10 pr-9 text-base shadow-none"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 h-7 w-7 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label="Clear search"
>
<X className="h-4.5 w-4.5" />
</Button>
)}
</div>
</div>
<AllConnectorsTab
searchQuery={searchQuery}
workspaceId={workspaceId}
connectedTypes={connectedTypes}
connectingId={connectingId}
allConnectors={connectors}
documentTypeCounts={documentTypeCounts}
indexingConnectorIds={indexingConnectorIds}
onConnectOAuth={handleConnectOAuth}
onConnectNonOAuth={handleConnectNonOAuth}
onCreateWebcrawler={handleCreateWebcrawler}
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/>
</div>
);
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-xl font-semibold tracking-tight text-foreground md:text-2xl">
Connectors
</h1>
<nav className="hidden flex-col gap-0.5 md:flex">{renderNav()}</nav>
<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">{detailTitle}</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">Connectors navigation</DrawerTitle>
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-4">
{renderNav()}
</nav>
</DrawerContent>
</Drawer>
</div>
<div className="min-w-0 flex-1">
<div className="hidden md:block">
<h2 className="text-lg font-semibold">{detailTitle}</h2>
<Separator className="mt-4 bg-border" />
</div>
<div className="min-w-0 pt-4 md:max-w-5xl">{isFlowActive ? flowView : catalog}</div>
</div>
</section>
);
}

View file

@ -1,42 +1,38 @@
import { EnumConnectorName } from "@/contracts/enums/connector"; import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
/** /**
* Connectors retired during the MCP migration (no viable official MCP server). * Connectors retired during the MCP migration (no viable official MCP server).
* The catalog card is shown disabled with a "Deprecated" badge so existing *
* users understand why; the backend `/add` routes also refuse with HTTP 410. * UI behavior: a deprecated connector is hidden from the catalog entirely unless
* Reinstate by removing the type here and in the backend * the user already connected it before deprecation in that case it still shows
* `DEPRECATED_CONNECTOR_TYPES` if demand returns. * as a normal card (inline, no "Deprecated" section) so they can manage/disconnect
* it. New connections are refused by the backend `/add` routes with HTTP 410.
* Reinstate by removing the type here and in the backend `DEPRECATED_CONNECTOR_TYPES`
* if demand returns.
*
* Full deprecated list (for devs):
* - DISCORD_CONNECTOR, TEAMS_CONNECTOR, LUMA_CONNECTOR
* - Search APIs (superseded by the Google-only web-search consolidation; the
* google_search subagent handles public web search, and Tavily/Linkup can
* still be added via the generic Custom MCP connector with API-key headers):
* TAVILY_API, SEARXNG_API, LINKUP_API, BAIDU_SEARCH_API
* - Legacy content crawlers/search (superseded by the file Import menu and
* hosted MCP tooling): YOUTUBE_CONNECTOR, WEBCRAWLER_CONNECTOR, ELASTICSEARCH_CONNECTOR
*/ */
export const DEPRECATED_CONNECTOR_TYPES = new Set<string>([ export const DEPRECATED_CONNECTOR_TYPES = new Set<string>([
EnumConnectorName.DISCORD_CONNECTOR, EnumConnectorName.DISCORD_CONNECTOR,
EnumConnectorName.TEAMS_CONNECTOR, EnumConnectorName.TEAMS_CONNECTOR,
EnumConnectorName.LUMA_CONNECTOR, EnumConnectorName.LUMA_CONNECTOR,
// Search APIs retired by the Google-only web-search consolidation. Public
// web search now runs through the google_search subagent; Tavily/Linkup can
// still be added via the generic Custom MCP connector (API-key headers).
EnumConnectorName.TAVILY_API, EnumConnectorName.TAVILY_API,
EnumConnectorName.SEARXNG_API, EnumConnectorName.SEARXNG_API,
EnumConnectorName.LINKUP_API, EnumConnectorName.LINKUP_API,
EnumConnectorName.BAIDU_SEARCH_API, EnumConnectorName.BAIDU_SEARCH_API,
// Legacy content crawlers/search retired in favor of the file Import menu and
// hosted MCP tooling. Existing rows stay manageable; new connections refused.
EnumConnectorName.YOUTUBE_CONNECTOR, EnumConnectorName.YOUTUBE_CONNECTOR,
EnumConnectorName.WEBCRAWLER_CONNECTOR, EnumConnectorName.WEBCRAWLER_CONNECTOR,
EnumConnectorName.ELASTICSEARCH_CONNECTOR, EnumConnectorName.ELASTICSEARCH_CONNECTOR,
]); ]);
/**
* File-import connectors surfaced through the Documents sidebar "Import" menu
* instead of the external-MCP connector catalog. They index remote files into
* the knowledge base, so they belong with uploads, not with live MCP tools.
*/
export const IMPORT_CONNECTOR_TYPES = new Set<string>([
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
EnumConnectorName.ONEDRIVE_CONNECTOR,
EnumConnectorName.DROPBOX_CONNECTOR,
]);
/** /**
* Connectors that operate in real time (no background indexing). * Connectors that operate in real time (no background indexing).
* Used to adjust UI: hide sync controls, show "Connected" instead of doc counts. * Used to adjust UI: hide sync controls, show "Connected" instead of doc counts.
@ -283,36 +279,35 @@ export function getConnectorTitle(connectorType: string): string {
); );
} }
/** One row per connected connector type (multiple accounts collapsed into one). */
export interface ConnectorTypeRow {
type: string;
title: string;
connectors: SearchSourceConnector[];
accountCount: number;
}
/** /**
* Primary way a user interacts with a connector. * Collapse a flat connector list into one row per `connector_type`, titled for
* Drives the two top-level groupings in the connector catalog UI. * display (MCP collapses to "MCP Servers") and sorted alphabetically. Shared by
* the connectors panel's "Your connectors" rail and the composer add-menu so the
* two lists never drift; callers layer their own extras (e.g. health) on top.
*/ */
export type ConnectorCategory = "knowledge_base" | "tools_live"; export function groupConnectorsByType(connectors: SearchSourceConnector[]): ConnectorTypeRow[] {
const byType = new Map<string, SearchSourceConnector[]>();
export const CONNECTOR_CATEGORY_LABELS: Record<ConnectorCategory, string> = { for (const c of connectors) {
knowledge_base: "Knowledge Base", const arr = byType.get(c.connector_type) ?? [];
tools_live: "Tools & Live Sources", arr.push(c);
}; byType.set(c.connector_type, arr);
}
const KNOWLEDGE_BASE_CONNECTOR_TYPES = new Set<string>([ return [...byType.entries()]
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR, .map(([type, list]) => ({
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR, type,
EnumConnectorName.ONEDRIVE_CONNECTOR, title: type === EnumConnectorName.MCP_CONNECTOR ? "MCP Servers" : getConnectorTitle(type),
EnumConnectorName.DROPBOX_CONNECTOR, connectors: list,
EnumConnectorName.NOTION_CONNECTOR, accountCount: list.length,
EnumConnectorName.CONFLUENCE_CONNECTOR, }))
EnumConnectorName.YOUTUBE_CONNECTOR, .sort((a, b) => a.title.localeCompare(b.title));
EnumConnectorName.WEBCRAWLER_CONNECTOR,
EnumConnectorName.BOOKSTACK_CONNECTOR,
EnumConnectorName.GITHUB_CONNECTOR,
EnumConnectorName.ELASTICSEARCH_CONNECTOR,
EnumConnectorName.CIRCLEBACK_CONNECTOR,
EnumConnectorName.OBSIDIAN_CONNECTOR,
]);
/** Unmapped connectors surface under Tools & Live Sources. */
export function getConnectorCategory(connectorType: string): ConnectorCategory {
return KNOWLEDGE_BASE_CONNECTOR_TYPES.has(connectorType) ? "knowledge_base" : "tools_live";
} }
// Composio Toolkits (available integrations via Composio) // Composio Toolkits (available integrations via Composio)

View file

@ -21,8 +21,6 @@ import { OAUTH_RESULT_COOKIE, parseOAuthCallbackResult } from "@/contracts/types
import { authenticatedFetch } from "@/lib/auth-fetch"; import { authenticatedFetch } from "@/lib/auth-fetch";
import { buildBackendUrl } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { import {
trackConnectorConnected,
trackConnectorDeleted,
trackConnectorSetupFailure, trackConnectorSetupFailure,
trackConnectorSetupStarted, trackConnectorSetupStarted,
trackIndexWithDateRangeOpened, trackIndexWithDateRangeOpened,
@ -296,11 +294,9 @@ export const useConnectorDialog = () => {
if (newConnector && oauthConnector) { if (newConnector && oauthConnector) {
const connectorValidation = searchSourceConnector.safeParse(newConnector); const connectorValidation = searchSourceConnector.safeParse(newConnector);
if (connectorValidation.success) { if (connectorValidation.success) {
trackConnectorConnected( // connector_connected is now emitted server-side (after_insert
Number(workspaceId), // listener in search_source_connectors_routes.py) — covers the
oauthConnector.connectorType, // OAuth callback redirect the frontend often never observes.
newConnector.id
);
const isLiveConnector = LIVE_CONNECTOR_TYPES.has(oauthConnector.connectorType); const isLiveConnector = LIVE_CONNECTOR_TYPES.has(oauthConnector.connectorType);
@ -436,12 +432,7 @@ export const useConnectorDialog = () => {
if (connector) { if (connector) {
const connectorValidation = searchSourceConnector.safeParse(connector); const connectorValidation = searchSourceConnector.safeParse(connector);
if (connectorValidation.success) { if (connectorValidation.success) {
// Track webcrawler connector connected // connector_connected is now emitted server-side (after_insert).
trackConnectorConnected(
Number(workspaceId),
EnumConnectorName.WEBCRAWLER_CONNECTOR,
connector.id
);
const config = validateIndexingConfigState({ const config = validateIndexingConfigState({
connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR, connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR,
@ -540,8 +531,7 @@ export const useConnectorDialog = () => {
// Store connectingConnectorType before clearing it // Store connectingConnectorType before clearing it
const currentConnectorType = connectingConnectorType; const currentConnectorType = connectingConnectorType;
// Track connector connected event for non-OAuth connectors // connector_connected is now emitted server-side (after_insert).
trackConnectorConnected(Number(workspaceId), currentConnectorType, connector.id);
// Find connector title from constants // Find connector title from constants
const connectorInfo = OTHER_CONNECTORS.find( const connectorInfo = OTHER_CONNECTORS.find(
@ -1229,12 +1219,8 @@ export const useConnectorDialog = () => {
id: editingConnector.id, id: editingConnector.id,
}); });
// Track connector deleted event // connector_deleted is now emitted server-side
trackConnectorDeleted( // (search_source_connectors_routes.delete_search_source_connector).
Number(workspaceId),
editingConnector.connector_type,
editingConnector.id
);
toast.success( toast.success(
editingConnector.connector_type === "MCP_CONNECTOR" editingConnector.connector_type === "MCP_CONNECTOR"

View file

@ -0,0 +1,78 @@
"use client";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { connectorIndexingMetadata } from "@/contracts/types/inbox.types";
import { type ConnectorTypeRow, groupConnectorsByType } from "../constants/connector-constants";
import { useIndexingConnectors } from "./use-indexing-connectors";
/** Health of a connected connector type, derived from indexing + inbox state. */
export type ConnectorHealth = "syncing" | "failed" | "ok";
/** A grouped connector row enriched with live indexing health. */
export interface ConnectorRow extends ConnectorTypeRow {
health: ConnectorHealth;
errorMessage?: string;
}
/**
* Single source of truth for the "Your connectors" list: groups the connectors
* by type and derives each row's health (syncing > failed > ok) from the status
* inbox and optimistic indexing state. Shared by the connectors panel rail and
* the composer add-menu so both render identical status glyphs.
*
* Also returns the underlying indexing controls so the panel can reuse them for
* its flow views instead of instantiating `useIndexingConnectors` twice.
*/
export function useConnectorRows(connectors: SearchSourceConnector[]) {
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
const inboxItems = useMemo(
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
[statusInboxItems]
);
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
connectors,
inboxItems
);
// Latest indexing status per connector id, parsed from the status inbox.
const statusByConnectorId = useMemo(() => {
const map = new Map<number, { status?: string; error?: string; at: string }>();
for (const item of inboxItems) {
const parsed = connectorIndexingMetadata.safeParse(item.metadata);
if (!parsed.success) continue;
const at = item.updated_at ?? item.created_at;
const prev = map.get(parsed.data.connector_id);
if (!prev || at > prev.at) {
map.set(parsed.data.connector_id, {
status: parsed.data.status,
error: parsed.data.error_message ?? undefined,
at,
});
}
}
return map;
}, [inboxItems]);
const rows = useMemo<ConnectorRow[]>(() => {
return groupConnectorsByType(connectors).map((row) => {
const ids = row.connectors.map((c) => c.id);
const syncing = ids.some(
(id) => indexingConnectorIds.has(id) || statusByConnectorId.get(id)?.status === "in_progress"
);
const failedEntry = syncing
? undefined
: ids.map((id) => statusByConnectorId.get(id)).find((s) => s?.status === "failed");
return {
...row,
health: syncing ? "syncing" : failedEntry ? "failed" : "ok",
errorMessage: failedEntry?.error,
} satisfies ConnectorRow;
});
}, [connectors, indexingConnectorIds, statusByConnectorId]);
return { rows, indexingConnectorIds, startIndexing, stopIndexing, inboxItems };
}

View file

@ -1,6 +1,3 @@
// Main component export
export { ConnectorIndicator } from "../connector-popup";
// Sub-components (if needed for external use) // Sub-components (if needed for external use)
export { ConnectorCard } from "./components/connector-card"; export { ConnectorCard } from "./components/connector-card";
export { ConnectorDialogHeader } from "./components/connector-dialog-header"; export { ConnectorDialogHeader } from "./components/connector-dialog-header";

View file

@ -11,7 +11,6 @@ import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
COMPOSIO_CONNECTORS, COMPOSIO_CONNECTORS,
IMPORT_CONNECTOR_TYPES,
LIVE_CONNECTOR_TYPES, LIVE_CONNECTOR_TYPES,
OAUTH_CONNECTORS, OAUTH_CONNECTORS,
} from "../constants/connector-constants"; } from "../constants/connector-constants";
@ -40,11 +39,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
onManage, onManage,
onViewAccountsList, onViewAccountsList,
}) => { }) => {
// Import connectors (Drive/OneDrive/Dropbox) are managed via the Documents const connectors = allActiveConnectors;
// sidebar "Import" menu, so they are excluded from the MCP connector list.
const connectors = allActiveConnectors.filter(
(c) => !IMPORT_CONNECTOR_TYPES.has(c.connector_type)
);
// Convert activeDocumentTypes array to Record for utility function // Convert activeDocumentTypes array to Record for utility function
const documentTypeCounts = activeDocumentTypes.reduce( const documentTypeCounts = activeDocumentTypes.reduce(
(acc, [docType, count]) => { (acc, [docType, count]) => {
@ -152,7 +147,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<div className="flex flex-col items-center justify-center py-20 text-center"> <div className="flex flex-col items-center justify-center py-20 text-center">
<Search className="size-8 text-muted-foreground mb-3" /> <Search className="size-8 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">No connectors found</p> <p className="text-sm text-muted-foreground">No connectors found</p>
<p className="text-xs text-muted-foreground/60 mt-1">Try a different search term</p>
</div> </div>
) : hasSources ? ( ) : hasSources ? (
<div className="space-y-6"> <div className="space-y-6">

View file

@ -1,6 +1,5 @@
"use client"; "use client";
import { Search } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { useIsSelfHosted } from "@/components/providers/runtime-config"; import { useIsSelfHosted } from "@/components/providers/runtime-config";
import { EnumConnectorName } from "@/contracts/enums/connector"; import { EnumConnectorName } from "@/contracts/enums/connector";
@ -9,12 +8,8 @@ import { usePlatform } from "@/hooks/use-platform";
import { ConnectorCard } from "../components/connector-card"; import { ConnectorCard } from "../components/connector-card";
import { import {
COMPOSIO_CONNECTORS, COMPOSIO_CONNECTORS,
CONNECTOR_CATEGORY_LABELS,
type ConnectorCategory,
CRAWLERS, CRAWLERS,
DEPRECATED_CONNECTOR_TYPES, DEPRECATED_CONNECTOR_TYPES,
getConnectorCategory,
IMPORT_CONNECTOR_TYPES,
OAUTH_CONNECTORS, OAUTH_CONNECTORS,
OTHER_CONNECTORS, OTHER_CONNECTORS,
} from "../constants/connector-constants"; } from "../constants/connector-constants";
@ -83,63 +78,39 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
const passesDeploymentFilter = (c: DeploymentFilterableConnector) => const passesDeploymentFilter = (c: DeploymentFilterableConnector) =>
(!c.selfHostedOnly || selfHosted) && (!c.desktopOnly || isDesktop); (!c.selfHostedOnly || selfHosted) && (!c.desktopOnly || isDesktop);
// Import connectors (Drive/OneDrive/Dropbox) are managed via the Documents // Filter connectors based on search and deployment mode
// sidebar "Import" menu, so they never appear in the MCP connector catalog.
const notImport = (c: { connectorType?: string }) =>
!c.connectorType || !IMPORT_CONNECTOR_TYPES.has(c.connectorType);
// Filter connectors based on search, deployment mode, and import exclusion
const filteredOAuth = OAUTH_CONNECTORS.filter( const filteredOAuth = OAUTH_CONNECTORS.filter(
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c) && notImport(c) (c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
); );
const filteredCrawlers = CRAWLERS.filter( const filteredCrawlers = CRAWLERS.filter(
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c) && notImport(c) (c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
); );
const filteredOther = OTHER_CONNECTORS.filter( const filteredOther = OTHER_CONNECTORS.filter(
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c) && notImport(c) (c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
); );
// Filter Composio connectors // Filter Composio connectors
const filteredComposio = COMPOSIO_CONNECTORS.filter( const filteredComposio = COMPOSIO_CONNECTORS.filter(
(c) => (c) =>
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) || c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) && c.description.toLowerCase().includes(searchQuery.toLowerCase())
notImport(c)
); );
const isDeprecated = (c: { connectorType?: string }) => // One flat grid, no separate "Deprecated" section. A deprecated connector is
!!c.connectorType && DEPRECATED_CONNECTOR_TYPES.has(c.connectorType); // shown only if the user already connected it (before deprecation), so it
// stays manageable/disconnectable; never-connected deprecated connectors are
const inCategory = // hidden entirely. See DEPRECATED_CONNECTOR_TYPES for the full list.
(category: ConnectorCategory) => const isVisible = <T extends { connectorType?: string }>(c: T) =>
<T extends { connectorType?: string }>(connector: T): boolean => !c.connectorType ||
!isDeprecated(connector) && !DEPRECATED_CONNECTOR_TYPES.has(c.connectorType) ||
!!connector.connectorType && connectedTypes.has(c.connectorType);
getConnectorCategory(connector.connectorType) === category; const available = {
oauth: filteredOAuth.filter(isVisible),
const knowledgeBase = { composio: filteredComposio.filter(isVisible),
oauth: filteredOAuth.filter(inCategory("knowledge_base")), other: filteredOther.filter(isVisible),
composio: filteredComposio.filter(inCategory("knowledge_base")), crawlers: filteredCrawlers.filter(isVisible),
other: filteredOther.filter(inCategory("knowledge_base")),
crawlers: filteredCrawlers.filter(inCategory("knowledge_base")),
};
const toolsLive = {
oauth: filteredOAuth.filter(inCategory("tools_live")),
composio: filteredComposio.filter(inCategory("tools_live")),
other: filteredOther.filter(inCategory("tools_live")),
crawlers: filteredCrawlers.filter(inCategory("tools_live")),
};
// Deprecated cards render in a single section at the very bottom. Discord and
// Teams come from OAUTH_CONNECTORS; Luma, Tavily, Linkup, Baidu, Elasticsearch
// from OTHER_CONNECTORS (SearXNG has no catalog card); YouTube and Web Pages
// from CRAWLERS.
const deprecated = {
oauth: filteredOAuth.filter(isDeprecated),
other: filteredOther.filter(isDeprecated),
crawlers: filteredCrawlers.filter(isDeprecated),
}; };
const renderOAuthCard = (connector: OAuthConnector | ComposioConnector) => { const renderOAuthCard = (connector: OAuthConnector | ComposioConnector) => {
@ -169,7 +140,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
documentCount={documentCount} documentCount={documentCount}
accountCount={accountCount} accountCount={accountCount}
isIndexing={isIndexing} isIndexing={isIndexing}
deprecated={DEPRECATED_CONNECTOR_TYPES.has(connector.connectorType)}
onConnect={() => onConnectOAuth(connector)} onConnect={() => onConnectOAuth(connector)}
onManage={ onManage={
isConnected && onViewAccountsList isConnected && onViewAccountsList
@ -218,7 +188,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
documentCount={documentCount} documentCount={documentCount}
connectorCount={mcpConnectorCount} connectorCount={mcpConnectorCount}
isIndexing={isIndexing} isIndexing={isIndexing}
deprecated={DEPRECATED_CONNECTOR_TYPES.has(connector.connectorType)}
onConnect={handleConnect} onConnect={handleConnect}
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined} onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
/> />
@ -267,86 +236,34 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
isConnecting={isConnecting} isConnecting={isConnecting}
documentCount={documentCount} documentCount={documentCount}
isIndexing={isIndexing} isIndexing={isIndexing}
deprecated={
!!crawler.connectorType && DEPRECATED_CONNECTOR_TYPES.has(crawler.connectorType)
}
onConnect={handleConnect} onConnect={handleConnect}
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined} onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
/> />
); );
}; };
const hasKnowledgeBase = const hasAnyResults =
knowledgeBase.oauth.length > 0 || available.oauth.length > 0 ||
knowledgeBase.composio.length > 0 || available.composio.length > 0 ||
knowledgeBase.other.length > 0 || available.other.length > 0 ||
knowledgeBase.crawlers.length > 0; available.crawlers.length > 0;
const hasToolsLive =
toolsLive.oauth.length > 0 ||
toolsLive.composio.length > 0 ||
toolsLive.other.length > 0 ||
toolsLive.crawlers.length > 0;
const hasDeprecated =
deprecated.oauth.length > 0 || deprecated.other.length > 0 || deprecated.crawlers.length > 0;
const hasAnyResults = hasKnowledgeBase || hasToolsLive || hasDeprecated;
if (!hasAnyResults && searchQuery) { if (!hasAnyResults && searchQuery) {
return ( return (
<div className="flex flex-col items-center justify-center py-20 text-center"> <div className="flex flex-col items-center justify-center py-20 text-center">
<Search className="size-8 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">No connectors found</p> <p className="text-sm text-muted-foreground">No connectors found</p>
<p className="text-xs text-muted-foreground/60 mt-1">Try a different search term</p>
</div> </div>
); );
} }
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{hasKnowledgeBase && ( <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<section> {available.oauth.map(renderOAuthCard)}
<div className="flex items-center gap-2 mb-4"> {available.composio.map(renderOAuthCard)}
<h3 className="text-sm font-semibold text-muted-foreground"> {available.crawlers.map(renderCrawlerCard)}
{CONNECTOR_CATEGORY_LABELS.knowledge_base} {available.other.map(renderOtherCard)}
</h3> </div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{knowledgeBase.oauth.map(renderOAuthCard)}
{knowledgeBase.composio.map(renderOAuthCard)}
{knowledgeBase.crawlers.map(renderCrawlerCard)}
{knowledgeBase.other.map(renderOtherCard)}
</div>
</section>
)}
{hasToolsLive && (
<section>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground">
{CONNECTOR_CATEGORY_LABELS.tools_live}
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{toolsLive.oauth.map(renderOAuthCard)}
{toolsLive.composio.map(renderOAuthCard)}
{toolsLive.crawlers.map(renderCrawlerCard)}
{toolsLive.other.map(renderOtherCard)}
</div>
</section>
)}
{hasDeprecated && (
<section>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground">Deprecated</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{deprecated.oauth.map(renderOAuthCard)}
{deprecated.crawlers.map(renderCrawlerCard)}
{deprecated.other.map(renderOtherCard)}
</div>
</section>
)}
</div> </div>
); );
}; };

View file

@ -111,7 +111,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */} {/* Header */}
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-1 sm:pb-4 bg-popover"> <div className="pb-1 sm:pb-4 bg-transparent">
{/* Back button */} {/* Back button */}
<Button <Button
type="button" type="button"
@ -165,7 +165,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto px-6 sm:px-12 pt-0 sm:pt-6 pb-6 sm:pb-8"> <div className="flex-1 overflow-y-auto pt-0 sm:pt-6 pb-6 sm:pb-8">
{/* Connected Accounts Grid */} {/* Connected Accounts Grid */}
{typeConnectors.length === 0 ? ( {typeConnectors.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">

View file

@ -216,7 +216,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ workspaceId, o
return ( return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden"> <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Header */} {/* Header */}
<div className="shrink-0 px-6 sm:px-12 pt-8 sm:pt-10"> <div className="shrink-0">
<Button <Button
variant="ghost" variant="ghost"
type="button" type="button"
@ -239,7 +239,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ workspaceId, o
</div> </div>
{/* Form Content - Scrollable */} {/* Form Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 sm:px-12"> <div className="flex-1 min-h-0 overflow-y-auto">
<div className="space-y-4 pb-6"> <div className="space-y-4 pb-6">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="video-input" className="text-sm sm:text-base"> <Label htmlFor="video-input" className="text-sm sm:text-base">
@ -325,7 +325,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ workspaceId, o
</div> </div>
{/* Fixed Footer - Action buttons */} {/* Fixed Footer - Action buttons */}
<div className="shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-popover"> <div className="shrink-0 flex items-center justify-between py-6 bg-transparent">
<Button <Button
variant="ghost" variant="ghost"
onClick={onBack} onClick={onBack}

View file

@ -12,17 +12,17 @@ import {
ArrowUpIcon, ArrowUpIcon,
Camera, Camera,
ChevronDown, ChevronDown,
ChevronRight,
Clipboard, Clipboard,
LayoutGrid,
Plus, Plus,
Settings2, Settings2,
SquareIcon, SquareIcon,
TriangleAlert,
Unplug, Unplug,
Upload, Upload,
Wrench, Wrench,
X, X,
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image"; import Image from "next/image";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -44,7 +44,7 @@ import {
clearPremiumAlertForThreadAtom, clearPremiumAlertForThreadAtom,
premiumAlertByThreadAtom, premiumAlertByThreadAtom,
} from "@/atoms/chat/premium-alert.atom"; } from "@/atoms/chat/premium-alert.atom";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; import { importConnectorRequestAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { membersAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms";
import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms"; import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms";
@ -52,6 +52,11 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AssistantMessage } from "@/components/assistant-ui/assistant-message"; import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status"; import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
import { ChatViewport } from "@/components/assistant-ui/chat-viewport"; import { ChatViewport } from "@/components/assistant-ui/chat-viewport";
import { ComposerAddMenuDrawer } from "@/components/assistant-ui/composer-add-menu-drawer";
import {
type ConnectorRow,
useConnectorRows,
} from "@/components/assistant-ui/connector-popup/hooks/use-connector-rows";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { import {
InlineMentionEditor, InlineMentionEditor,
@ -68,19 +73,12 @@ import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker"; import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuPortal, DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
@ -88,6 +86,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Popover, PopoverAnchor } from "@/components/ui/popover"; import { Popover, PopoverAnchor } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
@ -97,6 +96,7 @@ import {
getToolDisplayName, getToolDisplayName,
getToolIcon, getToolIcon,
} from "@/contracts/enums/toolIcons"; } from "@/contracts/enums/toolIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useBatchCommentsPreload } from "@/hooks/use-comments"; import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsSync } from "@/hooks/use-comments-sync"; import { useCommentsSync } from "@/hooks/use-comments-sync";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
@ -173,7 +173,7 @@ const ThreadContent: FC<ThreadProps> = ({
footer={ footer={
<> <>
<PremiumQuotaPinnedAlert /> <PremiumQuotaPinnedAlert />
<Composer hasActiveThread={hasActiveThread} isLoadingMessages={isLoadingMessages} /> <Composer isLoadingMessages={isLoadingMessages} />
</> </>
} }
> >
@ -313,99 +313,6 @@ const ThreadWelcome: FC = () => {
); );
}; };
const BANNER_CONNECTORS = [
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
{ type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" },
{ type: "NOTION_CONNECTOR", label: "Notion" },
{ type: "YOUTUBE_CONNECTOR", label: "YouTube" },
{ type: "SLACK_CONNECTOR", label: "Slack" },
] as const;
const BANNER_DISMISSED_KEY = "surfsense-connect-tools-banner-dismissed";
const ConnectToolsBanner: FC<{
isThreadEmpty: boolean;
onVisibleChange?: (visible: boolean) => void;
}> = ({ isThreadEmpty, onVisibleChange }) => {
const { data: connectors } = useAtomValue(connectorsAtom);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const [dismissed, setDismissed] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem(BANNER_DISMISSED_KEY) === "true";
});
const [dismissRequested, setDismissRequested] = useState(false);
const hasConnectors = (connectors?.length ?? 0) > 0;
const isVisible = !dismissed && !hasConnectors && isThreadEmpty;
const shouldShowTray = isVisible && !dismissRequested;
useEffect(() => {
onVisibleChange?.(isVisible);
}, [isVisible, onVisibleChange]);
const handleDismiss = (e: React.MouseEvent) => {
e.stopPropagation();
setDismissRequested(true);
};
return (
<AnimatePresence
initial={false}
onExitComplete={() => {
if (!dismissRequested) return;
setDismissed(true);
localStorage.setItem(BANNER_DISMISSED_KEY, "true");
}}
>
{shouldShowTray ? (
<motion.div
key="connect-tools-tray"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -14 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="relative z-0 -mt-5 flex min-w-0 items-center gap-2 rounded-b-3xl border border-input bg-muted/40 px-4 pt-7 pb-3 shadow-sm shadow-black/5 dark:shadow-black/10"
>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 min-w-0 cursor-pointer justify-start gap-2 rounded-md px-0 text-[13px] font-normal text-muted-foreground select-none hover:bg-transparent hover:text-foreground"
onClick={() => setConnectorDialogOpen(true)}
>
<Unplug className="size-4 shrink-0" />
<span className="truncate">Connect your tools</span>
</Button>
<div className="min-w-0 flex-1" />
<AvatarGroup className="shrink-0">
{BANNER_CONNECTORS.map(({ type }, i) => (
<Avatar
key={type}
className="size-5"
style={{ zIndex: BANNER_CONNECTORS.length - i }}
>
<AvatarFallback className="bg-accent text-[10px]">
{getConnectorIcon(type, "size-3")}
</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
<Button
type="button"
onClick={handleDismiss}
variant="ghost"
size="icon"
className="size-7 shrink-0 cursor-pointer rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground"
aria-label="Dismiss"
>
<X className="size-3.5" />
</Button>
</motion.div>
) : null}
</AnimatePresence>
);
};
const PendingScreenImageStrip: FC = () => { const PendingScreenImageStrip: FC = () => {
const [urls, setUrls] = useAtom(pendingUserImageDataUrlsAtom); const [urls, setUrls] = useAtom(pendingUserImageDataUrlsAtom);
if (urls.length === 0) return null; if (urls.length === 0) return null;
@ -485,8 +392,7 @@ const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDi
}; };
/** /**
* Inline "this workspace can't chat yet" tray, mirrored on top of the composer * Inline "this workspace can't chat yet" tray, mirrored on top of the composer.
* (same visual treatment as the "Connect your tools" tray below it).
* *
* Replaces the old full-screen onboarding redirect: a workspace with no usable * Replaces the old full-screen onboarding redirect: a workspace with no usable
* chat model (fresh install, or an admin deleted the last model) renders the * chat model (fresh install, or an admin deleted the last model) renders the
@ -531,14 +437,10 @@ const ChatUnavailableNotice: FC<{ workspaceId: number; canConfigure: boolean }>
}; };
interface ComposerProps { interface ComposerProps {
hasActiveThread?: boolean;
isLoadingMessages?: boolean; isLoadingMessages?: boolean;
} }
const Composer: FC<ComposerProps> = ({ const Composer: FC<ComposerProps> = ({ isLoadingMessages = false }) => {
hasActiveThread = false,
isLoadingMessages = false,
}) => {
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const setSubmittedMentions = useSetAtom(submittedMentionsAtom); const setSubmittedMentions = useSetAtom(submittedMentionsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false); const [showDocumentPopover, setShowDocumentPopover] = useState(false);
@ -575,7 +477,6 @@ const Composer: FC<ComposerProps> = ({
const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty); const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
const [connectToolsTrayVisible, setConnectToolsTrayVisible] = useState(false);
const { data: chatSetupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId ?? 0)); const { data: chatSetupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId ?? 0));
const isChatUnavailable = !!chatSetupStatus && chatSetupStatus.status !== "ready"; const isChatUnavailable = !!chatSetupStatus && chatSetupStatus.status !== "ready";
@ -1037,7 +938,6 @@ const Composer: FC<ComposerProps> = ({
<div <div
className={cn( className={cn(
"aui-composer-attachment-dropzone relative z-10 flex w-full flex-col overflow-hidden rounded-3xl border border-input/20 bg-muted pt-2 shadow-sm shadow-black/5 outline-none transition-[border-color,box-shadow] hover:border-input/60 focus-within:border-input/60 dark:shadow-black/10", "aui-composer-attachment-dropzone relative z-10 flex w-full flex-col overflow-hidden rounded-3xl border border-input/20 bg-muted pt-2 shadow-sm shadow-black/5 outline-none transition-[border-color,box-shadow] hover:border-input/60 focus-within:border-input/60 dark:shadow-black/10",
connectToolsTrayVisible && "rounded-b-3xl shadow-none dark:shadow-none",
isChatUnavailable && "shadow-none dark:shadow-none" isChatUnavailable && "shadow-none dark:shadow-none"
)} )}
> >
@ -1071,10 +971,6 @@ const Composer: FC<ComposerProps> = ({
onChatModelSelected={handleChatModelSelected} onChatModelSelected={handleChatModelSelected}
/> />
</div> </div>
<ConnectToolsBanner
isThreadEmpty={!hasActiveThread && isThreadEmpty}
onVisibleChange={setConnectToolsTrayVisible}
/>
{!isLoadingMessages && isThreadEmpty && isComposerInputEmpty ? ( {!isLoadingMessages && isThreadEmpty && isComposerInputEmpty ? (
<div className="absolute top-full left-0 right-0 z-20"> <div className="absolute top-full left-0 right-0 z-20">
<ChatExamplePrompts onSelect={handleExampleSelect} /> <ChatExamplePrompts onSelect={handleExampleSelect} />
@ -1152,12 +1048,13 @@ const ComposerAction: FC<ComposerActionProps> = ({
onChatModelSelected, onChatModelSelected,
}) => { }) => {
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom); const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom); const router = useRouter();
const openConnectors = useCallback(
() => router.push(`/dashboard/${workspaceId}/connectors`),
[router, workspaceId]
);
const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false); const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false);
const [openConnectorSubmenu, setOpenConnectorSubmenu] = useState<string | null>(null); const [openConnectorSubmenu, setOpenConnectorSubmenu] = useState<string | null>(null);
const [expandedConnectorGroups, setExpandedConnectorGroups] = useState<Set<string>>(
() => new Set()
);
const isDesktop = useMediaQuery("(min-width: 640px)"); const isDesktop = useMediaQuery("(min-width: 640px)");
const { openDialog: openUploadDialog } = useDocumentUploadDialog(); const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const pendingScreenImages = useAtomValue(pendingUserImageDataUrlsAtom); const pendingScreenImages = useAtomValue(pendingUserImageDataUrlsAtom);
@ -1192,6 +1089,20 @@ const ComposerAction: FC<ComposerActionProps> = ({
() => new Set<string>((connectors ?? []).map((c) => c.connector_type)), () => new Set<string>((connectors ?? []).map((c) => c.connector_type)),
[connectors] [connectors]
); );
// "Your connectors" rows for the add-menu (desktop submenu + mobile drawer):
// same hook the connectors panel rail uses, so grouping + health glyphs match.
const { rows: connectorRows } = useConnectorRows((connectors ?? []) as SearchSourceConnector[]);
const setImportRequest = useSetAtom(importConnectorRequestAtom);
const openConnectorManage = useCallback(
(row: ConnectorRow) => {
// Deep-link into the connector's manage view: the panel's
// useConnectorDialog consumes this atom and routes by account count
// (none -> connect, one -> edit, many -> accounts list).
setImportRequest({ connectorType: row.type, mode: "auto" });
openConnectors();
},
[setImportRequest, openConnectors]
);
const toggleToolGroup = useCallback( const toggleToolGroup = useCallback(
(toolNames: string[]) => { (toolNames: string[]) => {
@ -1204,18 +1115,6 @@ const ComposerAction: FC<ComposerActionProps> = ({
}, },
[disabledToolsSet, setDisabledTools] [disabledToolsSet, setDisabledTools]
); );
const setConnectorGroupExpanded = useCallback((label: string, expanded: boolean) => {
setExpandedConnectorGroups((prev) => {
const next = new Set(prev);
if (expanded) {
next.add(label);
} else {
next.delete(label);
}
return next;
});
}, []);
const filteredTools = agentTools; const filteredTools = agentTools;
const groupedTools = useMemo(() => { const groupedTools = useMemo(() => {
if (!filteredTools) return []; if (!filteredTools) return [];
@ -1284,206 +1183,30 @@ const ComposerAction: FC<ComposerActionProps> = ({
<div className="aui-composer-action-wrapper relative mx-3 mb-3 flex items-center justify-between"> <div className="aui-composer-action-wrapper relative mx-3 mb-3 flex items-center justify-between">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{!isDesktop ? ( {!isDesktop ? (
<> <ComposerAddMenuDrawer
<DropdownMenu> trigger={
<DropdownMenuTrigger asChild> <Button
<Button variant="ghost"
variant="ghost" size="icon"
size="icon" className="h-9 w-9 rounded-full p-0 font-semibold text-xs text-muted-foreground transition-colors dark:border-muted-foreground/15 hover:bg-foreground/10 hover:text-foreground"
className="h-9 w-9 rounded-full p-0 font-semibold text-xs text-muted-foreground transition-colors dark:border-muted-foreground/15 hover:bg-foreground/10 hover:text-foreground" aria-label="Upload files, manage tools and more"
aria-label="Upload files, manage tools and more" data-joyride="connector-icon"
data-joyride="connector-icon" >
> <Plus className="size-5" />
<Plus className="size-5" /> </Button>
</Button> }
</DropdownMenuTrigger> onUploadFiles={() => openUploadDialog()}
<DropdownMenuContent side="bottom" align="start" sideOffset={8}> connectorRows={connectorRows}
<DropdownMenuItem onSelect={() => openUploadDialog()}> onSelectConnector={openConnectorManage}
<Upload className="size-4" /> onBrowseConnectors={openConnectors}
Upload Files regularToolGroups={regularToolGroups}
</DropdownMenuItem> connectorToolGroups={connectorToolGroups}
<DropdownMenuItem onSelect={() => setConnectorDialogOpen(true)}> otherToolGroup={otherToolGroup}
<Unplug className="size-4" /> disabledToolsSet={disabledToolsSet}
MCP Connectors onToggleTool={toggleTool}
</DropdownMenuItem> onToggleToolGroup={toggleToolGroup}
<DropdownMenuItem onSelect={() => setToolsPopoverOpen(true)}> toolsLoading={!filteredTools?.length}
<Settings2 className="size-4" /> />
Manage Tools
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Drawer
open={toolsPopoverOpen}
onOpenChange={setToolsPopoverOpen}
shouldScaleBackground={false}
>
<DrawerContent className="h-[85vh] max-h-[85vh] z-80" overlayClassName="z-80">
<DrawerHandle />
<DrawerHeader className="px-4 pb-3 pt-2">
<DrawerTitle className="flex items-center justify-center gap-2 text-base font-semibold">
Manage Tools
</DrawerTitle>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin pb-6">
{regularToolGroups.map((group) => (
<div key={group.label}>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
{group.label}
</div>
{group.tools.map((tool) => {
const isDisabled = disabledToolsSet.has(tool.name);
const ToolIcon = getToolIcon(tool.name);
return (
<div
key={tool.name}
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-accent hover:text-accent-foreground transition-colors"
>
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-sm font-medium truncate">
{formatToolName(tool.name)}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0"
/>
</div>
);
})}
</div>
))}
{connectorToolGroups.length > 0 && (
<div>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
Connector Actions
</div>
{connectorToolGroups.map((group) => {
const iconKey = group.connectorIcon ?? "";
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
const toolNames = group.tools.map((t) => t.name);
const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
const isExpanded = expandedConnectorGroups.has(group.label);
return (
<Collapsible
key={group.label}
open={isExpanded}
onOpenChange={(open) => setConnectorGroupExpanded(group.label, open)}
>
<div className="flex w-full items-center gap-3 px-4 py-2 hover:bg-accent hover:text-accent-foreground transition-colors">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-auto min-w-0 flex-1 justify-start gap-3 p-0 text-left hover:bg-transparent hover:text-inherit"
>
{iconInfo ? (
<Image
src={iconInfo.src}
alt={iconInfo.alt}
width={18}
height={18}
className="size-[18px] shrink-0 select-none pointer-events-none"
draggable={false}
/>
) : (
<Wrench className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{group.label}
</span>
{isExpanded ? (
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
)}
</Button>
</CollapsibleTrigger>
<Switch
checked={!allDisabled}
onCheckedChange={() => toggleToolGroup(toolNames)}
className="shrink-0"
/>
</div>
<CollapsibleContent className="pb-1">
{group.tools.map((tool) => {
const isDisabled = disabledToolsSet.has(tool.name);
return (
<div
key={tool.name}
className={cn(
"ml-8 flex items-center gap-3 px-4 py-1.5 rounded-md transition-colors",
"hover:bg-accent hover:text-accent-foreground",
!isDisabled && "text-primary"
)}
>
<span className="min-w-0 flex-1 truncate text-sm">
{formatToolName(tool.name)}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0"
/>
</div>
);
})}
</CollapsibleContent>
</Collapsible>
);
})}
</div>
)}
{otherToolGroup && (
<div>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
{otherToolGroup.label}
</div>
{otherToolGroup.tools.map((tool) => {
const isDisabled = disabledToolsSet.has(tool.name);
const ToolIcon = getToolIcon(tool.name);
return (
<div
key={tool.name}
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-accent hover:text-accent-foreground transition-colors"
>
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-sm font-medium truncate">
{formatToolName(tool.name)}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0"
/>
</div>
);
})}
</div>
)}
{!filteredTools?.length && (
<div className="px-4 pt-3 pb-2">
<Skeleton className="h-3 w-16 mb-2" />
{["t1", "t2", "t3", "t4"].map((k) => (
<div key={k} className="flex items-center gap-3 py-2">
<Skeleton className="size-4 rounded shrink-0" />
<Skeleton className="h-3.5 flex-1" />
<Skeleton className="h-5 w-9 rounded-full shrink-0" />
</div>
))}
<Skeleton className="h-3 w-24 mt-3 mb-2" />
{["c1", "c2", "c3"].map((k) => (
<div key={k} className="flex items-center gap-3 py-2">
<Skeleton className="size-4 rounded shrink-0" />
<Skeleton className="h-3.5 flex-1" />
<Skeleton className="h-5 w-9 rounded-full shrink-0" />
</div>
))}
</div>
)}
</div>
</DrawerContent>
</Drawer>
</>
) : ( ) : (
<DropdownMenu <DropdownMenu
onOpenChange={(open) => { onOpenChange={(open) => {
@ -1522,10 +1245,53 @@ const ComposerAction: FC<ComposerActionProps> = ({
<Camera className="h-4 w-4" /> <Camera className="h-4 w-4" />
Take a screenshot Take a screenshot
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => setConnectorDialogOpen(true)}> {connectorRows.length > 0 ? (
<Unplug className="h-4 w-4" /> <DropdownMenuSub>
MCP Connectors <DropdownMenuSubTrigger>
</DropdownMenuItem> <Unplug className="h-4 w-4" />
MCP Connectors
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent
collisionPadding={8}
className="w-60 max-h-72 gap-1 overflow-y-auto overscroll-none"
>
{connectorRows.map((row) => (
<DropdownMenuItem
key={row.type}
onSelect={() => openConnectorManage(row)}
className="gap-2"
>
{getConnectorIcon(row.type, "h-4 w-4")}
<span className="min-w-0 flex-1 truncate">{row.title}</span>
{row.health === "syncing" ? (
<Spinner size="xs" className="shrink-0" />
) : row.health === "failed" ? (
<TriangleAlert
className="h-3.5 w-3.5 shrink-0 text-destructive"
aria-label={row.errorMessage ?? "Indexing failed"}
/>
) : row.accountCount > 1 ? (
<span className="shrink-0 text-xs text-muted-foreground">
{row.accountCount}
</span>
) : null}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={openConnectors} className="gap-2">
<LayoutGrid className="h-4 w-4" />
Manage connectors
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
) : (
<DropdownMenuItem onSelect={openConnectors}>
<Unplug className="h-4 w-4" />
MCP Connectors
</DropdownMenuItem>
)}
<DropdownMenuSub <DropdownMenuSub
open={toolsPopoverOpen} open={toolsPopoverOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
@ -1546,7 +1312,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
> >
{regularToolGroups.map((group) => ( {regularToolGroups.map((group) => (
<div key={group.label}> <div key={group.label}>
<div className="px-2 pt-1.5 pb-0.5 text-[10px] text-muted-foreground/80 font-normal select-none"> <div className="px-2 pt-1.5 pb-0.5 text-xs text-muted-foreground font-semibold select-none">
{group.label} {group.label}
</div> </div>
{group.tools.map((tool) => { {group.tools.map((tool) => {
@ -1581,7 +1347,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
))} ))}
{connectorToolGroups.length > 0 && ( {connectorToolGroups.length > 0 && (
<div> <div>
<div className="px-2 pt-1.5 pb-0.5 text-[10px] text-muted-foreground/80 font-normal select-none"> <div className="px-2 pt-1.5 pb-0.5 text-xs text-muted-foreground font-semibold select-none">
Connector Actions Connector Actions
</div> </div>
{connectorToolGroups.map((group) => { {connectorToolGroups.map((group) => {
@ -1667,7 +1433,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
)} )}
{otherToolGroup && ( {otherToolGroup && (
<div> <div>
<div className="px-2 pt-1.5 pb-0.5 text-[10px] text-muted-foreground/80 font-normal select-none"> <div className="px-2 pt-1.5 pb-0.5 text-xs text-muted-foreground font-semibold select-none">
{otherToolGroup.label} {otherToolGroup.label}
</div> </div>
{otherToolGroup.tools.map((tool) => { {otherToolGroup.tools.map((tool) => {

View file

@ -324,7 +324,6 @@ export function FolderTreeView({
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground"> <div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
<Search className="h-10 w-10" /> <Search className="h-10 w-10" />
<p className="text-sm text-muted-foreground">No matching documents</p> <p className="text-sm text-muted-foreground">No matching documents</p>
<p className="text-xs text-muted-foreground/70 mt-1">Try a different search term</p>
</div> </div>
); );
} }

View file

@ -2,7 +2,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { AlarmClock, AlertTriangle, Shapes, SquareTerminal } from "lucide-react"; import { AlarmClock, AlertTriangle, Shapes, SquareTerminal, Unplug } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation"; import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
@ -300,6 +300,7 @@ export function LayoutDataProvider({
const isAutomationsActive = pathname?.includes("/automations") === true; const isAutomationsActive = pathname?.includes("/automations") === true;
const isArtifactsActive = pathname?.endsWith("/artifacts") === true; const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
const isPlaygroundRoute = pathname?.includes("/playground") === true; const isPlaygroundRoute = pathname?.includes("/playground") === true;
const isConnectorsRoute = pathname?.includes("/connectors") === true;
const navItems: NavItem[] = useMemo( const navItems: NavItem[] = useMemo(
() => () =>
( (
@ -316,6 +317,12 @@ export function LayoutDataProvider({
icon: Shapes, icon: Shapes,
isActive: isArtifactsActive, isActive: isArtifactsActive,
}, },
{
title: "Connectors",
url: `/dashboard/${workspaceId}/connectors`,
icon: Unplug,
isActive: isConnectorsRoute,
},
{ {
title: "Playground", title: "Playground",
url: `/dashboard/${workspaceId}/playground`, url: `/dashboard/${workspaceId}/playground`,
@ -324,7 +331,7 @@ export function LayoutDataProvider({
}, },
] as (NavItem | null)[] ] as (NavItem | null)[]
).filter((item): item is NavItem => item !== null), ).filter((item): item is NavItem => item !== null),
[workspaceId, isAutomationsActive, isArtifactsActive, isPlaygroundRoute] [workspaceId, isAutomationsActive, isArtifactsActive, isConnectorsRoute, isPlaygroundRoute]
); );
// Handlers // Handlers
@ -631,6 +638,7 @@ export function LayoutDataProvider({
const isAutomationsPage = pathname?.includes("/automations") === true; const isAutomationsPage = pathname?.includes("/automations") === true;
const isArtifactsPage = pathname?.endsWith("/artifacts") === true; const isArtifactsPage = pathname?.endsWith("/artifacts") === true;
const isPlaygroundPage = pathname?.includes("/playground") === true; const isPlaygroundPage = pathname?.includes("/playground") === true;
const isConnectorsPage = pathname?.includes("/connectors") === true;
const isAllChatsPage = pathname?.endsWith("/chats") === true; const isAllChatsPage = pathname?.endsWith("/chats") === true;
const handleChatsClick = useCallback(() => { const handleChatsClick = useCallback(() => {
router.push(`/dashboard/${workspaceId}/chats`); router.push(`/dashboard/${workspaceId}/chats`);
@ -650,6 +658,7 @@ export function LayoutDataProvider({
isAutomationsPage || isAutomationsPage ||
isArtifactsPage || isArtifactsPage ||
isPlaygroundPage || isPlaygroundPage ||
isConnectorsPage ||
isAllChatsPage; isAllChatsPage;
return ( return (
@ -702,6 +711,7 @@ export function LayoutDataProvider({
isAutomationsPage || isAutomationsPage ||
isArtifactsPage || isArtifactsPage ||
isPlaygroundPage || isPlaygroundPage ||
isConnectorsPage ||
isAllChatsPage isAllChatsPage
? "items-start justify-center px-6 py-8 md:px-10 md:pb-10 md:pt-16" ? "items-start justify-center px-6 py-8 md:px-10 md:pb-10 md:pt-16"
: undefined : undefined

View file

@ -28,7 +28,6 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { trackWorkspaceCreated } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client"; import { queryClient } from "@/lib/query-client/client";
@ -68,7 +67,8 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia
description: values.description || "", description: values.description || "",
}); });
trackWorkspaceCreated(result.id, values.name); // workspace_created is now emitted server-side (workspaces_routes.py)
// so PAT/MCP-created workspaces are also counted.
// Seed the gate's query so it resolves without a loader flash, and // Seed the gate's query so it resolves without a loader flash, and
// route straight to onboarding vs. new-chat on the first hop. // route straight to onboarding vs. new-chat on the first hop.

View file

@ -487,13 +487,9 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) {
</div> </div>
) : isSearchMode ? ( ) : isSearchMode ? (
<div className="text-center py-8"> <div className="text-center py-8">
<Search className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" /> <p className="text-sm text-muted-foreground">
<p className="text-xs text-muted-foreground">
{t("no_chats_found") || "No chats found"} {t("no_chats_found") || "No chats found"}
</p> </p>
<p className="mt-1 text-[11px] text-muted-foreground/70">
{t("try_different_search") || "Try a different search term"}
</p>
</div> </div>
) : ( ) : (
<div className="text-center py-8"> <div className="text-center py-8">

View file

@ -8,25 +8,18 @@ import {
FolderPlus, FolderPlus,
FolderSync, FolderSync,
ListFilter, ListFilter,
Plus,
Settings2,
SlidersVertical, SlidersVertical,
Trash2, Trash2,
Upload,
} from "lucide-react"; } from "lucide-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { makeFolderMention, mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom"; import { makeFolderMention, mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
import { importConnectorRequestAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms"; import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms"; import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { useConnectorStatus } from "@/components/assistant-ui/connector-popup/hooks/use-connector-status";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog"; import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode"; import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
import { getDocumentTypeIcon } from "@/components/documents/DocumentTypeIcon"; import { getDocumentTypeIcon } from "@/components/documents/DocumentTypeIcon";
@ -34,7 +27,7 @@ import type { FolderDisplay } from "@/components/documents/FolderNode";
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog"; import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
import { FolderTreeView } from "@/components/documents/FolderTreeView"; import { FolderTreeView } from "@/components/documents/FolderTreeView";
import { VersionHistoryDialog } from "@/components/documents/version-history"; import { VersionHistoryDialog } from "@/components/documents/version-history";
import { useOptionalRuntimeConfig, useRuntimeConfig } from "@/components/providers/runtime-config"; import { useRuntimeConfig } from "@/components/providers/runtime-config";
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems"; import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import { import {
DEFAULT_EXCLUDE_PATTERNS, DEFAULT_EXCLUDE_PATTERNS,
@ -57,7 +50,6 @@ import {
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
@ -67,9 +59,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode"; import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate"; import { useLoginGate } from "@/contexts/login-gate";
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 type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { useElectronAPI, usePlatform } from "@/hooks/use-platform"; import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
@ -239,11 +228,9 @@ export function EmbeddedDocumentsMenu({
} }
/** /**
* Import menu: local file upload plus the cloud-drive import connectors * Desktop-only "Watch Local Folder" entry point. File uploads now live in the
* (Google Drive / OneDrive / Dropbox). Drive/OneDrive/Dropbox set * chat composer and cloud drives in the connector catalog, so this renders
* `importConnectorRequestAtom`, which the connector dialog consumes to run * nothing on web. `gate` login-gates the action in anonymous mode.
* OAuth or open the existing account's config. In anonymous mode, `gate`
* intercepts every item to trigger the login flow.
*/ */
export function EmbeddedImportMenu({ export function EmbeddedImportMenu({
gate, gate,
@ -252,30 +239,14 @@ export function EmbeddedImportMenu({
gate?: (feature: string) => void; gate?: (feature: string) => void;
onFolderWatched?: () => void; onFolderWatched?: () => void;
}) { }) {
const { openDialog } = useDocumentUploadDialog();
const setImportRequest = useSetAtom(importConnectorRequestAtom);
// Provider is absent on anonymous /free pages, where every item is login-gated
// anyway — defaulting to hosted (Composio) there is cosmetic.
const selfHosted = useOptionalRuntimeConfig()?.deploymentMode === "self-hosted";
const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus();
const { data: connectors } = useAtomValue(connectorsAtom);
// Watch Local Folder is a desktop-app feature (needs the Electron folder watcher). // Watch Local Folder is a desktop-app feature (needs the Electron folder watcher).
const { isDesktop } = usePlatform(); const { isDesktop } = usePlatform();
const params = useParams(); const params = useParams();
const workspaceId = getWorkspaceIdNumber(params) ?? 0; const workspaceId = getWorkspaceIdNumber(params) ?? 0;
const [folderWatchOpen, setFolderWatchOpen] = useState(false); const [folderWatchOpen, setFolderWatchOpen] = useState(false);
// Native Google Drive connector self-hosted only; hosted deployments use Composio. // Nothing to import on web anymore — hide the button rather than show an empty menu.
const driveType = selfHosted if (!isDesktop) return null;
? EnumConnectorName.GOOGLE_DRIVE_CONNECTOR
: EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR;
const cloudItems = [
{ type: driveType, label: "Google Drive" },
{ type: EnumConnectorName.ONEDRIVE_CONNECTOR, label: "OneDrive" },
{ type: EnumConnectorName.DROPBOX_CONNECTOR, label: "Dropbox" },
];
return ( return (
<DropdownMenu> <DropdownMenu>
@ -285,99 +256,20 @@ export function EmbeddedImportMenu({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6 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" aria-label="Watch local folder"
> >
<FilePlus className="size-3.5" /> <FilePlus className="size-3.5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onSelect={() => (gate ? gate("upload files") : openDialog())}> <DropdownMenuItem
<Upload className="h-4 w-4" /> onSelect={() => (gate ? gate("watch local folders") : setFolderWatchOpen(true))}
Upload Files >
<FolderSync className="h-4 w-4" />
Watch Local Folder
</DropdownMenuItem> </DropdownMenuItem>
{isDesktop && (
<DropdownMenuItem
onSelect={() => (gate ? gate("watch local folders") : setFolderWatchOpen(true))}
>
<FolderSync className="h-4 w-4" />
Watch Local Folder
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{cloudItems.map((item) => {
const enabled = gate ? true : isConnectorEnabled(item.type);
const statusMessage = enabled ? null : getConnectorStatusMessage(item.type);
const icon = getConnectorIcon(item.type, "h-4 w-4");
// gate = anonymous mode; treat every connector as unconnected so items
// route through the login gate rather than reading workspace connectors.
const accountCount = gate
? 0
: (connectors ?? []).filter(
(c: SearchSourceConnector) => c.connector_type === item.type
).length;
// Unavailable (e.g. maintenance): non-actionable item explaining why.
if (!enabled) {
return (
<DropdownMenuItem key={item.type} disabled title={statusMessage ?? undefined}>
{icon}
{item.label}
</DropdownMenuItem>
);
}
// Connected: manage the existing account(s) or add another.
if (accountCount > 0) {
return (
<DropdownMenuSub key={item.type}>
<DropdownMenuSubTrigger>
{icon}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<span className="ml-auto text-xs text-muted-foreground">{accountCount}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-48">
<DropdownMenuItem
onSelect={() =>
gate
? gate("manage import connectors")
: setImportRequest({ connectorType: item.type, mode: "auto" })
}
>
<Settings2 className="h-4 w-4" />
{accountCount > 1 ? "Manage accounts" : "Manage"}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
gate
? gate("import from cloud storage")
: setImportRequest({ connectorType: item.type, mode: "connect" })
}
>
<Plus className="h-4 w-4" />
Add another account
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
// Not connected: single click starts the first OAuth connect.
return (
<DropdownMenuItem
key={item.type}
onSelect={() =>
gate
? gate("import from cloud storage")
: setImportRequest({ connectorType: item.type, mode: "auto" })
}
>
{icon}
{item.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent> </DropdownMenuContent>
{isDesktop && !gate && ( {!gate && (
<FolderWatchDialog <FolderWatchDialog
open={folderWatchOpen} open={folderWatchOpen}
onOpenChange={setFolderWatchOpen} onOpenChange={setFolderWatchOpen}

View file

@ -140,6 +140,10 @@ export function Sidebar({
() => navItems.find((item) => item.url.endsWith("/artifacts")), () => navItems.find((item) => item.url.endsWith("/artifacts")),
[navItems] [navItems]
); );
const connectorsItem = useMemo(
() => navItems.find((item) => item.url.endsWith("/connectors")),
[navItems]
);
const playgroundItem = useMemo( const playgroundItem = useMemo(
() => navItems.find((item) => item.url.endsWith("/playground")), () => navItems.find((item) => item.url.endsWith("/playground")),
[navItems] [navItems]
@ -150,6 +154,7 @@ export function Sidebar({
(item) => (item) =>
!item.url.endsWith("/automations") && !item.url.endsWith("/automations") &&
!item.url.endsWith("/artifacts") && !item.url.endsWith("/artifacts") &&
!item.url.endsWith("/connectors") &&
!item.url.endsWith("/playground") !item.url.endsWith("/playground")
), ),
[navItems] [navItems]
@ -254,6 +259,16 @@ export function Sidebar({
tooltipContent={isCollapsed ? artifactsItem.title : undefined} tooltipContent={isCollapsed ? artifactsItem.title : undefined}
/> />
)} )}
{connectorsItem && (
<SidebarButton
icon={connectorsItem.icon}
label={connectorsItem.title}
onClick={() => onNavItemClick?.(connectorsItem)}
isCollapsed={isCollapsed}
isActive={connectorsItem.isActive}
tooltipContent={isCollapsed ? connectorsItem.title : undefined}
/>
)}
{playgroundItem && ( {playgroundItem && (
<SidebarButton <SidebarButton
icon={playgroundItem.icon} icon={playgroundItem.icon}

View file

@ -17,6 +17,16 @@ interface PublicChatSnapshotsManagerProps {
workspaceId: number; workspaceId: number;
} }
const infoAlert = (
<Alert>
<Info />
<AlertDescription>
Public chats allow anyone with the URL to view a snapshot of a chat. They do not update when
the original chat changes.
</AlertDescription>
</Alert>
);
export function PublicChatSnapshotsManager({ export function PublicChatSnapshotsManager({
workspaceId: _workspaceId, workspaceId: _workspaceId,
}: PublicChatSnapshotsManagerProps) { }: PublicChatSnapshotsManagerProps) {
@ -81,14 +91,7 @@ export function PublicChatSnapshotsManager({
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-4 md:space-y-5"> <div className="space-y-4 md:space-y-5">
<Alert> {infoAlert}
<Info />
<AlertDescription>
<div className="flex min-h-[1.625em] items-center">
<Skeleton className="h-4 w-60 bg-accent-foreground/15" />
</div>
</AlertDescription>
</Alert>
{/* Cards grid skeleton */} {/* Cards grid skeleton */}
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2"> <div className="grid gap-3 grid-cols-1 sm:grid-cols-2">
@ -135,13 +138,7 @@ export function PublicChatSnapshotsManager({
return ( return (
<div className="space-y-4 md:space-y-5"> <div className="space-y-4 md:space-y-5">
<Alert> {infoAlert}
<Info />
<AlertDescription>
Public chats allow anyone with the URL to view a snapshot of a chat. They do not update
when the original chat changes.
</AlertDescription>
</Alert>
<PublicChatSnapshotsList <PublicChatSnapshotsList
snapshots={snapshots} snapshots={snapshots}

View file

@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink } from "lucide-react"; import { Check, ExternalLink } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms"; import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -15,11 +14,7 @@ import { Spinner } from "@/components/ui/spinner";
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types"; import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service"; import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { stripeApiService } from "@/lib/apis/stripe-api.service";
import { import { trackIncentiveTaskClicked } from "@/lib/posthog/events";
trackIncentivePageViewed,
trackIncentiveTaskClicked,
trackIncentiveTaskCompleted,
} from "@/lib/posthog/events";
import { getWorkspaceIdParam } from "@/lib/route-params"; import { getWorkspaceIdParam } from "@/lib/route-params";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -35,9 +30,7 @@ export function EarnCreditsContent() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const workspaceId = getWorkspaceIdParam(params) ?? ""; const workspaceId = getWorkspaceIdParam(params) ?? "";
useEffect(() => { // incentive_page_viewed removed — redundant with $pageview.
trackIncentivePageViewed();
}, []);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["incentive-tasks"], queryKey: ["incentive-tasks"],
@ -51,13 +44,11 @@ export function EarnCreditsContent() {
const completeMutation = useMutation({ const completeMutation = useMutation({
mutationFn: incentiveTasksApiService.completeTask, mutationFn: incentiveTasksApiService.completeTask,
onSuccess: (response, taskType) => { onSuccess: (response) => {
if (response.success) { if (response.success) {
toast.success(response.message); toast.success(response.message);
const task = data?.tasks.find((t) => t.task_type === taskType); // incentive_task_completed is now emitted server-side
if (task) { // (incentive_tasks_routes.complete_task) where credit is granted.
trackIncentiveTaskCompleted(taskType, task.credit_micros_reward);
}
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] }); queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY }); queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
} }

View file

@ -29,11 +29,7 @@ import { Switch } from "@/components/ui/switch";
import type { ProcessingMode } from "@/contracts/types/document.types"; import type { ProcessingMode } from "@/contracts/types/document.types";
import { useElectronAPI } from "@/hooks/use-platform"; import { useElectronAPI } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { import { trackDocumentUploadStarted } from "@/lib/posthog/events";
trackDocumentUploadFailure,
trackDocumentUploadStarted,
trackDocumentUploadSuccess,
} from "@/lib/posthog/events";
import { import {
getAcceptedFileTypes, getAcceptedFileTypes,
getSupportedExtensions, getSupportedExtensions,
@ -380,13 +376,14 @@ export function DocumentUploadTab({
setUploadProgress(Math.round((uploaded / total) * 100)); setUploadProgress(Math.round((uploaded / total) * 100));
} }
trackDocumentUploadSuccess(Number(workspaceId), total); // Ingestion outcome is now emitted server-side
// (document_processing_completed/_failed in document_tasks.py); the
// upload POST succeeding only means the file was accepted, not processed.
toast(t("upload_initiated"), { description: t("upload_initiated_desc") }); toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
setFolderUpload(null); setFolderUpload(null);
onSuccess?.(); onSuccess?.();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Upload failed"; const message = error instanceof Error ? error.message : "Upload failed";
trackDocumentUploadFailure(Number(workspaceId), message);
toast(t("upload_error"), { toast(t("upload_error"), {
description: `${t("upload_error_desc")}: ${message}`, description: `${t("upload_error_desc")}: ${message}`,
}); });
@ -421,7 +418,7 @@ export function DocumentUploadTab({
onSuccess: () => { onSuccess: () => {
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current); if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
setUploadProgress(100); setUploadProgress(100);
trackDocumentUploadSuccess(Number(workspaceId), files.length); // Ingestion outcome now server-side (document_processing_*).
toast(t("upload_initiated"), { description: t("upload_initiated_desc") }); toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
onSuccess?.(); onSuccess?.();
}, },
@ -429,7 +426,6 @@ export function DocumentUploadTab({
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current); if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
setUploadProgress(0); setUploadProgress(0);
const message = error instanceof Error ? error.message : "Upload failed"; const message = error instanceof Error ? error.message : "Upload failed";
trackDocumentUploadFailure(Number(workspaceId), message);
toast(t("upload_error"), { toast(t("upload_error"), {
description: `${t("upload_error_desc")}: ${message}`, description: `${t("upload_error_desc")}: ${message}`,
}); });

View file

@ -26,7 +26,7 @@ Connectors bring data into SurfSense — either as searchable knowledge or as li
## How connectors surface in SurfSense ## How connectors surface in SurfSense
- **Native scrapers** — the AI agent uses them as tools automatically in chat, and you can run them yourself from the **API Playground** or your own code via the REST API. - **Native scrapers** — the AI agent uses them as tools automatically in chat, and you can run them yourself from the **API Playground** or your own code via the REST API.
- **Tools & Live Sources** — external connectors like Notion, Slack, Jira, or Linear give the agent real-time tools. Ask a question in chat and the agent searches or acts on the connected service directly; nothing is copied into SurfSense in the background. - **Integrations** — external connectors like Notion, Slack, Jira, or Linear give the agent real-time tools. Ask a question in chat and the agent searches or acts on the connected service directly; nothing is copied into SurfSense in the background.
- **Knowledge Base** — file sources like Google Drive, OneDrive, and Dropbox are imported through the Documents sidebar's **Import** menu and indexed into your knowledge base alongside uploads and notes. - **Knowledge Base** — file sources like Google Drive, OneDrive, and Dropbox are imported through the Documents sidebar's **Import** menu and indexed into your knowledge base alongside uploads and notes.
To manage external connectors, open the **Connectors** dialog inside your workspace. Connected accounts, sync status, and disconnect options all live there. To manage external connectors, open the **Connectors** dialog inside your workspace. Connected accounts, sync status, and disconnect options all live there.

View file

@ -156,6 +156,40 @@ transport.
Celery runtime, and runtime gauges appear within one export interval. Celery runtime, and runtime gauges appear within one export interval.
6. Confirm logs emitted inside a traced request show non-zero trace and span IDs. 6. Confirm logs emitted inside a traced request show non-zero trace and span IDs.
## Product Analytics (PostHog)
Separate from OpenTelemetry, the backend can emit server-side product events to
PostHog. This is the authoritative source for outcome events (chats, document
ingestion, connector indexing, billing, automations) because it captures traffic
the browser never sees — MCP clients, personal-access-token scripts, and Celery
background jobs. It is fully opt-in and mirrors the OTel contract: with
`POSTHOG_API_KEY` unset, every capture is a silent no-op.
Use the **same** project key as the frontend's `NEXT_PUBLIC_POSTHOG_KEY` so
server events merge onto the same PostHog persons the web app identifies by user
id. Add these to `surfsense_backend/.env` (local) or `docker/.env` (production);
they reach the API, Celery worker, and beat services via `env_file`, so no
compose changes are needed:
```dotenv
POSTHOG_API_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
POSTHOG_HOST=https://us.i.posthog.com
POSTHOG_AI_PRIVACY_MODE=true
```
`POSTHOG_AI_PRIVACY_MODE` defaults to `true`; set it to `false` only if you want
LLM prompt and completion bodies shipped to PostHog's AI observability views.
Every backend event is stamped `source=backend` (so it is distinguishable from
frontend captures), carries `auth_method` / `client` for surface attribution, and
sends `disable_geoip=true` so the server IP never overwrites a person's real
location. LangGraph chat turns additionally emit `$ai_generation` / `$ai_span`
traces via the PostHog LangChain callback handler, keyed by turn and chat id.
Keep event properties low-cardinality. Never attach user content — workspace
names, connector titles, document titles, prompts, or raw queries — as event
properties; they carry no aggregation value and are a privacy risk.
## Out Of Scope ## Out Of Scope
- Frontend/browser OpenTelemetry. - Frontend/browser OpenTelemetry.

View file

@ -74,7 +74,17 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
case EnumConnectorName.CIRCLEBACK_CONNECTOR: case EnumConnectorName.CIRCLEBACK_CONNECTOR:
return <Image src="/connectors/circleback.svg" alt="Circleback" {...imgProps} />; return <Image src="/connectors/circleback.svg" alt="Circleback" {...imgProps} />;
case EnumConnectorName.MCP_CONNECTOR: case EnumConnectorName.MCP_CONNECTOR:
return <Image src="/connectors/modelcontextprotocol.svg" alt="MCP" {...imgProps} />; // Masked so the black glyph inherits currentColor (white in dark mode).
return (
<span
aria-hidden="true"
className={`${className || "h-5 w-5"} bg-current select-none pointer-events-none`}
style={{
mask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
WebkitMask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
}}
/>
);
case EnumConnectorName.OBSIDIAN_CONNECTOR: case EnumConnectorName.OBSIDIAN_CONNECTOR:
return <Image src="/connectors/obsidian.svg" alt="Obsidian" {...imgProps} />; return <Image src="/connectors/obsidian.svg" alt="Obsidian" {...imgProps} />;
case EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: case EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR:

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { Boxes, RefreshCw, TriangleAlert } from "lucide-react"; import { RefreshCw, Shapes, TriangleAlert } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { MobileReportPanel } from "@/components/report-panel/report-panel"; import { MobileReportPanel } from "@/components/report-panel/report-panel";
@ -48,7 +48,7 @@ function EmptyState() {
return ( return (
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-12 text-center"> <div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground"> <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Boxes className="h-6 w-6" aria-hidden /> <Shapes className="h-6 w-6" aria-hidden />
</div> </div>
<h3 className="mt-4 text-base font-semibold text-foreground">No artifacts yet</h3> <h3 className="mt-4 text-base font-semibold text-foreground">No artifacts yet</h3>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto"> <p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">

View file

@ -4,7 +4,6 @@ import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { ScraperRunDetail, ScraperRunEvent } from "@/contracts/types/scraper.types"; import type { ScraperRunDetail, ScraperRunEvent } from "@/contracts/types/scraper.types";
import { scrapersApiService } from "@/lib/apis/scrapers-api.service"; import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
import { trackWeeklyUser } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
export type RunStatus = "idle" | "running" | "success" | "error" | "cancelled"; export type RunStatus = "idle" | "running" | "success" | "error" | "cancelled";
@ -110,7 +109,8 @@ export function useRunStream(workspaceId: number) {
try { try {
const started = await scrapersApiService.runAsync(workspaceId, platform, verb, payload); const started = await scrapersApiService.runAsync(workspaceId, platform, verb, payload);
runIdRef.current = started.run_id; runIdRef.current = started.run_id;
trackWeeklyUser("api_run", workspaceId); // weekly_users removed — WAU is derived server-side from
// scraper_run_completed / chat_turn_completed.
setState((s) => ({ ...s, runId: started.run_id })); setState((s) => ({ ...s, runId: started.run_id }));
void consume(started.run_id, controller.signal); void consume(started.run_id, controller.signal);
} catch (e) { } catch (e) {

View file

@ -40,6 +40,30 @@ function blockRefreshRetry(key: string): void {
refreshRetryBlockedUntil.set(key, Date.now() + REFRESH_RETRY_BLOCK_MS); refreshRetryBlockedUntil.set(key, Date.now() + REFRESH_RETRY_BLOCK_MS);
} }
/**
* Send an API failure to PostHog error tracking. Scoped by the caller to only
* 5xx server faults + network outages 4xx responses are expected behavior.
* Lazy-imports posthog-js so an ad-blocker can never break the request path.
*/
function captureApiException(error: unknown, url: string, method?: RequestOptions["method"]): void {
import("posthog-js")
.then(({ default: posthog }) => {
posthog.captureException(error, {
api_url: url,
api_method: method ?? "GET",
...(error instanceof AppError && {
status_code: error.status,
status_text: error.statusText,
error_code: error.code,
request_id: error.requestId,
}),
});
})
.catch(() => {
console.error("Failed to capture exception in PostHog");
});
}
export type RequestOptions = { export type RequestOptions = {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record<string, string>; headers?: Record<string, string>;
@ -288,9 +312,12 @@ class BaseApiService {
throw new AbortedError(); throw new AbortedError();
} }
if (error instanceof TypeError && !(error instanceof AppError)) { if (error instanceof TypeError && !(error instanceof AppError)) {
throw new NetworkError( const networkError = new NetworkError(
"Unable to connect to the server. Check your internet connection and try again." "Unable to connect to the server. Check your internet connection and try again."
); );
// Network failures are genuine outages worth tracking.
captureApiException(networkError, url, options?.method);
throw networkError;
} }
// Handled client errors (validation, credits, auth, not-found, ...) are // Handled client errors (validation, credits, auth, not-found, ...) are
@ -305,23 +332,10 @@ class BaseApiService {
if (!isHandledClientError) { if (!isHandledClientError) {
console.error("Request failed:", JSON.stringify(error)); console.error("Request failed:", JSON.stringify(error));
if (!(error instanceof AuthenticationError)) { // Only 5xx server faults are unexpected; 4xx are handled above and
import("posthog-js") // network outages were already captured before this point.
.then(({ default: posthog }) => { if (error instanceof AppError && error.status >= 500) {
posthog.captureException(error, { captureApiException(error, url, options?.method);
api_url: url,
api_method: options?.method ?? "GET",
...(error instanceof AppError && {
status_code: error.status,
status_text: error.statusText,
error_code: error.code,
request_id: error.requestId,
}),
});
})
.catch(() => {
console.error("Failed to capture exception in PostHog");
});
} }
} }
throw error; throw error;

View file

@ -58,7 +58,6 @@ import {
import { buildBackendUrl } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { import {
trackChatBlocked, trackChatBlocked,
trackChatCreated,
trackChatErrorDetailed, trackChatErrorDetailed,
trackChatMessageSent, trackChatMessageSent,
trackChatResponseReceived, trackChatResponseReceived,
@ -384,7 +383,8 @@ export async function startNewChat(ctx: EngineContext, message: AppendMessage):
queryClient.setQueryData(cacheKeys.threads.detail(newThread.id), newThread); queryClient.setQueryData(cacheKeys.threads.detail(newThread.id), newThread);
queryClient.setQueryData(cacheKeys.threads.messages(newThread.id), { messages: [] }); queryClient.setQueryData(cacheKeys.threads.messages(newThread.id), { messages: [] });
trackChatCreated(workspaceId, currentThreadId); // chat_created is now emitted server-side (new_chat_routes.create_thread)
// so PAT/MCP-created threads are also counted.
isNewThread = true; isNewThread = true;
// Update URL silently using browser API (not router.replace) to avoid // Update URL silently using browser API (not router.replace) to avoid

View file

@ -3,24 +3,20 @@ import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat
import { getConnectorTelemetryMeta } from "@/lib/connector-telemetry"; import { getConnectorTelemetryMeta } from "@/lib/connector-telemetry";
/** /**
* PostHog Analytics Event Definitions * PostHog Analytics Event Definitions (frontend)
* *
* All capture/identify/reset calls are wrapped in try-catch so that * All capture/identify/reset calls are wrapped in try-catch so that
* ad-blockers that interfere with posthog-js can never break app * ad-blockers that interfere with posthog-js can never break app
* functionality (e.g. the chat flow). * functionality (e.g. the chat flow).
* *
* Events follow a consistent naming convention: category_action * SCOPE: this file now holds only *intent* and client-perceived *UX* events.
* Authoritative *outcome* events (resource creation, task/ingestion/indexing
* completion, auth success, billing) are emitted server-side in
* surfsense_backend/app/observability/analytics.py they are reliable
* regardless of ad-blockers, tab-close, or non-browser (MCP/PAT/OAuth)
* clients. Do NOT re-add optimistic outcome captures here; they double-count.
* *
* Categories: * Events follow a consistent naming convention: category_action
* - auth: Authentication events
* - workspace: Search space management
* - document: Document management
* - chat: Chat and messaging (authenticated + anonymous)
* - connector: External connector events (all lifecycle stages)
* - contact: Contact form events
* - settings: Settings changes
* - automation: Automation lifecycle (create/update/delete/trigger/chat)
* - marketing: Marketing/referral tracking
*/ */
function safeCapture(event: string, properties?: Record<string, unknown>) { function safeCapture(event: string, properties?: Record<string, unknown>) {
@ -43,17 +39,13 @@ function compact<T extends object>(obj: T): Record<string, unknown> {
} }
// ============================================ // ============================================
// AUTH EVENTS // AUTH EVENTS (attempts + failures only; successes are server-side)
// ============================================ // ============================================
export function trackLoginAttempt(method: "local" | "google") { export function trackLoginAttempt(method: "local" | "google") {
safeCapture("auth_login_attempt", { method }); safeCapture("auth_login_attempt", { method });
} }
export function trackLoginSuccess(method: "local" | "google") {
safeCapture("auth_login_success", { method });
}
export function trackLoginFailure(method: "local" | "google", error?: string) { export function trackLoginFailure(method: "local" | "google", error?: string) {
safeCapture("auth_login_failure", { method, error }); safeCapture("auth_login_failure", { method, error });
} }
@ -62,10 +54,6 @@ export function trackRegistrationAttempt() {
safeCapture("auth_registration_attempt"); safeCapture("auth_registration_attempt");
} }
export function trackRegistrationSuccess() {
safeCapture("auth_registration_success");
}
export function trackRegistrationFailure(error?: string) { export function trackRegistrationFailure(error?: string) {
safeCapture("auth_registration_failure", { error }); safeCapture("auth_registration_failure", { error });
} }
@ -75,56 +63,9 @@ export function trackLogout() {
} }
// ============================================ // ============================================
// SEARCH SPACE EVENTS // CHAT EVENTS (client-perceived UX)
// ============================================ // ============================================
export function trackWorkspaceCreated(workspaceId: number, name: string) {
safeCapture("workspace_created", {
workspace_id: workspaceId,
name,
});
}
export function trackWorkspaceDeleted(workspaceId: number) {
safeCapture("workspace_deleted", {
workspace_id: workspaceId,
});
}
export function trackWorkspaceViewed(workspaceId: number) {
safeCapture("workspace_viewed", {
workspace_id: workspaceId,
});
}
// ============================================
// ACTIVE-USER (WAU) EVENT
// ============================================
/**
* Single signal for active-user counting. Fired whenever a user sends a
* chat message or starts an API run, so a "weekly unique users on
* weekly_users" insight in PostHog is our WAU number.
*
* ponytail: frontend-only capture API runs made directly against the
* backend (PAT/curl, no browser) are not counted. Upgrade path is a
* server-side capture in the backend if that ever matters.
*/
export function trackWeeklyUser(source: "chat_message" | "api_run", workspaceId?: number) {
safeCapture("weekly_users", compact({ source, workspace_id: workspaceId }));
}
// ============================================
// CHAT EVENTS
// ============================================
export function trackChatCreated(workspaceId: number, chatId: number) {
safeCapture("chat_created", {
workspace_id: workspaceId,
chat_id: chatId,
});
}
export function trackChatMessageSent( export function trackChatMessageSent(
workspaceId: number, workspaceId: number,
chatId: number, chatId: number,
@ -141,7 +82,6 @@ export function trackChatMessageSent(
has_mentioned_documents: options?.hasMentionedDocuments ?? false, has_mentioned_documents: options?.hasMentionedDocuments ?? false,
message_length: options?.messageLength, message_length: options?.messageLength,
}); });
trackWeeklyUser("chat_message", workspaceId);
} }
export function trackChatResponseReceived(workspaceId: number, chatId: number) { export function trackChatResponseReceived(workspaceId: number, chatId: number) {
@ -213,6 +153,10 @@ export function trackChatErrorDetailed(
* flow. This is intentionally a separate event from `chat_message_sent` * flow. This is intentionally a separate event from `chat_message_sent`
* so WAU / retention queries on the authenticated event stay clean while * so WAU / retention queries on the authenticated event stay clean while
* still giving us visibility into top-of-funnel usage on /free/*. * still giving us visibility into top-of-funnel usage on /free/*.
*
* Kept frontend-side despite the backend's `anon_chat_turn_completed`: the
* frontend anon distinct id is what merges into the person at signup,
* powering the anonymous-to-registered conversion funnel.
*/ */
export function trackAnonymousChatMessageSent(options: { export function trackAnonymousChatMessageSent(options: {
modelSlug: string; modelSlug: string;
@ -229,7 +173,7 @@ export function trackAnonymousChatMessageSent(options: {
} }
// ============================================ // ============================================
// DOCUMENT EVENTS // DOCUMENT EVENTS (intent only; ingestion outcome is server-side)
// ============================================ // ============================================
export function trackDocumentUploadStarted( export function trackDocumentUploadStarted(
@ -244,59 +188,20 @@ export function trackDocumentUploadStarted(
}); });
} }
export function trackDocumentUploadSuccess(workspaceId: number, fileCount: number) {
safeCapture("document_upload_success", {
workspace_id: workspaceId,
file_count: fileCount,
});
}
export function trackDocumentUploadFailure(workspaceId: number, error?: string) {
safeCapture("document_upload_failure", {
workspace_id: workspaceId,
error,
});
}
export function trackDocumentDeleted(workspaceId: number, documentId: number) {
safeCapture("document_deleted", {
workspace_id: workspaceId,
document_id: documentId,
});
}
export function trackDocumentBulkDeleted(workspaceId: number, count: number) {
safeCapture("document_bulk_deleted", {
workspace_id: workspaceId,
count,
});
}
export function trackYouTubeImport(workspaceId: number, url: string) {
safeCapture("youtube_import_started", {
workspace_id: workspaceId,
url,
});
}
// ============================================ // ============================================
// CONNECTOR EVENTS (generic lifecycle dispatcher) // CONNECTOR EVENTS (setup intent/UX; connected/deleted are server-side)
// ============================================ // ============================================
// //
// All connector events go through `trackConnectorEvent`. The connector's // All connector events go through `trackConnectorEvent`. The connector's
// human-readable title and its group (oauth/composio/crawler/other) are // group (oauth/composio/crawler/other) is auto-attached from the shared
// auto-attached from the shared registry in `connector-constants.ts`, so // registry, so adding a new connector to that list is the only change
// adding a new connector to that list is the only change required for it // required for it to show up correctly in PostHog dashboards.
// to show up correctly in PostHog dashboards.
export type ConnectorEventStage = export type ConnectorEventStage =
| "setup_started" | "setup_started"
| "setup_success" | "setup_success"
| "setup_failure" | "setup_failure"
| "oauth_initiated" | "oauth_initiated";
| "connected"
| "deleted"
| "synced";
export interface ConnectorEventOptions { export interface ConnectorEventOptions {
workspaceId?: number | null; workspaceId?: number | null;
@ -312,6 +217,9 @@ export interface ConnectorEventOptions {
/** /**
* Generic connector lifecycle tracker. Every connector analytics event * Generic connector lifecycle tracker. Every connector analytics event
* should funnel through here so the enrichment stays consistent. * should funnel through here so the enrichment stays consistent.
*
* ``connector_title`` is intentionally NOT sent it's a display label with
* no aggregation value; segment on ``connector_type`` / ``connector_group``.
*/ */
export function trackConnectorEvent( export function trackConnectorEvent(
stage: ConnectorEventStage, stage: ConnectorEventStage,
@ -327,7 +235,6 @@ export function trackConnectorEvent(
error: options.error, error: options.error,
}), }),
connector_type: meta.connector_type, connector_type: meta.connector_type,
connector_title: meta.connector_title,
connector_group: meta.connector_group, connector_group: meta.connector_group,
is_oauth: meta.is_oauth, is_oauth: meta.is_oauth,
...(options.extra ?? {}), ...(options.extra ?? {}),
@ -365,59 +272,10 @@ export function trackConnectorSetupFailure(
}); });
} }
export function trackConnectorDeleted(
workspaceId: number,
connectorType: string,
connectorId: number
) {
trackConnectorEvent("deleted", connectorType, { workspaceId, connectorId });
}
export function trackConnectorSynced(
workspaceId: number,
connectorType: string,
connectorId: number
) {
trackConnectorEvent("synced", connectorType, { workspaceId, connectorId });
}
// ============================================
// SETTINGS EVENTS
// ============================================
export function trackSettingsViewed(workspaceId: number, section: string) {
safeCapture("settings_viewed", {
workspace_id: workspaceId,
section,
});
}
export function trackSettingsUpdated(workspaceId: number, section: string, setting: string) {
safeCapture("settings_updated", {
workspace_id: workspaceId,
section,
setting,
});
}
// ============================================ // ============================================
// FEATURE USAGE EVENTS // FEATURE USAGE EVENTS
// ============================================ // ============================================
export function trackPodcastGenerated(workspaceId: number, chatId: number) {
safeCapture("podcast_generated", {
workspace_id: workspaceId,
chat_id: chatId,
});
}
export function trackSourcesTabViewed(workspaceId: number, tab: string) {
safeCapture("sources_tab_viewed", {
workspace_id: workspaceId,
tab,
});
}
export function trackDesktopDownloadClicked(options: { export function trackDesktopDownloadClicked(options: {
os: string; os: string;
placement: "sidebar_collapsed" | "sidebar_expanded"; placement: "sidebar_collapsed" | "sidebar_expanded";
@ -429,84 +287,7 @@ export function trackDesktopDownloadClicked(options: {
} }
// ============================================ // ============================================
// SEARCH SPACE INVITE EVENTS // INDEXING EVENTS (intent/UX; indexing outcome is server-side)
// ============================================
export function trackWorkspaceInviteSent(
workspaceId: number,
options?: {
roleName?: string;
hasExpiry?: boolean;
hasMaxUses?: boolean;
}
) {
safeCapture("workspace_invite_sent", {
workspace_id: workspaceId,
role_name: options?.roleName,
has_expiry: options?.hasExpiry ?? false,
has_max_uses: options?.hasMaxUses ?? false,
});
}
export function trackWorkspaceInviteAccepted(
workspaceId: number,
workspaceName: string,
roleName?: string | null
) {
safeCapture("workspace_invite_accepted", {
workspace_id: workspaceId,
workspace_name: workspaceName,
role_name: roleName,
});
}
export function trackWorkspaceInviteDeclined(workspaceName?: string) {
safeCapture("workspace_invite_declined", {
workspace_name: workspaceName,
});
}
export function trackWorkspaceUserAdded(
workspaceId: number,
workspaceName: string,
roleName?: string | null
) {
safeCapture("workspace_user_added", {
workspace_id: workspaceId,
workspace_name: workspaceName,
role_name: roleName,
});
}
export function trackWorkspaceUsersViewed(
workspaceId: number,
userCount: number,
ownerCount: number
) {
safeCapture("workspace_users_viewed", {
workspace_id: workspaceId,
user_count: userCount,
owner_count: ownerCount,
});
}
// ============================================
// CONNECTOR CONNECTION EVENTS
// ============================================
export function trackConnectorConnected(
workspaceId: number,
connectorType: string,
connectorId?: number
) {
trackConnectorEvent("connected", connectorType, {
workspaceId,
connectorId: connectorId ?? undefined,
});
}
// ============================================
// INDEXING EVENTS
// ============================================ // ============================================
export function trackIndexWithDateRangeOpened( export function trackIndexWithDateRangeOpened(
@ -578,20 +359,19 @@ export function trackPeriodicIndexingStarted(
} }
// ============================================ // ============================================
// INCENTIVE TASKS EVENTS // SEARCH SPACE INVITE EVENTS (decline is client-only; sent/accepted server-side)
// ============================================ // ============================================
export function trackIncentivePageViewed() { export function trackWorkspaceInviteDeclined(workspaceName?: string) {
safeCapture("incentive_page_viewed"); safeCapture("workspace_invite_declined", {
} workspace_name: workspaceName,
export function trackIncentiveTaskCompleted(taskType: string, creditMicrosRewarded: number) {
safeCapture("incentive_task_completed", {
task_type: taskType,
credit_micros_rewarded: creditMicrosRewarded,
}); });
} }
// ============================================
// INCENTIVE TASKS EVENTS (click intent only; completion is server-side)
// ============================================
export function trackIncentiveTaskClicked(taskType: string) { export function trackIncentiveTaskClicked(taskType: string) {
safeCapture("incentive_task_clicked", { safeCapture("incentive_task_clicked", {
task_type: taskType, task_type: taskType,
@ -616,83 +396,25 @@ export function trackReferralLanding(refCode: string, landingUrl: string) {
} }
// ============================================ // ============================================
// AUTOMATION EVENTS // AUTOMATION EVENTS (failures + chat-builder UX; CRUD outcomes are server-side)
// ============================================ // ============================================
interface AutomationCreatedProps {
workspace_id: number;
automation_id: number;
task_count?: number;
trigger_type?: string;
has_schedule?: boolean;
chat_model_id?: number;
image_gen_model_id?: number;
vision_model_id?: number;
tags_count?: number;
}
export function trackAutomationCreated(props: AutomationCreatedProps) {
safeCapture("automation_created", compact(props));
}
export function trackAutomationCreateFailed(props: { workspace_id?: number; error?: string }) { export function trackAutomationCreateFailed(props: { workspace_id?: number; error?: string }) {
safeCapture("automation_create_failed", compact(props)); safeCapture("automation_create_failed", compact(props));
} }
export function trackAutomationUpdated(props: {
automation_id: number;
workspace_id?: number;
has_definition_change?: boolean;
has_name_change?: boolean;
has_description_change?: boolean;
task_count?: number;
}) {
safeCapture("automation_updated", compact(props));
}
export function trackAutomationStatusChanged(props: {
automation_id: number;
workspace_id?: number;
next_status: string;
}) {
safeCapture("automation_status_changed", compact(props));
}
export function trackAutomationUpdateFailed(props: { automation_id: number; error?: string }) { export function trackAutomationUpdateFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_update_failed", compact(props)); safeCapture("automation_update_failed", compact(props));
} }
export function trackAutomationDeleted(props: { automation_id: number; workspace_id?: number }) {
safeCapture("automation_deleted", compact(props));
}
export function trackAutomationDeleteFailed(props: { automation_id: number; error?: string }) { export function trackAutomationDeleteFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_delete_failed", compact(props)); safeCapture("automation_delete_failed", compact(props));
} }
export function trackAutomationTriggerAdded(props: {
automation_id: number;
trigger_id?: number;
trigger_type?: string;
enabled?: boolean;
has_cron?: boolean;
}) {
safeCapture("automation_trigger_added", compact(props));
}
export function trackAutomationTriggerAddFailed(props: { automation_id: number; error?: string }) { export function trackAutomationTriggerAddFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_trigger_add_failed", compact(props)); safeCapture("automation_trigger_add_failed", compact(props));
} }
export function trackAutomationTriggerUpdated(props: {
automation_id: number;
trigger_id: number;
change?: "enabled" | "params" | "other";
enabled?: boolean;
}) {
safeCapture("automation_trigger_updated", compact(props));
}
export function trackAutomationTriggerUpdateFailed(props: { export function trackAutomationTriggerUpdateFailed(props: {
automation_id: number; automation_id: number;
trigger_id: number; trigger_id: number;
@ -701,13 +423,6 @@ export function trackAutomationTriggerUpdateFailed(props: {
safeCapture("automation_trigger_update_failed", compact(props)); safeCapture("automation_trigger_update_failed", compact(props));
} }
export function trackAutomationTriggerRemoved(props: {
automation_id: number;
trigger_id: number;
}) {
safeCapture("automation_trigger_removed", compact(props));
}
export function trackAutomationTriggerRemoveFailed(props: { export function trackAutomationTriggerRemoveFailed(props: {
automation_id: number; automation_id: number;
trigger_id: number; trigger_id: number;

View file

@ -664,7 +664,6 @@
"chat_deleted": "Chat deleted successfully", "chat_deleted": "Chat deleted successfully",
"error_deleting_chat": "Failed to delete chat", "error_deleting_chat": "Failed to delete chat",
"delete": "Delete", "delete": "Delete",
"try_different_search": "Try a different search term",
"updated": "Updated", "updated": "Updated",
"more_options": "More options", "more_options": "More options",
"clear_search": "Clear search", "clear_search": "Clear search",

View file

@ -664,7 +664,6 @@
"chat_deleted": "Chat eliminado correctamente", "chat_deleted": "Chat eliminado correctamente",
"error_deleting_chat": "Error al eliminar el chat", "error_deleting_chat": "Error al eliminar el chat",
"delete": "Eliminar", "delete": "Eliminar",
"try_different_search": "Intenta con un término de búsqueda diferente",
"updated": "Actualizado", "updated": "Actualizado",
"more_options": "Más opciones", "more_options": "Más opciones",
"clear_search": "Limpiar búsqueda", "clear_search": "Limpiar búsqueda",

View file

@ -664,7 +664,6 @@
"chat_deleted": "चैट सफलतापूर्वक हटाया गया", "chat_deleted": "चैट सफलतापूर्वक हटाया गया",
"error_deleting_chat": "चैट हटाने में विफल", "error_deleting_chat": "चैट हटाने में विफल",
"delete": "हटाएं", "delete": "हटाएं",
"try_different_search": "कोई अलग खोज शब्द आज़माएं",
"updated": "अपडेट किया गया", "updated": "अपडेट किया गया",
"more_options": "और विकल्प", "more_options": "और विकल्प",
"clear_search": "खोज साफ करें", "clear_search": "खोज साफ करें",

View file

@ -664,7 +664,6 @@
"chat_deleted": "채팅이 성공적으로 삭제되었습니다", "chat_deleted": "채팅이 성공적으로 삭제되었습니다",
"error_deleting_chat": "채팅 삭제에 실패했습니다", "error_deleting_chat": "채팅 삭제에 실패했습니다",
"delete": "삭제", "delete": "삭제",
"try_different_search": "다른 검색어를 시도해보세요",
"updated": "업데이트됨", "updated": "업데이트됨",
"more_options": "추가 옵션", "more_options": "추가 옵션",
"clear_search": "검색 지우기", "clear_search": "검색 지우기",

View file

@ -664,7 +664,6 @@
"chat_deleted": "Chat excluído com sucesso", "chat_deleted": "Chat excluído com sucesso",
"error_deleting_chat": "Falha ao excluir chat", "error_deleting_chat": "Falha ao excluir chat",
"delete": "Excluir", "delete": "Excluir",
"try_different_search": "Tente um termo de pesquisa diferente",
"updated": "Atualizado", "updated": "Atualizado",
"more_options": "Mais opções", "more_options": "Mais opções",
"clear_search": "Limpar pesquisa", "clear_search": "Limpar pesquisa",

View file

@ -663,7 +663,6 @@
"chat_deleted": "对话删除成功", "chat_deleted": "对话删除成功",
"error_deleting_chat": "删除对话失败", "error_deleting_chat": "删除对话失败",
"delete": "删除", "delete": "删除",
"try_different_search": "尝试其他搜索词",
"updated": "更新时间", "updated": "更新时间",
"more_options": "更多选项", "more_options": "更多选项",
"clear_search": "清除搜索", "clear_search": "清除搜索",