From 1c2c480b92fff7a543c96a933be64f80fed540ae Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:29:19 +0530 Subject: [PATCH 01/35] feat(schemas): add LlmSetupStatusRead for server-authoritative onboarding --- surfsense_backend/app/schemas/__init__.py | 4 +++- .../app/schemas/model_connections.py | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) 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..f190ad745 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,16 @@ 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. + """ + + status: Literal["ready", "needs_setup"] + source: Literal["global_config", "models", "none"] + can_configure: bool From ff676519ea6c17e4a050b29266b57d2b53f5c029 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:29:26 +0530 Subject: [PATCH 02/35] feat(model-connections): derive workspace LLM setup status server-side --- .../app/routes/model_connections_routes.py | 113 +++++++++++++++++- 1 file changed, 110 insertions(+), 3 deletions(-) diff --git a/surfsense_backend/app/routes/model_connections_routes.py b/surfsense_backend/app/routes/model_connections_routes.py index 8f6a17cfd..afb45864f 100644 --- a/surfsense_backend/app/routes/model_connections_routes.py +++ b/surfsense_backend/app/routes/model_connections_routes.py @@ -16,11 +16,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 +45,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 +357,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 +762,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 +833,108 @@ 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: + return LlmSetupStatusRead( + status="ready", source="global_config", can_configure=can_configure + ) + + # 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) + + 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" + return LlmSetupStatusRead( + status="ready", source=source, can_configure=can_configure + ) + + if global_usable: + return LlmSetupStatusRead( + status="ready", source="global_config", can_configure=can_configure + ) + if await _workspace_has_enabled_chat_model(session, workspace_id): + return LlmSetupStatusRead( + status="ready", source="models", can_configure=can_configure + ) + + return LlmSetupStatusRead( + status="needs_setup", source="none", can_configure=can_configure + ) + + +@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) From 561bc76a9a331bea31cbd30cc2defd688178b73e Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:29:31 +0530 Subject: [PATCH 03/35] feat(workspaces): return llm_setup on workspace create --- surfsense_backend/app/routes/workspaces_routes.py | 8 +++++++- surfsense_backend/app/schemas/workspace.py | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) 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/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) From 6a5b62de654e9243c4272157976b7323bedb3427 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:29:42 +0530 Subject: [PATCH 04/35] test(model-connections): cover compute_llm_setup_status matrix --- .../unit/routes/test_llm_setup_status.py | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 surfsense_backend/tests/unit/routes/test_llm_setup_status.py 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..da8375cf4 --- /dev/null +++ b/surfsense_backend/tests/unit/routes/test_llm_setup_status.py @@ -0,0 +1,231 @@ +"""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 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 + + +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, +): + """Drive the decision tree with DB-touching seams stubbed out.""" + if permissions is None: + permissions = [Permission.FULL_ACCESS.value] + 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=_FakeWorkspace(chat_model_id=chat_model_id)), + ) + ) + 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" + + @pytest.mark.asyncio + async def test_usable_global_catalog_is_ready(self): + result = await _run_status( + file_exists=True, global_usable=True, chat_model_id=0 + ) + assert result.status == "ready" + assert result.source == "global_config" + + @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" + + @pytest.mark.asyncio + async def test_auto_mode_with_workspace_model_is_ready(self): + result = await _run_status( + file_exists=False, global_usable=False, chat_model_id=0, ws_has_chat=True + ) + assert result.status == "ready" + assert result.source == "models" + + @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. + result = await _run_status( + file_exists=False, global_usable=False, chat_model_id=5 + ) + assert result.status == "ready" + assert result.source == "models" + + @pytest.mark.asyncio + async def test_pinned_global_model_is_ready(self): + result = await _run_status( + file_exists=False, global_usable=False, chat_model_id=-3 + ) + assert result.status == "ready" + assert result.source == "global_config" + + @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 From be626dff057de9cd63d0b4274d176cbfccb65414 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:29:47 +0530 Subject: [PATCH 05/35] feat(contracts): add llmSetupStatus schema and llm_setup on create response --- surfsense_web/contracts/types/model-connections.types.ts | 7 +++++++ surfsense_web/contracts/types/workspace.types.ts | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/surfsense_web/contracts/types/model-connections.types.ts b/surfsense_web/contracts/types/model-connections.types.ts index 74c31b66e..7771d2b93 100644 --- a/surfsense_web/contracts/types/model-connections.types.ts +++ b/surfsense_web/contracts/types/model-connections.types.ts @@ -111,6 +111,12 @@ export const globalLlmConfigStatus = z.object({ exists: z.boolean(), }); +export const llmSetupStatus = z.object({ + status: z.enum(["ready", "needs_setup"]), + source: z.enum(["global_config", "models", "none"]), + can_configure: z.boolean(), +}); + export const modelProviderRead = z.object({ provider: z.string(), transport: z.string(), @@ -140,5 +146,6 @@ export type ModelUpdateRequest = z.infer; 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 From ad28bd912ca691b2b3642350d45bd1a7c11e1a88 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:29:53 +0530 Subject: [PATCH 06/35] feat(model-connections): add setupStatus cache key and api fetcher --- surfsense_web/lib/apis/model-connections-api.service.ts | 9 +++++++++ surfsense_web/lib/query-client/cache-keys.ts | 1 + 2 files changed, 10 insertions(+) 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/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, From 5efd4291bfe54c7815f052be953ebcd6195be9e8 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:29:59 +0530 Subject: [PATCH 07/35] feat(model-connections): add llmSetupStatusAtomFamily and invalidate on mutations --- .../model-connections-mutation.atoms.ts | 6 ++++++ .../model-connections-query.atoms.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+) 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..c30d65587 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,9 @@ function invalidateModelConnections(workspaceId: number) { queryClient.invalidateQueries({ queryKey: cacheKeys.modelConnections.roles(workspaceId), }); + queryClient.invalidateQueries({ + queryKey: cacheKeys.modelConnections.setupStatus(workspaceId), + }); } function upsertModelConnection(workspaceId: number, connection: ConnectionRead) { @@ -208,6 +211,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..f73fc3f18 100644 --- a/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts +++ b/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts @@ -1,4 +1,5 @@ import { atomWithQuery } from "jotai-tanstack-query"; +import { atomFamily } from "jotai/utils"; import { modelConnectionsApiService } from "@/lib/apis/model-connections-api.service"; import { isAuthenticated } from "@/lib/auth-utils"; import { cacheKeys } from "@/lib/query-client/cache-keys"; @@ -44,3 +45,15 @@ 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, + queryFn: () => modelConnectionsApiService.getLlmSetupStatus(workspaceId), + })) +); From dc582a77166ef6fe1344d1d68b8ef12e7458ea73 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:30:06 +0530 Subject: [PATCH 08/35] refactor(dashboard): replace onboarding redirect with declarative setup gate --- .../[workspace_id]/client-layout.tsx | 144 +++++++----------- 1 file changed, 53 insertions(+), 91 deletions(-) diff --git a/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx b/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx index 1f4948548..6bc0a352b 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,7 +16,6 @@ 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, @@ -39,85 +32,39 @@ 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 needsSetup = setupStatus?.status === "needs_setup"; + const isReady = setupStatus?.status === "ready"; + const canConfigure = setupStatus?.can_configure ?? false; + + // Redirect into onboarding (configurable members) or out of it (workspace + // became ready). Both directions live here; the pages themselves are dumb. 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 (needsSetup && canConfigure && !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, + needsSetup, + canConfigure, + isReady, isOnboardingPage, - isOwner, router, workspaceId, - hasCheckedOnboarding, ]); const electronAPI = useElectronAPI(); @@ -167,18 +114,16 @@ export function DashboardClientLayout({ } }, [workspace_id, setActiveWorkspaceIdState, electronAPI]); - // Determine if we should show loading - const shouldShowLoading = - !hasCheckedOnboarding && - (!isWorkspaceReady || - loading || - accessLoading || - globalConfigsLoading || - globalConfigStatusLoading || - modelConnectionsLoading) && - !isOnboardingPage; + // Currently navigating between onboarding and the dashboard. + const isRedirecting = + !setupLoading && + !setupError && + ((needsSetup && canConfigure && !isOnboardingPage) || (isReady && isOnboardingPage)); + + // Block children until the workspace is synced and the setup verdict is in, + // so an unconfigured workspace never flashes the composer. + 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 +133,7 @@ export function DashboardClientLayout({ return null; } - if (error && !hasCheckedOnboarding && !isOnboardingPage) { + if (setupError) { return (
@@ -200,7 +145,7 @@ export function DashboardClientLayout({

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

@@ -208,6 +153,23 @@ export function DashboardClientLayout({ ); } + // Member without permission to connect a model in an unconfigured workspace. + if (needsSetup && !canConfigure) { + return ( +
+ + + No model available + + A workspace admin needs to connect a language model before chat is available + here. + + + +
+ ); + } + if (isOnboardingPage) { return <>{children}; } From 13a7491cdd423e9d46391859dae1b0c82699e952 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:30:11 +0530 Subject: [PATCH 09/35] refactor(onboard): drive page from server setup status --- .../dashboard/[workspace_id]/onboard/page.tsx | 50 +++---------------- 1 file changed, 8 insertions(+), 42 deletions(-) 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={ From b010468a96232032ea393c1f5f62d3665067e443 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:30:14 +0530 Subject: [PATCH 10/35] refactor(thread): gate composer on setup status and drop select-a-model hint --- .../components/assistant-ui/thread.tsx | 44 +++++-------------- 1 file changed, 11 insertions(+), 33 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 0cf6ef296..0cee0d078 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -47,11 +47,7 @@ import { import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms"; -import { - globalModelConnectionsAtom, - modelConnectionsAtom, - modelRolesAtom, -} from "@/atoms/model-connections/model-connections-query.atoms"; +import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { AssistantMessage } from "@/components/assistant-ui/assistant-message"; import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status"; @@ -1074,9 +1070,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); @@ -1157,21 +1151,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]); + // Defense-in-depth only: the onboarding gate makes an unconfigured + // workspace unreachable, but a stale cache (e.g. another admin removed the + // last model) can briefly leave this composer mounted. Block sends silently + // until the status refetch re-engages the gate. + const isWorkspaceChatReady = setupStatus?.status === "ready"; - const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser; + const isSendDisabled = isComposerEmpty || !isWorkspaceChatReady || isBlockedByOtherUser; return (
@@ -1613,12 +1599,6 @@ const ComposerAction: FC = ({ )}
- {!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" From 6f34bdf6b57b1a5ef6e7114787d7db91c8168ed8 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:30:20 +0530 Subject: [PATCH 11/35] feat(workspace-dialog): route new workspace from llm_setup and seed query cache --- .../layout/ui/dialogs/CreateWorkspaceDialog.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx b/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx index cabe4879a..c7cb00a1f 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,20 @@ 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 + ); + } + const needsSetup = result.llm_setup?.status === "needs_setup"; + router.push( + needsSetup + ? `/dashboard/${result.id}/onboard` + : `/dashboard/${result.id}/new-chat` + ); } catch (error) { console.error("Failed to create workspace:", error); setIsSubmitting(false); From aca243ba43f8159d622ce03754ef713ca165eec6 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:30:23 +0530 Subject: [PATCH 12/35] refactor(onboarding): remove client-side onboarding helper --- surfsense_web/lib/onboarding.ts | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 surfsense_web/lib/onboarding.ts 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) - ) - ); -} From 3cf94e0aa82082f72964ddf5ef7f38f93b1f0bdc Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:38:52 +0530 Subject: [PATCH 13/35] fix(layout): update icon for artifacts in LayoutDataProvider --- .../components/layout/providers/LayoutDataProvider.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index f9c2c9072..e60d04198 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"; @@ -306,7 +306,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider { title: "Artifacts", url: `/dashboard/${workspaceId}/artifacts`, - icon: Boxes, + icon: Shapes, isActive: isArtifactsActive, }, { From bd15ccdf7b65f73d4bb73cbb6c2c96ef008c4a2c Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:11:48 +0530 Subject: [PATCH 14/35] feat(database): add llm_setup_completed_at column to workspaces for onboarding tracking --- .../174_add_llm_setup_completed_at.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 surfsense_backend/alembic/versions/174_add_llm_setup_completed_at.py 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") From 007629443bd5d0efcbb2f9f0a0e93de8be0cfaeb Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:14:28 +0530 Subject: [PATCH 15/35] feat(db): add llm_setup_completed_at column to workspace --- surfsense_backend/app/db.py | 5 +++++ 1 file changed, 5 insertions(+) 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 ) From 88d3d336bfdb5212f9eb3dcb2d89ec996733c262 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:14:34 +0530 Subject: [PATCH 16/35] feat(schemas): add stage field to LlmSetupStatusRead --- surfsense_backend/app/schemas/model_connections.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/app/schemas/model_connections.py b/surfsense_backend/app/schemas/model_connections.py index f190ad745..9c61e9c38 100644 --- a/surfsense_backend/app/schemas/model_connections.py +++ b/surfsense_backend/app/schemas/model_connections.py @@ -153,9 +153,11 @@ class LlmSetupStatusRead(BaseModel): ``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. + 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"] From 2f048d87bc5cc84b75a3ad21f2b06022e20e5172 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:14:51 +0530 Subject: [PATCH 17/35] feat(model-connections): stamp own-model readiness and derive setup stage --- .../app/routes/model_connections_routes.py | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/surfsense_backend/app/routes/model_connections_routes.py b/surfsense_backend/app/routes/model_connections_routes.py index afb45864f..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 @@ -895,8 +896,12 @@ async def compute_llm_setup_status( 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 + 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 @@ -906,25 +911,40 @@ async def compute_llm_setup_status( 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 + status="ready", source=source, can_configure=can_configure, stage="ready" ) if global_usable: return LlmSetupStatusRead( - status="ready", source="global_config", can_configure=can_configure + 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 + 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 + status="needs_setup", source="none", can_configure=can_configure, stage=stage ) From 5ef944c5746e711c5b9745b7cd04a79bd0195b20 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:14:59 +0530 Subject: [PATCH 18/35] test(model-connections): cover recovery stage and setup stamping --- .../unit/routes/test_llm_setup_status.py | 99 +++++++++++++++++-- 1 file changed, 93 insertions(+), 6 deletions(-) diff --git a/surfsense_backend/tests/unit/routes/test_llm_setup_status.py b/surfsense_backend/tests/unit/routes/test_llm_setup_status.py index da8375cf4..2f07d8bd2 100644 --- a/surfsense_backend/tests/unit/routes/test_llm_setup_status.py +++ b/surfsense_backend/tests/unit/routes/test_llm_setup_status.py @@ -15,6 +15,7 @@ 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 @@ -34,6 +35,7 @@ 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( @@ -90,10 +92,20 @@ async def _run_status( 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.""" + """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) @@ -113,7 +125,7 @@ async def _run_status( patch.object( mc, "_clear_invalid_roles", - AsyncMock(return_value=_FakeWorkspace(chat_model_id=chat_model_id)), + AsyncMock(return_value=workspace), ) ) stack.enter_context( @@ -136,14 +148,19 @@ class TestComputeLlmSetupStatus: ) 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 + 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): @@ -154,14 +171,22 @@ class TestComputeLlmSetupStatus: ) 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 + 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): @@ -174,19 +199,26 @@ class TestComputeLlmSetupStatus: @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 + 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 + 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): @@ -229,3 +261,58 @@ class TestComputeLlmSetupStatus: 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 From 4b4efc243aa6230d179c7445815319d3f170c4a2 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:15:06 +0530 Subject: [PATCH 19/35] feat(contracts): add stage to llmSetupStatus schema --- surfsense_web/contracts/types/model-connections.types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/surfsense_web/contracts/types/model-connections.types.ts b/surfsense_web/contracts/types/model-connections.types.ts index 7771d2b93..778ade901 100644 --- a/surfsense_web/contracts/types/model-connections.types.ts +++ b/surfsense_web/contracts/types/model-connections.types.ts @@ -115,6 +115,7 @@ export const llmSetupStatus = z.object({ status: z.enum(["ready", "needs_setup"]), source: z.enum(["global_config", "models", "none"]), can_configure: z.boolean(), + stage: z.enum(["initial_setup", "recovery", "ready"]), }); export const modelProviderRead = z.object({ From ad91a3bfa516ea25840fc415dafa3d83c0aa3ab2 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:15:10 +0530 Subject: [PATCH 20/35] feat(model-connections): refetch setup status on window focus --- .../atoms/model-connections/model-connections-query.atoms.ts | 3 +++ 1 file changed, 3 insertions(+) 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 f73fc3f18..b6c57af9e 100644 --- a/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts +++ b/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts @@ -54,6 +54,9 @@ export const llmSetupStatusAtomFamily = atomFamily((workspaceId: number) => 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), })) ); From 3dd41670ad1fac431a946a606c1579ecfe022ece Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:15:21 +0530 Subject: [PATCH 21/35] feat(model-connections): warn when a mutation disables workspace chat --- .../model-connections-mutation.atoms.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) 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 c30d65587..77fccc1d1 100644 --- a/surfsense_web/atoms/model-connections/model-connections-mutation.atoms.ts +++ b/surfsense_web/atoms/model-connections/model-connections-mutation.atoms.ts @@ -30,6 +30,24 @@ function invalidateModelConnections(workspaceId: number) { }); } +// 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 language model to start chatting again."); + } + } catch { + // Non-fatal: the inline composer notice still reflects the state. + } +} + function upsertModelConnection(workspaceId: number, connection: ConnectionRead) { queryClient.setQueryData( cacheKeys.modelConnections.all(workspaceId), @@ -81,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"), }; @@ -182,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"), }; }); From e9e727b5df5e856f10bde8db06c595479107048c Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:15:29 +0530 Subject: [PATCH 22/35] refactor(dashboard): redirect only on initial_setup and drop blocked screen --- .../[workspace_id]/client-layout.tsx | 45 +++++++------------ 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx b/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx index 6bc0a352b..221586674 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx @@ -43,15 +43,15 @@ export function DashboardClientLayout({ const isOnboardingPage = pathname?.includes("/onboard"); const isWorkspaceReady = activeWorkspaceId === workspaceId; - const needsSetup = setupStatus?.status === "needs_setup"; const isReady = setupStatus?.status === "ready"; - const canConfigure = setupStatus?.can_configure ?? false; - // Redirect into onboarding (configurable members) or out of it (workspace - // became ready). Both directions live here; the pages themselves are dumb. + // 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 (setupLoading || setupError) return; - if (needsSetup && canConfigure && !isOnboardingPage) { + if (setupStatus?.stage === "initial_setup" && !isOnboardingPage) { router.replace(`/dashboard/${workspaceId}/onboard`); } else if (isReady && isOnboardingPage) { router.replace(`/dashboard/${workspaceId}/new-chat`); @@ -59,8 +59,7 @@ export function DashboardClientLayout({ }, [ setupLoading, setupError, - needsSetup, - canConfigure, + setupStatus?.stage, isReady, isOnboardingPage, router, @@ -114,14 +113,17 @@ export function DashboardClientLayout({ } }, [workspace_id, setActiveWorkspaceIdState, electronAPI]); - // Currently navigating between onboarding and the dashboard. + // 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 && - ((needsSetup && canConfigure && !isOnboardingPage) || (isReady && isOnboardingPage)); + !setupLoading && !setupError && (isLeavingOnboarding || isEnteringOnboarding); - // Block children until the workspace is synced and the setup verdict is in, - // so an unconfigured workspace never flashes the composer. + // 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); useGlobalLoadingEffect(shouldShowLoading); @@ -153,23 +155,6 @@ export function DashboardClientLayout({ ); } - // Member without permission to connect a model in an unconfigured workspace. - if (needsSetup && !canConfigure) { - return ( -
- - - No model available - - A workspace admin needs to connect a language model before chat is available - here. - - - -
- ); - } - if (isOnboardingPage) { return <>{children}; } From 3c3ec76d776616cda56f7470f62996f84333b3e0 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:15:38 +0530 Subject: [PATCH 23/35] feat(thread): add inline chat-unavailable notice to composer --- .../components/assistant-ui/thread.tsx | 68 +++++++++++++++++-- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 0cee0d078..9e03c2577 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -24,7 +24,7 @@ import { } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import Image from "next/image"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { agentToolsAtom, @@ -444,6 +444,52 @@ const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDi ); }; +/** + * 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 + * chat model (fresh install, or an admin deleted the last model) renders the + * composer in place with this notice instead of navigating away. Configurable + * members get a CTA to connect one; everyone else is told to ask an admin. The + * send button stays disabled independently, and the backend rejects any send + * that slips through, so this is purely guidance. + * + * Presentational: visibility is decided by the parent so the composer can flatten + * its top shadow for a seamless join. + */ +const ChatUnavailableNotice: FC<{ workspaceId: number; canConfigure: boolean }> = ({ + workspaceId, + canConfigure, +}) => { + const router = useRouter(); + + return ( +
+
+ + + {canConfigure + ? "Connect a language model to start chatting." + : "No model available. Ask a workspace admin to connect a language model."} + +
+
+ {canConfigure ? ( + + ) : null} +
+ ); +}; + const Composer: FC = () => { const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const setSubmittedMentions = useSetAtom(submittedMentionsAtom); @@ -483,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); @@ -930,10 +979,17 @@ const Composer: FC = () => { ) : null}
+ {isChatUnavailable ? ( + + ) : null}
@@ -1151,10 +1207,10 @@ const ComposerAction: FC = ({ hydrateDisabled(); }, [hydrateDisabled]); - // Defense-in-depth only: the onboarding gate makes an unconfigured - // workspace unreachable, but a stale cache (e.g. another admin removed the - // last model) can briefly leave this composer mounted. Block sends silently - // until the status refetch re-engages the gate. + // 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 || !isWorkspaceChatReady || isBlockedByOtherUser; From c0df67574043b738534abcb3e4903117d487a35d Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:15:48 +0530 Subject: [PATCH 24/35] feat(workspace-dialog): route new workspace on initial_setup stage --- .../components/layout/ui/dialogs/CreateWorkspaceDialog.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx b/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx index c7cb00a1f..75192e5e2 100644 --- a/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx +++ b/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx @@ -77,9 +77,11 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia result.llm_setup ); } - const needsSetup = result.llm_setup?.status === "needs_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( - needsSetup + isInitialSetup ? `/dashboard/${result.id}/onboard` : `/dashboard/${result.id}/new-chat` ); From 9364f92c63550a70e3f983a10d78441fbe0614cb Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:15:56 +0530 Subject: [PATCH 25/35] refactor(connector-card): drop deprecated badge branch --- .../components/connector-card.tsx | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx index 80930f632..195ca98ea 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx @@ -113,19 +113,12 @@ export const ConnectorCard: FC = ({
{title} - {deprecated ? ( - - Deprecated - - ) : ( - showWarnings && - status.status !== "active" && ( - - ) + {showWarnings && status.status !== "active" && ( + )}
{isIndexing ? ( From 527ce2d97b1227826e9fe40c3702bb43d257f927 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:02:22 +0530 Subject: [PATCH 26/35] refactor(announcement-spotlight): simplify dismissal logic and update button style --- .../announcements/AnnouncementSpotlight.tsx | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) 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 && ( -
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 84a33a6b1..15a75cc6f 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -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", From faffd9974cac2e7e1cbef1da363f820babc4479a Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:17:24 +0530 Subject: [PATCH 28/35] refactor(connector-popup): simplify terminology by renaming "Manage External MCP Connectors" to "MCP Connectors" across components and tests --- surfsense_web/components/assistant-ui/connector-popup.tsx | 2 +- .../connector-popup/components/connector-dialog-header.tsx | 4 ++-- surfsense_web/components/assistant-ui/thread.tsx | 4 ++-- surfsense_web/messages/en.json | 2 +- surfsense_web/tests/connectors/clickup/journey.spec.ts | 2 +- .../tests/connectors/composio/calendar/journey.spec.ts | 2 +- surfsense_web/tests/connectors/composio/gmail/journey.spec.ts | 2 +- surfsense_web/tests/connectors/confluence/journey.spec.ts | 2 +- .../tests/connectors/google/calendar/journey.spec.ts | 2 +- surfsense_web/tests/connectors/google/gmail/journey.spec.ts | 2 +- surfsense_web/tests/connectors/jira/journey.spec.ts | 2 +- surfsense_web/tests/connectors/linear/journey.spec.ts | 2 +- surfsense_web/tests/connectors/notion/journey.spec.ts | 2 +- surfsense_web/tests/connectors/slack/journey.spec.ts | 2 +- surfsense_web/tests/helpers/ui/connector-popup.ts | 2 +- 15 files changed, 17 insertions(+), 17 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index b1a02b953..a8bfc29bd 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -189,7 +189,7 @@ export const ConnectorIndicator = forwardRef - Manage External MCP Connectors + MCP Connectors {/* YouTube Crawler View - shown when adding YouTube videos */} {isYouTubeView && workspaceId ? ( diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-dialog-header.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-dialog-header.tsx index 435685b0f..61d1ef844 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-dialog-header.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-dialog-header.tsx @@ -31,10 +31,10 @@ export const ConnectorDialogHeader: FC = ({ > - Manage External MCP Connectors + MCP Connectors - Connect Surfsense to your favorite tools and services. + Connect external tools and services through MCP. diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index d3210cd87..066df6e5f 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -1239,7 +1239,7 @@ const ComposerAction: FC = ({ setConnectorDialogOpen(true)}> - Manage External MCP Connectors + MCP Connectors setToolsPopoverOpen(true)}> @@ -1459,7 +1459,7 @@ const ComposerAction: FC = ({ setConnectorDialogOpen(true)}> - Manage External MCP Connectors + MCP Connectors { 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(); } /** From 44138668a4ce7c452d864334e6ef45b30eccdba6 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:58:39 +0530 Subject: [PATCH 29/35] refactor(thread): update layout structure and improve styling for welcome message --- .../connector-popup/components/connector-card.tsx | 13 +++++++++++-- surfsense_web/components/assistant-ui/thread.tsx | 10 +++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx index 195ca98ea..d8e4b174c 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx @@ -79,7 +79,7 @@ export const ConnectorCard: FC = ({ } if (isDeprecatedForConnect) { - return "Deprecated — no longer available to connect."; + return "Deprecated. No longer available to connect."; } return description; @@ -102,7 +102,16 @@ export const ConnectorCard: FC = ({ : "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5" )} > - {connectorType ? ( + {isMCP ? ( +