diff --git a/surfsense_backend/alembic/versions/174_add_llm_setup_completed_at.py b/surfsense_backend/alembic/versions/174_add_llm_setup_completed_at.py new file mode 100644 index 000000000..b4aadb189 --- /dev/null +++ b/surfsense_backend/alembic/versions/174_add_llm_setup_completed_at.py @@ -0,0 +1,30 @@ +"""Add workspaces.llm_setup_completed_at for first-run vs. recovery onboarding. + +No backfill: NULL is correct for every existing row. Configured workspaces +self-heal via lazy stamping on their next status read (which fires while still +``ready``, before they could reach ``needs_setup``); a blanket +``SET ... = created_at`` would misclassify abandoned and global-only workspaces. + +Revision ID: 174 +Revises: 173 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "174" +down_revision: str | None = "173" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE workspaces " + "ADD COLUMN IF NOT EXISTS llm_setup_completed_at TIMESTAMPTZ" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE workspaces DROP COLUMN IF EXISTS llm_setup_completed_at") diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/report.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/report.py index e95d6f61e..df9d2120e 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/report.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/report.py @@ -773,7 +773,7 @@ def create_generate_report_tool( if not llm: error_msg = ( - "No LLM configured. Please configure a language model in Settings." + "No LLM configured. Please configure a chat model in Settings." ) report_id = await _save_failed_report(error_msg) return _failed( diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py index c3475fb45..1c59bf034 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py @@ -585,7 +585,7 @@ def create_generate_resume_tool( if not llm: error_msg = ( - "No LLM configured. Please configure a language model in Settings." + "No LLM configured. Please configure a chat model in Settings." ) report_id = await _save_failed_report(error_msg) return _failed( diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 380a8b2bf..d5bad55e0 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1732,6 +1732,11 @@ class Workspace(BaseModel, TimestampMixin): Integer, nullable=True, default=0, server_default="0" ) # For vision/screenshot analysis, defaults to Auto mode + # First time this workspace went ready via its own model (source=="models"). + # NULL = never self-configured. Set once, never cleared; splits a needs_setup + # verdict into first-run vs. recovery. + llm_setup_completed_at = Column(TIMESTAMP(timezone=True), nullable=True) + user_id = Column( UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) diff --git a/surfsense_backend/app/routes/model_connections_routes.py b/surfsense_backend/app/routes/model_connections_routes.py index 8f6a17cfd..68d9d3da2 100644 --- a/surfsense_backend/app/routes/model_connections_routes.py +++ b/surfsense_backend/app/routes/model_connections_routes.py @@ -1,4 +1,5 @@ import logging +from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select, update @@ -16,11 +17,13 @@ from app.db import ( Permission, Workspace, get_async_session, + has_permission, ) from app.schemas import ( ConnectionCreate, ConnectionRead, ConnectionUpdate, + LlmSetupStatusRead, ModelCreate, ModelPreviewRead, ModelProviderRead, @@ -43,7 +46,7 @@ from app.services.model_connection_service import ( ) from app.services.provider_registry import REGISTRY from app.users import get_auth_context, require_session_context -from app.utils.rbac import check_permission +from app.utils.rbac import check_permission, get_user_permissions router = APIRouter() logger = logging.getLogger(__name__) @@ -355,7 +358,7 @@ async def list_connections( session, auth, workspace_id, - Permission.LLM_CONFIGS_CREATE.value, + Permission.LLM_CONFIGS_READ.value, "You don't have permission to view model connections in this workspace", ) stmt = stmt.where(Connection.workspace_id == workspace_id) @@ -760,7 +763,7 @@ async def get_model_roles( session, auth, workspace_id, - Permission.LLM_CONFIGS_CREATE.value, + Permission.LLM_CONFIGS_READ.value, "You don't have permission to view model roles in this workspace", ) workspace = await _clear_invalid_roles(session, workspace_id) @@ -831,3 +834,127 @@ async def update_model_roles( vision_model_id=workspace.vision_model_id, image_gen_model_id=workspace.image_gen_model_id, ) + + +def _global_catalog_has_usable_chat() -> bool: + """True when the operator global catalog exposes a usable chat model. + + Checks usability (enabled connection + enabled, chat-capable model), not + mere presence of ``global_llm_config.yaml`` — an empty or malformed file, + or an OpenRouter-only config whose startup fetch failed, yields no models + and must fall through to onboarding. + """ + enabled_connection_ids = { + conn["id"] for conn in config.GLOBAL_CONNECTIONS if conn.get("enabled", True) + } + return any( + model.get("connection_id") in enabled_connection_ids + and model.get("enabled", True) + and has_capability(model, "chat") + for model in config.GLOBAL_MODELS + ) + + +async def _workspace_has_enabled_chat_model( + session: AsyncSession, workspace_id: int +) -> bool: + result = await session.execute( + select(Connection) + .options(selectinload(Connection.models)) + .where( + Connection.workspace_id == workspace_id, + Connection.enabled == True, + ) + ) + return any( + model.enabled and has_capability(model, "chat") + for conn in result.scalars().all() + for model in conn.models + ) + + +async def compute_llm_setup_status( + session: AsyncSession, + auth: AuthContext, + workspace_id: int, +) -> LlmSetupStatusRead: + """Single source of truth for whether a workspace can chat. + + "Needs onboarding" is derived, never persisted: a workspace is ``ready`` + exactly when a usable chat model resolves for it (operator global catalog, + a valid pinned role, or an enabled chat-capable model in Auto mode). + """ + await check_permission( + session, + auth, + workspace_id, + Permission.LLM_CONFIGS_READ.value, + "You don't have permission to view LLM setup status in this workspace", + ) + permissions = await get_user_permissions(session, auth.user.id, workspace_id) + can_configure = has_permission(permissions, Permission.LLM_CONFIGS_CREATE.value) + + global_usable = _global_catalog_has_usable_chat() + if config.GLOBAL_LLM_CONFIG_FILE_EXISTS and global_usable: + # Global readiness is never stamped: it is not this workspace's own setup. + return LlmSetupStatusRead( + status="ready", + source="global_config", + can_configure=can_configure, + stage="ready", + ) + + # Heal dangling role pins first: a chat_model_id pointing at a deleted or + # disabled model collapses to 0 (Auto) here, so the checks below see the + # real state. + workspace = await _clear_invalid_roles(session, workspace_id) + await session.commit() + await session.refresh(workspace) + + async def _stamp_own_setup() -> None: + # Record first own-model readiness once; the guard makes concurrent + # stamps idempotent. (Writing on this GET is precedented above.) + if workspace.llm_setup_completed_at is None: + workspace.llm_setup_completed_at = datetime.now(UTC) + await session.commit() + + chat_model_id = workspace.chat_model_id or 0 + if chat_model_id != 0: + # Survived _clear_invalid_roles => valid, enabled, chat-capable. + source = "global_config" if chat_model_id < 0 else "models" + if source == "models": + await _stamp_own_setup() + return LlmSetupStatusRead( + status="ready", source=source, can_configure=can_configure, stage="ready" + ) + + if global_usable: + return LlmSetupStatusRead( + status="ready", + source="global_config", + can_configure=can_configure, + stage="ready", + ) + if await _workspace_has_enabled_chat_model(session, workspace_id): + await _stamp_own_setup() + return LlmSetupStatusRead( + status="ready", source="models", can_configure=can_configure, stage="ready" + ) + + # A set timestamp => self-configured before (recovery); NULL => never (first-run). + stage = "recovery" if workspace.llm_setup_completed_at else "initial_setup" + return LlmSetupStatusRead( + status="needs_setup", source="none", can_configure=can_configure, stage=stage + ) + + +@router.get( + "/workspaces/{workspace_id}/llm-setup-status", + response_model=LlmSetupStatusRead, +) +async def llm_setup_status( + workspace_id: int, + session: AsyncSession = Depends(get_async_session), + auth: AuthContext = Depends(get_auth_context), +): + return await compute_llm_setup_status(session, auth, workspace_id) diff --git a/surfsense_backend/app/routes/workspaces_routes.py b/surfsense_backend/app/routes/workspaces_routes.py index 4f36ef62b..9dd3f196c 100644 --- a/surfsense_backend/app/routes/workspaces_routes.py +++ b/surfsense_backend/app/routes/workspaces_routes.py @@ -14,6 +14,7 @@ from app.db import ( get_async_session, get_default_roles_config, ) +from app.routes.model_connections_routes import compute_llm_setup_status from app.schemas import ( WorkspaceApiAccessUpdate, WorkspaceCreate, @@ -93,7 +94,12 @@ async def create_workspace( await session.commit() await session.refresh(db_workspace) - return db_workspace + + response = WorkspaceRead.model_validate(db_workspace) + response.llm_setup = await compute_llm_setup_status( + session, auth, db_workspace.id + ) + return response except HTTPException: raise except Exception as e: diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index 845136cfc..482c895a3 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -43,6 +43,7 @@ from .model_connections import ( ConnectionCreate, ConnectionRead, ConnectionUpdate, + LlmSetupStatusRead, ModelCreate, ModelPreviewRead, ModelProviderRead, @@ -185,13 +186,14 @@ __all__ = [ "InviteInfoResponse", "InviteRead", "InviteUpdate", + # Auth schemas + "LlmSetupStatusRead", # Log schemas "LogBase", "LogCreate", "LogFilter", "LogRead", "LogUpdate", - # Auth schemas "LogoutAllResponse", "LogoutRequest", "LogoutResponse", diff --git a/surfsense_backend/app/schemas/model_connections.py b/surfsense_backend/app/schemas/model_connections.py index 7b7ac33ed..9c61e9c38 100644 --- a/surfsense_backend/app/schemas/model_connections.py +++ b/surfsense_backend/app/schemas/model_connections.py @@ -1,6 +1,6 @@ import uuid from datetime import datetime -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field @@ -146,3 +146,18 @@ class ModelRolesUpdate(BaseModel): chat_model_id: int | None = None vision_model_id: int | None = None image_gen_model_id: int | None = None + + +class LlmSetupStatusRead(BaseModel): + """Server-authoritative verdict for the per-workspace LLM onboarding gate. + + ``status`` is the only thing the frontend gate acts on; ``source`` is + informational and ``can_configure`` selects the onboarding vs. blocked + screen for members who cannot manage models. ``stage`` refines a + ``needs_setup`` verdict into first-run (``initial_setup``) vs. recovery. + """ + + status: Literal["ready", "needs_setup"] + source: Literal["global_config", "models", "none"] + can_configure: bool + stage: Literal["initial_setup", "recovery", "ready"] diff --git a/surfsense_backend/app/schemas/workspace.py b/surfsense_backend/app/schemas/workspace.py index 57b7e0a52..b804e6d5b 100644 --- a/surfsense_backend/app/schemas/workspace.py +++ b/surfsense_backend/app/schemas/workspace.py @@ -4,6 +4,7 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict from .base import IDModel, TimestampModel +from .model_connections import LlmSetupStatusRead class WorkspaceBase(BaseModel): @@ -35,6 +36,9 @@ class WorkspaceRead(WorkspaceBase, IDModel, TimestampModel): api_access_enabled: bool = False qna_custom_instructions: str | None = None shared_memory_md: str | None = None + # Populated only by create_workspace so the client can route straight to + # onboarding vs. new-chat on the first hop. Null everywhere else. + llm_setup: LlmSetupStatusRead | None = None model_config = ConfigDict(from_attributes=True) diff --git a/surfsense_backend/tests/unit/routes/test_llm_setup_status.py b/surfsense_backend/tests/unit/routes/test_llm_setup_status.py new file mode 100644 index 000000000..2f07d8bd2 --- /dev/null +++ b/surfsense_backend/tests/unit/routes/test_llm_setup_status.py @@ -0,0 +1,318 @@ +"""Unit tests for the server-authoritative LLM onboarding verdict. + +``compute_llm_setup_status`` is the single source of truth for whether a +workspace can chat. These tests cover the two pieces of genuinely new logic: + +1. ``_global_catalog_has_usable_chat`` — a pure check over the operator + global catalog (usable model, not mere file presence). +2. The decision tree in ``compute_llm_setup_status`` — exercised by faking + the DB-touching seams (``_clear_invalid_roles`` heals dangling pins, + ``_workspace_has_enabled_chat_model`` reports BYOK models) so the routing + between ready / needs_setup / global_config / models is asserted directly. +""" + +from __future__ import annotations + +from contextlib import ExitStack +from dataclasses import dataclass +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +import pytest + +from app.auth.context import AuthContext +from app.db import Permission +from app.routes import model_connections_routes as mc + + +@dataclass +class _FakeUser: + id: str = "u1" + + +@dataclass +class _FakeWorkspace: + chat_model_id: int | None = 0 + vision_model_id: int | None = 0 + image_gen_model_id: int | None = 0 + llm_setup_completed_at: datetime | None = None + + +def _global_model( + *, + model_id: int = -1, + connection_id: int = -1, + enabled: bool = True, + supports_chat: bool = True, +) -> dict: + return { + "id": model_id, + "connection_id": connection_id, + "enabled": enabled, + "supports_chat": supports_chat, + "capabilities_override": {}, + } + + +class TestGlobalCatalogHasUsableChat: + """Usability, not file existence, is what counts.""" + + def test_usable_when_enabled_connection_and_chat_model(self, monkeypatch): + monkeypatch.setattr(mc.config, "GLOBAL_CONNECTIONS", [{"id": -1, "enabled": True}]) + monkeypatch.setattr(mc.config, "GLOBAL_MODELS", [_global_model()]) + assert mc._global_catalog_has_usable_chat() is True + + def test_empty_catalog_is_not_usable(self, monkeypatch): + monkeypatch.setattr(mc.config, "GLOBAL_CONNECTIONS", []) + monkeypatch.setattr(mc.config, "GLOBAL_MODELS", []) + assert mc._global_catalog_has_usable_chat() is False + + def test_disabled_connection_is_not_usable(self, monkeypatch): + monkeypatch.setattr(mc.config, "GLOBAL_CONNECTIONS", [{"id": -1, "enabled": False}]) + monkeypatch.setattr(mc.config, "GLOBAL_MODELS", [_global_model()]) + assert mc._global_catalog_has_usable_chat() is False + + def test_disabled_model_is_not_usable(self, monkeypatch): + monkeypatch.setattr(mc.config, "GLOBAL_CONNECTIONS", [{"id": -1, "enabled": True}]) + monkeypatch.setattr(mc.config, "GLOBAL_MODELS", [_global_model(enabled=False)]) + assert mc._global_catalog_has_usable_chat() is False + + def test_non_chat_model_is_not_usable(self, monkeypatch): + monkeypatch.setattr(mc.config, "GLOBAL_CONNECTIONS", [{"id": -1, "enabled": True}]) + monkeypatch.setattr( + mc.config, "GLOBAL_MODELS", [_global_model(supports_chat=False)] + ) + assert mc._global_catalog_has_usable_chat() is False + + +async def _run_status( + *, + file_exists: bool, + global_usable: bool, + chat_model_id: int, + ws_has_chat: bool = False, + permissions: list[str] | None = None, + workspace: _FakeWorkspace | None = None, +): + """Drive the decision tree with DB-touching seams stubbed out. + + Pass ``workspace`` to preset/inspect ``llm_setup_completed_at`` — it is the + object ``_clear_invalid_roles`` returns, and the lazy stamp mutates it in + place (``session.commit`` is a no-op AsyncMock). + """ + if permissions is None: + permissions = [Permission.FULL_ACCESS.value] + if workspace is None: + workspace = _FakeWorkspace(chat_model_id=chat_model_id) + else: + workspace.chat_model_id = chat_model_id + with ExitStack() as stack: + stack.enter_context( + patch.object(mc.config, "GLOBAL_LLM_CONFIG_FILE_EXISTS", file_exists) + ) + stack.enter_context( + patch.object( + mc, "_global_catalog_has_usable_chat", return_value=global_usable + ) + ) + stack.enter_context(patch.object(mc, "check_permission", AsyncMock())) + stack.enter_context( + patch.object( + mc, "get_user_permissions", AsyncMock(return_value=permissions) + ) + ) + stack.enter_context( + patch.object( + mc, + "_clear_invalid_roles", + AsyncMock(return_value=workspace), + ) + ) + stack.enter_context( + patch.object( + mc, + "_workspace_has_enabled_chat_model", + AsyncMock(return_value=ws_has_chat), + ) + ) + return await mc.compute_llm_setup_status( + AsyncMock(), AuthContext.session(_FakeUser()), 1 + ) + + +class TestComputeLlmSetupStatus: + @pytest.mark.asyncio + async def test_no_yaml_no_models_needs_setup(self): + result = await _run_status( + file_exists=False, global_usable=False, chat_model_id=0, ws_has_chat=False + ) + assert result.status == "needs_setup" + assert result.source == "none" + assert result.stage == "initial_setup" + + @pytest.mark.asyncio + async def test_usable_global_catalog_is_ready(self): + ws = _FakeWorkspace() + result = await _run_status( + file_exists=True, global_usable=True, chat_model_id=0, workspace=ws + ) + assert result.status == "ready" + assert result.source == "global_config" + assert result.stage == "ready" + # Global readiness is never stamped as this workspace's own setup. + assert ws.llm_setup_completed_at is None + + @pytest.mark.asyncio + async def test_yaml_present_but_empty_catalog_falls_through(self): + # File exists but no usable model AND no BYOK => onboarding, not a + # dead composer. This is the empty/broken-YAML regression. + result = await _run_status( + file_exists=True, global_usable=False, chat_model_id=0, ws_has_chat=False + ) + assert result.status == "needs_setup" + assert result.source == "none" + assert result.stage == "initial_setup" + + @pytest.mark.asyncio + async def test_auto_mode_with_workspace_model_is_ready(self): + ws = _FakeWorkspace() + result = await _run_status( + file_exists=False, + global_usable=False, + chat_model_id=0, + ws_has_chat=True, + workspace=ws, + ) + assert result.status == "ready" + assert result.source == "models" + assert result.stage == "ready" + assert ws.llm_setup_completed_at is not None + + @pytest.mark.asyncio + async def test_auto_mode_counts_global_catalog_without_file(self): + result = await _run_status( + file_exists=False, global_usable=True, chat_model_id=0, ws_has_chat=False + ) + assert result.status == "ready" + assert result.source == "global_config" + + @pytest.mark.asyncio + async def test_pinned_workspace_model_is_ready(self): + # chat_model_id > 0 survived _clear_invalid_roles => valid + enabled. + ws = _FakeWorkspace() + result = await _run_status( + file_exists=False, global_usable=False, chat_model_id=5, workspace=ws + ) + assert result.status == "ready" + assert result.source == "models" + assert result.stage == "ready" + assert ws.llm_setup_completed_at is not None + + @pytest.mark.asyncio + async def test_pinned_global_model_is_ready(self): + ws = _FakeWorkspace() + result = await _run_status( + file_exists=False, global_usable=False, chat_model_id=-3, workspace=ws + ) + assert result.status == "ready" + assert result.source == "global_config" + assert result.stage == "ready" + # A negative pin is global-config readiness, not own setup. + assert ws.llm_setup_completed_at is None + + @pytest.mark.asyncio + async def test_pinned_dead_model_healed_to_needs_setup(self): + # A pin to a deleted/disabled model collapses to 0 in + # _clear_invalid_roles; with no fallback model it is needs_setup. + result = await _run_status( + file_exists=False, global_usable=False, chat_model_id=0, ws_has_chat=False + ) + assert result.status == "needs_setup" + + @pytest.mark.asyncio + async def test_can_configure_owner(self): + result = await _run_status( + file_exists=True, + global_usable=True, + chat_model_id=0, + permissions=[Permission.FULL_ACCESS.value], + ) + assert result.can_configure is True + + @pytest.mark.asyncio + async def test_can_configure_editor(self): + result = await _run_status( + file_exists=True, + global_usable=True, + chat_model_id=0, + permissions=[ + Permission.LLM_CONFIGS_CREATE.value, + Permission.LLM_CONFIGS_READ.value, + ], + ) + assert result.can_configure is True + + @pytest.mark.asyncio + async def test_can_configure_viewer_is_false(self): + result = await _run_status( + file_exists=True, + global_usable=True, + chat_model_id=0, + permissions=[Permission.LLM_CONFIGS_READ.value], + ) + assert result.can_configure is False + + +class TestOnboardingStage: + """First-run vs. recovery: the durable timestamp splits needs_setup.""" + + @pytest.mark.asyncio + async def test_fresh_workspace_is_initial_setup(self): + result = await _run_status( + file_exists=False, global_usable=False, chat_model_id=0, ws_has_chat=False + ) + assert result.stage == "initial_setup" + + @pytest.mark.asyncio + async def test_previously_configured_then_lost_is_recovery(self): + # Configured before (timestamp set), then deleted: needs_setup but recovery. + ws = _FakeWorkspace(llm_setup_completed_at=datetime.now(UTC)) + result = await _run_status( + file_exists=False, + global_usable=False, + chat_model_id=0, + ws_has_chat=False, + workspace=ws, + ) + assert result.status == "needs_setup" + assert result.stage == "recovery" + assert ws.llm_setup_completed_at is not None # preserved, never cleared + + @pytest.mark.asyncio + async def test_stamp_is_not_overwritten_on_subsequent_ready(self): + original = datetime(2020, 1, 1, tzinfo=UTC) + ws = _FakeWorkspace(llm_setup_completed_at=original) + result = await _run_status( + file_exists=False, + global_usable=False, + chat_model_id=0, + ws_has_chat=True, + workspace=ws, + ) + assert result.stage == "ready" + assert ws.llm_setup_completed_at == original + + @pytest.mark.asyncio + async def test_global_only_loss_is_initial_setup_not_recovery(self): + # Rode global (never stamped), then global lost: a genuine first own-setup. + ws = _FakeWorkspace() + result = await _run_status( + file_exists=False, + global_usable=False, + chat_model_id=0, + ws_has_chat=False, + workspace=ws, + ) + assert result.status == "needs_setup" + assert result.stage == "initial_setup" + assert ws.llm_setup_completed_at is None diff --git a/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx b/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx index 1f4948548..54fc9d000 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx @@ -4,15 +4,9 @@ import { useAtomValue, useSetAtom } from "jotai"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import type React from "react"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom"; -import { myAccessAtom } from "@/atoms/members/members-query.atoms"; -import { - globalLlmConfigStatusAtom, - globalModelConnectionsAtom, - modelConnectionsAtom, - modelRolesAtom, -} 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 { ConnectorIndicator } from "@/components/assistant-ui/connector-popup"; import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup"; @@ -22,14 +16,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { useFolderSync } from "@/hooks/use-folder-sync"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { useElectronAPI } from "@/hooks/use-platform"; -import { isLlmOnboardingComplete } from "@/lib/onboarding"; export function DashboardClientLayout({ children, workspaceId, + initialPlaygroundSidebarCollapsed, }: { children: React.ReactNode; workspaceId: string; + initialPlaygroundSidebarCollapsed: boolean; }) { const t = useTranslations("dashboard"); const router = useRouter(); @@ -39,85 +34,38 @@ export function DashboardClientLayout({ const setActiveWorkspaceIdState = useSetAtom(activeWorkspaceIdAtom); const setPendingUserImageUrls = useSetAtom(pendingUserImageDataUrlsAtom); - const { data: modelRoles = {}, isLoading: loading, error } = useAtomValue(modelRolesAtom); - const { data: globalConnections = [], isLoading: globalConfigsLoading } = useAtomValue( - globalModelConnectionsAtom - ); - const { data: modelConnections = [], isLoading: modelConnectionsLoading } = - useAtomValue(modelConnectionsAtom); - const { data: globalConfigStatus, isLoading: globalConfigStatusLoading } = - useAtomValue(globalLlmConfigStatusAtom); - - const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom); - const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false); + // Single source of truth for the onboarding gate. Keyed by the route + // workspaceId so the verdict can never lag behind a workspace switch. + const { + data: setupStatus, + isLoading: setupLoading, + error: setupError, + } = useAtomValue(llmSetupStatusAtomFamily(Number(workspaceId))); const isOnboardingPage = pathname?.includes("/onboard"); - const isOwner = access?.is_owner ?? false; const isWorkspaceReady = activeWorkspaceId === workspaceId; + const isReady = setupStatus?.status === "ready"; + + // First-run (initial_setup) is the only not-ready state that redirects, so + // recovery falls through to the inline composer notice and an established + // user who lost their models is never re-onboarded. The other direction + // leaves onboarding once the workspace can chat. useEffect(() => { - if (isWorkspaceReady) return; - setHasCheckedOnboarding(false); - }, [isWorkspaceReady]); - - useEffect(() => { - if (isOnboardingPage) { - setHasCheckedOnboarding(true); - return; - } - - if ( - isWorkspaceReady && - !loading && - !accessLoading && - !globalConfigsLoading && - !globalConfigStatusLoading && - !modelConnectionsLoading && - !hasCheckedOnboarding - ) { - // Onboarding is only relevant when no operator-provided - // global_llm_config.yaml exists. When it does, workspaces inherit - // the global config and should never be forced into onboarding. - if (globalConfigStatus?.exists) { - setHasCheckedOnboarding(true); - return; - } - - const onboardingComplete = isLlmOnboardingComplete( - modelRoles.chat_model_id, - globalConnections, - modelConnections - ); - - if (onboardingComplete) { - setHasCheckedOnboarding(true); - return; - } - - if (!isOwner) { - setHasCheckedOnboarding(true); - return; - } - - router.push(`/dashboard/${workspaceId}/onboard`); - setHasCheckedOnboarding(true); + if (setupLoading || setupError) return; + if (setupStatus?.stage === "initial_setup" && !isOnboardingPage) { + router.replace(`/dashboard/${workspaceId}/onboard`); + } else if (isReady && isOnboardingPage) { + router.replace(`/dashboard/${workspaceId}/new-chat`); } }, [ - isWorkspaceReady, - loading, - accessLoading, - globalConfigsLoading, - globalConfigStatusLoading, - globalConfigStatus, - modelConnectionsLoading, - modelRoles.chat_model_id, - globalConnections, - modelConnections, + setupLoading, + setupError, + setupStatus?.stage, + isReady, isOnboardingPage, - isOwner, router, workspaceId, - hasCheckedOnboarding, ]); const electronAPI = useElectronAPI(); @@ -167,18 +115,19 @@ export function DashboardClientLayout({ } }, [workspace_id, setActiveWorkspaceIdState, electronAPI]); - // Determine if we should show loading - const shouldShowLoading = - !hasCheckedOnboarding && - (!isWorkspaceReady || - loading || - accessLoading || - globalConfigsLoading || - globalConfigStatusLoading || - modelConnectionsLoading) && - !isOnboardingPage; + // Suppress children during either pending redirect so neither /new-chat nor + // /onboard flashes for a frame. + const isLeavingOnboarding = isReady && isOnboardingPage; + const isEnteringOnboarding = setupStatus?.stage === "initial_setup" && !isOnboardingPage; + const isRedirecting = + !setupLoading && !setupError && (isLeavingOnboarding || isEnteringOnboarding); + + // Block children until the workspace is synced and the initial verdict is + // in; afterwards the composer renders its own not-ready state in place, so + // recovery (e.g. deleting the last model) never triggers a full-screen + // loader or a redirect. + const shouldShowLoading = !setupError && (!isWorkspaceReady || setupLoading || isRedirecting); - // Use global loading screen - spinner animation won't reset useGlobalLoadingEffect(shouldShowLoading); // Wire desktop app file watcher -> single-file re-index API @@ -188,7 +137,7 @@ export function DashboardClientLayout({ return null; } - if (error && !hasCheckedOnboarding && !isOnboardingPage) { + if (setupError) { return (
@@ -200,7 +149,7 @@ export function DashboardClientLayout({

- {error instanceof Error ? error.message : String(error)} + {setupError instanceof Error ? setupError.message : String(setupError)}

@@ -215,7 +164,10 @@ export function DashboardClientLayout({ return ( - + {children} diff --git a/surfsense_web/app/dashboard/[workspace_id]/layout.tsx b/surfsense_web/app/dashboard/[workspace_id]/layout.tsx index 74c12fd98..4ef79076e 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/layout.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/layout.tsx @@ -1,16 +1,27 @@ // Server component import type React from "react"; -import { use } from "react"; +import { cookies } from "next/headers"; import { DashboardClientLayout } from "./client-layout"; -export default function DashboardLayout({ +const PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE = "surfsense_playground_sidebar_collapsed"; + +export default async function DashboardLayout({ params, children, }: { params: Promise<{ workspace_id: string }>; children: React.ReactNode; }) { - const { workspace_id } = use(params); + const [{ workspace_id }, cookieStore] = await Promise.all([params, cookies()]); + const initialPlaygroundSidebarCollapsed = + cookieStore.get(PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE)?.value === "true"; - return {children}; + return ( + + {children} + + ); } diff --git a/surfsense_web/app/dashboard/[workspace_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/onboard/page.tsx index 861ceaa07..bc7454e94 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/onboard/page.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/onboard/page.tsx @@ -2,66 +2,32 @@ import { useAtomValue } from "jotai"; import { useParams, useRouter } from "next/navigation"; -import { useEffect, useMemo } from "react"; +import { useEffect } from "react"; import { - globalLlmConfigStatusAtom, - globalModelConnectionsAtom, + llmSetupStatusAtomFamily, modelConnectionsAtom, - modelRolesAtom, } from "@/atoms/model-connections/model-connections-query.atoms"; import { Logo } from "@/components/Logo"; import { ModelProviderConnectionsPanel } from "@/components/settings/model-connections/model-provider-connections-panel"; import { Button } from "@/components/ui/button"; -import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { useSession } from "@/hooks/use-session"; import { redirectToLogin } from "@/lib/auth-utils"; -import { hasEnabledChatModel, isLlmOnboardingComplete } from "@/lib/onboarding"; export default function OnboardPage() { const router = useRouter(); const params = useParams(); const workspaceId = Number(params.workspace_id); const session = useSession(); - const { data: globalConnections = [], isLoading: globalLoading } = useAtomValue( - globalModelConnectionsAtom - ); const { data: connections = [] } = useAtomValue(modelConnectionsAtom); - const { data: roles = {}, isLoading: rolesLoading } = useAtomValue(modelRolesAtom); - const { data: globalConfigStatus, isLoading: globalConfigStatusLoading } = - useAtomValue(globalLlmConfigStatusAtom); + const { data: setupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId)); useEffect(() => { if (session.status === "unauthenticated") redirectToLogin(); }, [session.status]); - const hasUsableChatModel = useMemo( - () => hasEnabledChatModel([...globalConnections, ...connections]), - [globalConnections, connections] - ); - - const onboardingComplete = isLlmOnboardingComplete( - roles.chat_model_id, - globalConnections, - connections - ); - - const isLoading = - session.status === "loading" || globalLoading || rolesLoading || globalConfigStatusLoading; - - // Onboarding only applies when no global_llm_config.yaml exists. If a global - // config is present (or onboarding is already complete), leave this page. - const shouldLeaveOnboarding = - !isLoading && (Boolean(globalConfigStatus?.exists) || onboardingComplete); - - useEffect(() => { - if (shouldLeaveOnboarding) { - router.replace(`/dashboard/${workspaceId}/new-chat`); - } - }, [shouldLeaveOnboarding, router, workspaceId]); - - useGlobalLoadingEffect(isLoading || shouldLeaveOnboarding); - - if (isLoading || shouldLeaveOnboarding) return null; + // Leaving onboarding is the layout gate's job; here we only enable the + // explicit CTA once the workspace can chat. + const isReady = setupStatus?.status === "ready"; return (
@@ -80,8 +46,8 @@ export default function OnboardPage() { footerAction={ diff --git a/surfsense_web/atoms/model-connections/model-connections-mutation.atoms.ts b/surfsense_web/atoms/model-connections/model-connections-mutation.atoms.ts index 5228833e2..5e7e87b04 100644 --- a/surfsense_web/atoms/model-connections/model-connections-mutation.atoms.ts +++ b/surfsense_web/atoms/model-connections/model-connections-mutation.atoms.ts @@ -25,6 +25,27 @@ function invalidateModelConnections(workspaceId: number) { queryClient.invalidateQueries({ queryKey: cacheKeys.modelConnections.roles(workspaceId), }); + queryClient.invalidateQueries({ + queryKey: cacheKeys.modelConnections.setupStatus(workspaceId), + }); +} + +// After a mutation that can remove the workspace's last usable chat model, +// surface immediate feedback. The composer's inline notice covers the state on +// its own; this just makes the consequence obvious at the moment of the action. +async function warnIfWorkspaceChatDisabled(workspaceId: number) { + if (workspaceId <= 0) return; + try { + const status = await queryClient.fetchQuery({ + queryKey: cacheKeys.modelConnections.setupStatus(workspaceId), + queryFn: () => modelConnectionsApiService.getLlmSetupStatus(workspaceId), + }); + if (status?.status === "needs_setup") { + toast.warning("Chat is now disabled. Connect a chat model to start chatting again."); + } + } catch { + // Non-fatal: the inline composer notice still reflects the state. + } } function upsertModelConnection(workspaceId: number, connection: ConnectionRead) { @@ -78,9 +99,10 @@ export const deleteModelConnectionMutationAtom = atomWithMutation((get) => { return { mutationKey: ["model-connections", "delete"], mutationFn: (id: number) => modelConnectionsApiService.deleteConnection(id), - onSuccess: () => { + onSuccess: async () => { toast.success("Connection deleted"); invalidateModelConnections(workspaceId); + await warnIfWorkspaceChatDisabled(workspaceId); }, onError: (error: Error) => toast.error(error.message || "Failed to delete connection"), }; @@ -179,7 +201,10 @@ export const bulkUpdateModelsMutationAtom = atomWithMutation((get) => { mutationKey: ["models", "bulk-update"], mutationFn: ({ connectionId, data }: { connectionId: number; data: ModelsBulkUpdateRequest }) => modelConnectionsApiService.bulkUpdateModels(connectionId, data), - onSuccess: () => invalidateModelConnections(workspaceId), + onSuccess: async () => { + invalidateModelConnections(workspaceId); + await warnIfWorkspaceChatDisabled(workspaceId); + }, onError: (error: Error) => toast.error(error.message || "Failed to update models"), }; }); @@ -208,6 +233,9 @@ export const updateModelRolesMutationAtom = atomWithMutation((get) => { queryClient.invalidateQueries({ queryKey: cacheKeys.modelConnections.roles(workspaceId), }); + queryClient.invalidateQueries({ + queryKey: cacheKeys.modelConnections.setupStatus(workspaceId), + }); }, onError: (error: Error) => toast.error(error.message || "Failed to update model roles"), }; diff --git a/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts b/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts index 36cc6ae4e..fc9876aa0 100644 --- a/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts +++ b/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts @@ -1,3 +1,4 @@ +import { atomFamily } from "jotai-family"; import { atomWithQuery } from "jotai-tanstack-query"; import { modelConnectionsApiService } from "@/lib/apis/model-connections-api.service"; import { isAuthenticated } from "@/lib/auth-utils"; @@ -44,3 +45,18 @@ export const modelRolesAtom = atomWithQuery((get) => { queryFn: () => modelConnectionsApiService.getModelRoles(workspaceId), }; }); + +// Keyed by the route workspaceId (not activeWorkspaceIdAtom) so the onboarding +// gate's verdict can never be computed against a stale workspace during a +// workspace switch. +export const llmSetupStatusAtomFamily = atomFamily((workspaceId: number) => + atomWithQuery(() => ({ + queryKey: cacheKeys.modelConnections.setupStatus(workspaceId), + enabled: workspaceId > 0 && isAuthenticated(), + staleTime: 5 * 60 * 1000, + // Recovery is event-driven: mutations invalidate this key; external fixes + // are caught on window focus. No polling, so not-ready tabs cost nothing. + refetchOnWindowFocus: true, + queryFn: () => modelConnectionsApiService.getLlmSetupStatus(workspaceId), + })) +); diff --git a/surfsense_web/components/announcements/AnnouncementSpotlight.tsx b/surfsense_web/components/announcements/AnnouncementSpotlight.tsx index 794a3c1cf..2eac913d8 100644 --- a/surfsense_web/components/announcements/AnnouncementSpotlight.tsx +++ b/surfsense_web/components/announcements/AnnouncementSpotlight.tsx @@ -20,14 +20,10 @@ import { useAnnouncements } from "@/hooks/use-announcements"; * Behaviour: * - On load, the first active, audience-matched, unread spotlight announcement * is shown automatically. - * - The user must explicitly acknowledge it ("Got it" or the CTA link), which - * marks it as read so it never shows again. - * - Closing via the X / Escape / outside-click only hides it for the current - * session; it reappears on the next load until the user marks it as seen. + * - Any dismissal marks it as read so it never shows again. */ export function AnnouncementSpotlight() { const { announcements, markRead } = useAnnouncements(); - const [sessionDismissed, setSessionDismissed] = useState>(() => new Set()); const [ready, setReady] = useState(false); // Short delay so the spotlight doesn't flash during initial hydration/layout. @@ -37,11 +33,8 @@ export function AnnouncementSpotlight() { }, []); const current = useMemo( - () => - announcements.find( - (a) => a.spotlight && a.isImportant && !a.isRead && !sessionDismissed.has(a.id) - ) ?? null, - [announcements, sessionDismissed] + () => announcements.find((a) => a.spotlight && a.isImportant && !a.isRead) ?? null, + [announcements] ); if (!current) return null; @@ -51,13 +44,7 @@ export function AnnouncementSpotlight() { }; const handleOpenChange = (next: boolean) => { - if (!next) { - setSessionDismissed((prev) => { - const updated = new Set(prev); - updated.add(current.id); - return updated; - }); - } + if (!next) markRead(current.id); }; return ( @@ -82,7 +69,7 @@ export function AnnouncementSpotlight() { {current.link && ( - + ) : null} +
+ ); +}; + const Composer: FC = () => { const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const setSubmittedMentions = useSetAtom(submittedMentionsAtom); @@ -487,6 +529,9 @@ const Composer: FC = () => { const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); const [connectToolsTrayVisible, setConnectToolsTrayVisible] = useState(false); + const { data: chatSetupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId ?? 0)); + const isChatUnavailable = !!chatSetupStatus && chatSetupStatus.status !== "ready"; + const currentPlaceholder = COMPOSER_PLACEHOLDER; const { data: currentUser } = useAtomValue(currentUserAtom); @@ -934,10 +979,17 @@ const Composer: FC = () => { ) : null}
+ {isChatUnavailable ? ( + + ) : null}
@@ -1071,9 +1123,7 @@ const ComposerAction: FC = ({ if (url) setPendingScreenImages((prev) => [...prev, url]); }, [electronAPI, setPendingScreenImages]); - const { data: globalModelConnections } = useAtomValue(globalModelConnectionsAtom); - const { data: modelConnections } = useAtomValue(modelConnectionsAtom); - const { data: modelRoles } = useAtomValue(modelRolesAtom); + const { data: setupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId)); const { data: agentTools } = useAtomValue(agentToolsAtom); const disabledTools = useAtomValue(disabledToolsAtom); @@ -1154,21 +1204,13 @@ const ComposerAction: FC = ({ hydrateDisabled(); }, [hydrateDisabled]); - const hasModelConfigured = useMemo(() => { - const chatModelId = modelRoles?.chat_model_id ?? 0; - if (chatModelId === 0) { - return [...(globalModelConnections ?? []), ...(modelConnections ?? [])].some((connection) => - connection.models.some((model) => model.enabled && Boolean(model.supports_chat)) - ); - } - return [...(globalModelConnections ?? []), ...(modelConnections ?? [])].some((connection) => - connection.models.some( - (model) => model.id === chatModelId && model.enabled && Boolean(model.supports_chat) - ) - ); - }, [modelRoles?.chat_model_id, globalModelConnections, modelConnections]); + // A workspace with no usable chat model renders the composer with an inline + // notice (see ChatUnavailableNotice) rather than being redirected away, so + // the send button must stay disabled here. The backend also rejects any + // send that lacks a resolvable model, making this defense-in-depth. + const isWorkspaceChatReady = setupStatus?.status === "ready"; - const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser; + const isSendDisabled = isComposerEmpty || !isWorkspaceChatReady || isBlockedByOtherUser; return (
@@ -1194,7 +1236,7 @@ const ComposerAction: FC = ({ setConnectorDialogOpen(true)}> - Manage External MCP Connectors + MCP Connectors setToolsPopoverOpen(true)}> @@ -1414,7 +1456,7 @@ const ComposerAction: FC = ({ setConnectorDialogOpen(true)}> - Manage External MCP Connectors + MCP Connectors = ({ )}
- {!hasModelConfigured && ( -
- - Select a model -
- )}
= ({ tooltip={ isBlockedByOtherUser ? "Wait for AI to finish responding" - : !hasModelConfigured - ? "Please select a model to start chatting" - : isComposerEmpty - ? "Enter a message or add a screenshot to send" - : "Send message" + : isComposerEmpty + ? "Enter a message or add a screenshot to send" + : "Send message" } side="bottom" type="submit" diff --git a/surfsense_web/components/documents/DocumentNode.tsx b/surfsense_web/components/documents/DocumentNode.tsx index 256fb4147..423d7562f 100644 --- a/surfsense_web/components/documents/DocumentNode.tsx +++ b/surfsense_web/components/documents/DocumentNode.tsx @@ -37,6 +37,7 @@ import { import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; +import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { SidebarListItem } from "../layout/ui/sidebar/SidebarListItem"; import { DND_TYPES } from "./FolderNode"; @@ -92,6 +93,7 @@ export const DocumentNode = React.memo(function DocumentNode({ const isMemoryDocument = doc.document_type === "USER_MEMORY" || doc.document_type === "TEAM_MEMORY"; const isSelectable = canMention && !isUnavailable; + const isMobile = useIsMobile(); const handleCheckChange = useCallback(() => { if (isSelectable) { @@ -164,9 +166,9 @@ export const DocumentNode = React.memo(function DocumentNode({ - {isMemoryDocument ? ( - - - - ) : canMention ? ( + + + {getDocumentTypeIcon( + doc.document_type as DocumentTypeEnum, + "h-3.5 w-3.5 text-muted-foreground" + )} + + {canMention ? ( e.stopPropagation()} - className="h-3.5 w-3.5 shrink-0" - /> - ) : ( - - {getDocumentTypeIcon( - doc.document_type as DocumentTypeEnum, - "h-3.5 w-3.5 text-muted-foreground" + className={cn( + "absolute h-3.5 w-3.5 transition-opacity max-sm:pointer-events-auto max-sm:opacity-100", + isMentioned + ? "opacity-100" + : "pointer-events-none opacity-0 group-hover/item:pointer-events-auto group-hover/item:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100" )} - - )} - + /> + ) : null} + ); })()} @@ -260,36 +266,30 @@ export const DocumentNode = React.memo(function DocumentNode({ - - {getDocumentTypeIcon( - doc.document_type as DocumentTypeEnum, - "h-3.5 w-3.5 text-muted-foreground" - ) && ( - - {getDocumentTypeIcon( - doc.document_type as DocumentTypeEnum, - "h-3.5 w-3.5 text-muted-foreground" - )} - +
@@ -341,7 +341,7 @@ export const DocumentNode = React.memo(function DocumentNode({ )} - +
diff --git a/surfsense_web/components/documents/FolderNode.tsx b/surfsense_web/components/documents/FolderNode.tsx index 1cf74f549..bfebf29cd 100644 --- a/surfsense_web/components/documents/FolderNode.tsx +++ b/surfsense_web/components/documents/FolderNode.tsx @@ -34,6 +34,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import type { FolderSelectionState } from "./FolderTreeView"; @@ -124,6 +125,7 @@ export const FolderNode = React.memo(function FolderNode({ onStopWatching, onExportFolder, }: FolderNodeProps) { + const isMobile = useIsMobile(); const [renameValue, setRenameValue] = useState(folder.name); const inputRef = useRef(null); const rowRef = useRef(null); @@ -261,8 +263,9 @@ export const FolderNode = React.memo(function FolderNode({ tabIndex={0} dragging={isDragging} className={cn( - "relative gap-1 px-1", + "group/item relative gap-1 px-1", isExpanded && "font-medium", + dropdownOpen && "bg-accent text-accent-foreground", isOver && canDrop && dropZone === "middle" && "bg-accent ring-1 ring-primary/40", isOver && canDrop && dropZone === "top" && "border-t-2 border-primary", isOver && canDrop && dropZone === "bottom" && "border-b-2 border-primary", @@ -291,11 +294,11 @@ export const FolderNode = React.memo(function FolderNode({ )} - {processingState !== "idle" && selectionState === "none" ? ( - <> + + {processingState !== "idle" && selectionState === "none" ? ( - + {processingState === "processing" ? ( ) : ( @@ -309,29 +312,37 @@ export const FolderNode = React.memo(function FolderNode({ : "Some files failed to process"} - e.stopPropagation()} - className="h-3.5 w-3.5 shrink-0 hidden group-hover:flex" - /> - - ) : ( - e.stopPropagation()} - className="h-3.5 w-3.5 shrink-0" - /> - )} - - + ) : ( + <> + + e.stopPropagation()} + className={cn( + "absolute h-3.5 w-3.5 transition-opacity max-sm:pointer-events-auto max-sm:opacity-100", + selectionState === "none" + ? "pointer-events-none opacity-0 group-hover/item:pointer-events-auto group-hover/item:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100" + : "opacity-100" + )} + /> + + )} + {isRenaming ? ( - - - - +
+ + + + + {isWatched && onRescan && ( { @@ -436,8 +459,9 @@ export const FolderNode = React.memo(function FolderNode({ Delete - - + + +
)} diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 81f0d974f..7fc862e49 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; -import { AlarmClock, AlertTriangle, Boxes, SquareTerminal } from "lucide-react"; +import { AlarmClock, AlertTriangle, Shapes, SquareTerminal } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -57,10 +57,15 @@ import { LayoutShell } from "../ui/shell"; interface LayoutDataProviderProps { workspaceId: string; + initialPlaygroundSidebarCollapsed: boolean; children: React.ReactNode; } -export function LayoutDataProvider({ workspaceId, children }: LayoutDataProviderProps) { +export function LayoutDataProvider({ + workspaceId, + initialPlaygroundSidebarCollapsed, + children, +}: LayoutDataProviderProps) { const t = useTranslations("dashboard"); const tCommon = useTranslations("common"); const tSidebar = useTranslations("sidebar"); @@ -307,7 +312,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider { title: "Artifacts", url: `/dashboard/${workspaceId}/artifacts`, - icon: Boxes, + icon: Shapes, isActive: isArtifactsActive, }, { @@ -724,6 +729,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider onTabSwitch={handleTabSwitch} onTabPrefetch={handleTabPrefetch} playgroundSidebar={} + initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed} > {children} diff --git a/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx b/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx index cabe4879a..75192e5e2 100644 --- a/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx +++ b/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx @@ -28,6 +28,8 @@ import { import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { trackWorkspaceCreated } from "@/lib/posthog/events"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { queryClient } from "@/lib/query-client/client"; const formSchema = z.object({ name: z.string().min(1, "Name is required"), @@ -67,7 +69,22 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia trackWorkspaceCreated(result.id, values.name); - router.push(`/dashboard/${result.id}/new-chat`); + // Seed the gate's query so it resolves without a loader flash, and + // route straight to onboarding vs. new-chat on the first hop. + if (result.llm_setup) { + queryClient.setQueryData( + cacheKeys.modelConnections.setupStatus(result.id), + result.llm_setup + ); + } + // A fresh workspace can never be recovery, so this matches the gate, + // which is the authoritative net regardless. + const isInitialSetup = result.llm_setup?.stage === "initial_setup"; + router.push( + isInitialSetup + ? `/dashboard/${result.id}/onboard` + : `/dashboard/${result.id}/new-chat` + ); } catch (error) { console.error("Failed to create workspace:", error); setIsSubmitting(false); diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index c9bbe8efc..7a42d5a2e 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -41,6 +41,9 @@ const DocumentTabContent = dynamic( } ); +const PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE = "surfsense_playground_sidebar_collapsed"; +const PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; + function MacDesktopTitleBar({ isSidebarCollapsed, onToggleSidebar, @@ -113,6 +116,7 @@ interface LayoutShellProps { onTabSwitch?: (tab: Tab) => void; onTabPrefetch?: (tab: Tab) => void; playgroundSidebar?: React.ReactNode; + initialPlaygroundSidebarCollapsed?: boolean; } function MainContentPanel({ @@ -214,12 +218,15 @@ export function LayoutShell({ onTabSwitch, onTabPrefetch, playgroundSidebar, + initialPlaygroundSidebarCollapsed = false, }: LayoutShellProps) { const isMobile = useIsMobile(); const electronAPI = useElectronAPI(); const isMacDesktop = electronAPI?.versions.platform === "darwin"; const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const [isPlaygroundSidebarCollapsed, setIsPlaygroundSidebarCollapsed] = useState(false); + const [isPlaygroundSidebarCollapsed, setIsPlaygroundSidebarCollapsed] = useState( + initialPlaygroundSidebarCollapsed + ); const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed); const { sidebarWidth, @@ -233,7 +240,12 @@ export function LayoutShell({ [isCollapsed, setIsCollapsed, toggleCollapsed] ); const handlePlaygroundSidebarToggle = () => { - setIsPlaygroundSidebarCollapsed((collapsed) => !collapsed); + setIsPlaygroundSidebarCollapsed((collapsed) => { + const nextCollapsed = !collapsed; + const secureAttribute = window.location.protocol === "https:" ? "; Secure" : ""; + document.cookie = `${PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE}=${nextCollapsed}; Path=/; Max-Age=${PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE}; SameSite=Lax${secureAttribute}`; + return nextCollapsed; + }); }; // Mobile layout diff --git a/surfsense_web/components/mcp/agent-setup-tabs.tsx b/surfsense_web/components/mcp/agent-setup-tabs.tsx index 3790441f2..a9a5a8e94 100644 --- a/surfsense_web/components/mcp/agent-setup-tabs.tsx +++ b/surfsense_web/components/mcp/agent-setup-tabs.tsx @@ -30,18 +30,17 @@ function CopyButton({ text }: { text: string }) { ); } const TRANSPORTS: { id: McpTransport; label: string; hint: string }[] = [ - { id: "remote", label: "Hosted", hint: "mcp.surfsense.com — nothing to install" }, + { id: "remote", label: "Hosted", hint: "mcp.surfsense.com, nothing to install" }, { id: "stdio", label: "Self-host", hint: "run the server against your own backend" }, ]; @@ -64,7 +63,7 @@ export function AgentSetupTabs({ options }: { options?: Partial t.id === transport) ?? TRANSPORTS[0]; return ( -
+
{TRANSPORTS.map((t) => ( @@ -83,19 +82,21 @@ export function AgentSetupTabs({ options }: { options?: Partial{active.hint}
- - - {MCP_CLIENTS.map((client) => ( - - {client.label} - - ))} - + +
+ + {MCP_CLIENTS.map((client) => ( + + {client.label} + + ))} + +
{MCP_CLIENTS.map((client) => { const snippet = client[transport]; const config = snippet.build(resolved); return ( - +
    {snippet.steps.map((step) => (
  1. {step}
  2. diff --git a/surfsense_web/components/mcp/connect-agent-dialog.tsx b/surfsense_web/components/mcp/connect-agent-dialog.tsx index fdbf08d71..e46f602ec 100644 --- a/surfsense_web/components/mcp/connect-agent-dialog.tsx +++ b/surfsense_web/components/mcp/connect-agent-dialog.tsx @@ -41,13 +41,11 @@ export function ConnectAgentDialog({ className }: { className?: string }) { New - + Connect to Claude Code, Codex, OpenCode… - The SurfSense MCP server gives any coding agent these scrapers and your knowledge base - as native tools. You need an API key (create one under API Keys) — then pick your agent - and paste its config. + Give your coding agent access to SurfSense scrapers and your knowledge base. Create an API key under API Keys, choose your agent, then paste the config. diff --git a/surfsense_web/components/ui/context-menu.tsx b/surfsense_web/components/ui/context-menu.tsx index 8fa7c6d1a..c7644ecb2 100644 --- a/surfsense_web/components/ui/context-menu.tsx +++ b/surfsense_web/components/ui/context-menu.tsx @@ -66,7 +66,7 @@ function ContextMenuSubContent({ ; export type ModelsBulkUpdateRequest = z.infer; export type ModelRoles = z.infer; export type GlobalLlmConfigStatus = z.infer; +export type LlmSetupStatus = z.infer; export type VerifyConnectionResponse = z.infer; export type ModelProviderRead = z.infer; diff --git a/surfsense_web/contracts/types/workspace.types.ts b/surfsense_web/contracts/types/workspace.types.ts index 9b4541b1e..a777f810a 100644 --- a/surfsense_web/contracts/types/workspace.types.ts +++ b/surfsense_web/contracts/types/workspace.types.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { paginationQueryParams } from "."; +import { llmSetupStatus } from "./model-connections.types"; export const workspace = z.object({ id: z.number(), @@ -36,7 +37,9 @@ export const createWorkspaceRequest = workspace.pick({ name: true, description: qna_custom_instructions: z.string().nullable().optional(), }); -export const createWorkspaceResponse = workspace.omit({ member_count: true, is_owner: true }); +export const createWorkspaceResponse = workspace + .omit({ member_count: true, is_owner: true }) + .extend({ llm_setup: llmSetupStatus.nullable().optional() }); /** * Get workspace diff --git a/surfsense_web/lib/apis/model-connections-api.service.ts b/surfsense_web/lib/apis/model-connections-api.service.ts index e9468f2f3..56fe806ec 100644 --- a/surfsense_web/lib/apis/model-connections-api.service.ts +++ b/surfsense_web/lib/apis/model-connections-api.service.ts @@ -8,6 +8,8 @@ import { connectionUpdateRequest, type GlobalLlmConfigStatus, globalLlmConfigStatus, + type LlmSetupStatus, + llmSetupStatus, type ModelCreateRequest, type ModelPreviewRead, type ModelProviderRead, @@ -40,6 +42,13 @@ class ModelConnectionsApiService { return baseApiService.get(`/api/v1/global-llm-config-status`, globalLlmConfigStatus); }; + getLlmSetupStatus = async (workspaceId: number): Promise => { + return baseApiService.get( + `/api/v1/workspaces/${workspaceId}/llm-setup-status`, + llmSetupStatus + ); + }; + getModelProviders = async (): Promise => { return baseApiService.get(`/api/v1/model-providers`, modelProviderListResponse); }; diff --git a/surfsense_web/lib/onboarding.ts b/surfsense_web/lib/onboarding.ts deleted file mode 100644 index 6ffb75662..000000000 --- a/surfsense_web/lib/onboarding.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ConnectionRead } from "@/contracts/types/model-connections.types"; - -export function hasEnabledChatModel(connections: ConnectionRead[]): boolean { - return connections.some( - (connection) => - connection.enabled && - connection.models.some((model) => model.enabled && Boolean(model.supports_chat)) - ); -} - -export function isLlmOnboardingComplete( - chatModelId: number | null | undefined, - globalConnections: ConnectionRead[], - workspaceConnections: ConnectionRead[] -): boolean { - const connections = [...globalConnections, ...workspaceConnections]; - const resolvedChatModelId = chatModelId ?? 0; - - if (resolvedChatModelId === 0) { - return hasEnabledChatModel(connections); - } - - return connections.some((connection) => - connection.models.some( - (model) => model.id === resolvedChatModelId && model.enabled && Boolean(model.supports_chat) - ) - ); -} diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index c3825c144..13c141f69 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -42,6 +42,7 @@ export const cacheKeys = { globalConfigStatus: () => ["model-connections", "global-config-status"] as const, providers: () => ["model-connections", "providers"] as const, roles: (workspaceId: number) => ["model-roles", workspaceId] as const, + setupStatus: (workspaceId: number) => ["llm-setup-status", workspaceId] as const, }, auth: { user: ["auth", "user"] as const, diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 84a33a6b1..50b92b1f7 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -205,7 +205,7 @@ "manage_documents": "Manage Documents", "connectors": "Connectors", "add_connector": "Add Connector", - "manage_connectors": "Manage External MCP Connectors", + "manage_connectors": "MCP Connectors", "logs": "Logs", "all_workspaces": "All Workspaces", "team": "Team" @@ -587,7 +587,7 @@ "add_provider_subtitle": "Configure your first model provider to get started", "add_provider_button": "Add Provider", "add_new_llm_provider": "Add New LLM Provider", - "configure_new_provider": "Configure a new language model provider for your AI assistant", + "configure_new_provider": "Configure a new chat model provider for your AI assistant", "config_name": "Configuration Name", "config_name_required": "Configuration Name *", "config_name_placeholder": "e.g., My OpenAI GPT-4", diff --git a/surfsense_web/package.json b/surfsense_web/package.json index 989dde480..21894d224 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -111,6 +111,7 @@ "fuzzy-search": "^3.2.1", "geist": "^1.4.2", "jotai": "^2.15.1", + "jotai-family": "^1.0.2", "jotai-tanstack-query": "^0.11.0", "katex": "^0.16.28", "lenis": "^1.3.17", diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml index 4284d944d..d93ad580d 100644 --- a/surfsense_web/pnpm-lock.yaml +++ b/surfsense_web/pnpm-lock.yaml @@ -254,6 +254,9 @@ importers: jotai: specifier: ^2.15.1 version: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4) + jotai-family: + specifier: ^1.0.2 + version: 1.0.2(jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4)) jotai-tanstack-query: specifier: ^0.11.0 version: 0.11.0(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) @@ -7032,6 +7035,12 @@ packages: jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jotai-family@1.0.2: + resolution: {integrity: sha512-U1aTMGxmsmz2Z8gaJD1/ljmMnsmG4/dqrcsfwGbjWV7p6boB9Vy3+75YwUpwPj7GbLNJk9O8rl1VNi8d7+6Rxw==} + engines: {node: '>=12.20.0'} + peerDependencies: + jotai: '>=2.9.0' + jotai-optics@0.4.0: resolution: {integrity: sha512-osbEt9AgS55hC4YTZDew2urXKZkaiLmLqkTS/wfW5/l0ib8bmmQ7kBXSFaosV6jDDWSp00IipITcJARFHdp42g==} peerDependencies: @@ -16336,6 +16345,10 @@ snapshots: jose@5.10.0: {} + jotai-family@1.0.2(jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4)): + dependencies: + jotai: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4) + jotai-optics@0.4.0(jotai@2.8.4(@types/react@19.2.14)(react@19.2.4))(optics-ts@2.4.1): dependencies: jotai: 2.8.4(@types/react@19.2.14)(react@19.2.4) diff --git a/surfsense_web/tests/connectors/clickup/journey.spec.ts b/surfsense_web/tests/connectors/clickup/journey.spec.ts index e24d69321..aaec1abd0 100644 --- a/surfsense_web/tests/connectors/clickup/journey.spec.ts +++ b/surfsense_web/tests/connectors/clickup/journey.spec.ts @@ -44,7 +44,7 @@ test.describe("ClickUp connector journey", () => { waitUntil: "domcontentloaded", }); await openConnectorPopup(page); - const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" }); + const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" }); await expect(connectorDialog).toBeVisible(); await expect(connectorDialog.getByText("ClickUp")).toBeVisible(); diff --git a/surfsense_web/tests/connectors/composio/calendar/journey.spec.ts b/surfsense_web/tests/connectors/composio/calendar/journey.spec.ts index a24fca929..e56f59318 100644 --- a/surfsense_web/tests/connectors/composio/calendar/journey.spec.ts +++ b/surfsense_web/tests/connectors/composio/calendar/journey.spec.ts @@ -27,7 +27,7 @@ test.describe("Composio Calendar journey", () => { waitUntil: "domcontentloaded", }); await openConnectorPopup(page); - const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" }); + const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" }); await expect(connectorDialog).toBeVisible(); const beforeChatDocs = await listDocuments(request, apiToken, workspace.id); diff --git a/surfsense_web/tests/connectors/composio/gmail/journey.spec.ts b/surfsense_web/tests/connectors/composio/gmail/journey.spec.ts index bbe0c8b12..02a473015 100644 --- a/surfsense_web/tests/connectors/composio/gmail/journey.spec.ts +++ b/surfsense_web/tests/connectors/composio/gmail/journey.spec.ts @@ -27,7 +27,7 @@ test.describe("Composio Gmail journey", () => { waitUntil: "domcontentloaded", }); await openConnectorPopup(page); - const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" }); + const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" }); await expect(connectorDialog).toBeVisible(); const beforeChatDocs = await listDocuments(request, apiToken, workspace.id); diff --git a/surfsense_web/tests/connectors/confluence/journey.spec.ts b/surfsense_web/tests/connectors/confluence/journey.spec.ts index 20b48fcd6..9c51ff140 100644 --- a/surfsense_web/tests/connectors/confluence/journey.spec.ts +++ b/surfsense_web/tests/connectors/confluence/journey.spec.ts @@ -48,7 +48,7 @@ test.describe("Confluence connector journey", () => { waitUntil: "domcontentloaded", }); await openConnectorPopup(page); - const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" }); + const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" }); await expect(connectorDialog).toBeVisible(); await connectorDialog.getByPlaceholder("Search").fill("Confluence"); await expect(connectorDialog.getByText("Confluence", { exact: true })).toBeVisible(); diff --git a/surfsense_web/tests/connectors/google/calendar/journey.spec.ts b/surfsense_web/tests/connectors/google/calendar/journey.spec.ts index 6874761f9..e332d8dd1 100644 --- a/surfsense_web/tests/connectors/google/calendar/journey.spec.ts +++ b/surfsense_web/tests/connectors/google/calendar/journey.spec.ts @@ -32,7 +32,7 @@ test.describe("Native Google Calendar journey", () => { waitUntil: "domcontentloaded", }); await openConnectorPopup(page); - const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" }); + const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" }); await expect(connectorDialog).toBeVisible(); const beforeDocs = await listDocuments(request, apiToken, workspace.id); diff --git a/surfsense_web/tests/connectors/google/gmail/journey.spec.ts b/surfsense_web/tests/connectors/google/gmail/journey.spec.ts index 67781f7f2..6343ab3f9 100644 --- a/surfsense_web/tests/connectors/google/gmail/journey.spec.ts +++ b/surfsense_web/tests/connectors/google/gmail/journey.spec.ts @@ -32,7 +32,7 @@ test.describe("Native Google Gmail journey", () => { waitUntil: "domcontentloaded", }); await openConnectorPopup(page); - const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" }); + const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" }); await expect(connectorDialog).toBeVisible(); const beforeDocs = await listDocuments(request, apiToken, workspace.id); diff --git a/surfsense_web/tests/connectors/jira/journey.spec.ts b/surfsense_web/tests/connectors/jira/journey.spec.ts index b6b561eac..d0dc2eb89 100644 --- a/surfsense_web/tests/connectors/jira/journey.spec.ts +++ b/surfsense_web/tests/connectors/jira/journey.spec.ts @@ -42,7 +42,7 @@ test.describe("Jira connector journey", () => { waitUntil: "domcontentloaded", }); await openConnectorPopup(page); - const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" }); + const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" }); await expect(connectorDialog).toBeVisible(); await connectorDialog.getByPlaceholder("Search").fill("Jira"); await expect(connectorDialog.getByText("Jira", { exact: true })).toBeVisible(); diff --git a/surfsense_web/tests/connectors/linear/journey.spec.ts b/surfsense_web/tests/connectors/linear/journey.spec.ts index db1696999..50d8d1fcb 100644 --- a/surfsense_web/tests/connectors/linear/journey.spec.ts +++ b/surfsense_web/tests/connectors/linear/journey.spec.ts @@ -42,7 +42,7 @@ test.describe("Linear connector journey", () => { waitUntil: "domcontentloaded", }); await openConnectorPopup(page); - const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" }); + const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" }); await expect(connectorDialog).toBeVisible(); const beforeDocs = await listDocuments(request, apiToken, workspace.id); diff --git a/surfsense_web/tests/connectors/notion/journey.spec.ts b/surfsense_web/tests/connectors/notion/journey.spec.ts index 720fe4424..dc66650cc 100644 --- a/surfsense_web/tests/connectors/notion/journey.spec.ts +++ b/surfsense_web/tests/connectors/notion/journey.spec.ts @@ -43,7 +43,7 @@ test.describe("Notion connector journey", () => { waitUntil: "domcontentloaded", }); await openConnectorPopup(page); - const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" }); + const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" }); await expect(connectorDialog).toBeVisible(); await connectorDialog.getByPlaceholder("Search").fill("Notion"); await expect(connectorDialog.getByText("Notion", { exact: true })).toBeVisible(); diff --git a/surfsense_web/tests/connectors/slack/journey.spec.ts b/surfsense_web/tests/connectors/slack/journey.spec.ts index 65283107d..876d16b9a 100644 --- a/surfsense_web/tests/connectors/slack/journey.spec.ts +++ b/surfsense_web/tests/connectors/slack/journey.spec.ts @@ -44,7 +44,7 @@ test.describe("Slack connector journey", () => { waitUntil: "domcontentloaded", }); await openConnectorPopup(page); - const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" }); + const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" }); await expect(connectorDialog).toBeVisible(); await connectorDialog.getByPlaceholder("Search").fill("Slack"); await expect(connectorDialog.getByText("Slack", { exact: true })).toBeVisible(); diff --git a/surfsense_web/tests/helpers/ui/connector-popup.ts b/surfsense_web/tests/helpers/ui/connector-popup.ts index 5cf8ba5d3..a506bdff6 100644 --- a/surfsense_web/tests/helpers/ui/connector-popup.ts +++ b/surfsense_web/tests/helpers/ui/connector-popup.ts @@ -21,7 +21,7 @@ export async function openConnectorPopup(page: Page): Promise { await expect(trigger).toBeVisible({ timeout: 60_000 }); await trigger.click(); - await expect(page.getByRole("dialog", { name: "Manage External MCP Connectors" })).toBeVisible(); + await expect(page.getByRole("dialog", { name: "MCP Connectors" })).toBeVisible(); } /**