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 && (
-