Merge pull request #1600 from AnishSarkar22/fix/onboarding

fix(onboarding): make LLM setup server-authoritative with recovery-aware gate
This commit is contained in:
Rohan Verma 2026-07-14 21:38:07 -07:00 committed by GitHub
commit d5b5c9cda2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 954 additions and 386 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
@ -200,7 +149,7 @@ export function DashboardClientLayout({
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{error instanceof Error ? error.message : String(error)}
{setupError instanceof Error ? setupError.message : String(setupError)}
</p>
</CardContent>
</Card>
@ -215,7 +164,10 @@ export function DashboardClientLayout({
return (
<DocumentUploadDialogProvider>
<OnboardingTour />
<LayoutDataProvider workspaceId={workspaceId}>
<LayoutDataProvider
workspaceId={workspaceId}
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
>
{children}
<ConnectorIndicator showTrigger={false} />
</LayoutDataProvider>

View file

@ -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 <DashboardClientLayout workspaceId={workspace_id}>{children}</DashboardClientLayout>;
return (
<DashboardClientLayout
workspaceId={workspace_id}
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
>
{children}
</DashboardClientLayout>
);
}

View file

@ -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 (
<div className="flex min-h-screen select-none flex-col items-center justify-center bg-main-panel p-4">
@ -80,8 +46,8 @@ export default function OnboardPage() {
footerAction={
<Button
className="min-w-[112px]"
disabled={!onboardingComplete || !hasUsableChatModel}
onClick={() => router.push(`/dashboard/${workspaceId}/new-chat`)}
disabled={!isReady}
onClick={() => router.replace(`/dashboard/${workspaceId}/new-chat`)}
>
Start
</Button>

View file

@ -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"),
};

View file

@ -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),
}))
);

View file

@ -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<Set<string>>(() => 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() {
</DialogDescription>
<DialogFooter className="mt-2">
{current.link && (
<Button variant="outline" asChild className="gap-1.5" onClick={handleAcknowledge}>
<Button variant="secondary" asChild className="gap-1.5" onClick={handleAcknowledge}>
<Link
href={current.link.url}
target={current.link.url.startsWith("http") ? "_blank" : undefined}

View file

@ -189,7 +189,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
}}
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden ring-0 dark:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-accent [&>button]:hover:text-accent-foreground [&>button>svg]:size-5 select-none"
>
<DialogTitle className="sr-only">Manage External MCP Connectors</DialogTitle>
<DialogTitle className="sr-only">MCP Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && workspaceId ? (
<YouTubeCrawlerView workspaceId={workspaceId} onBack={handleBackFromYouTube} />

View file

@ -79,7 +79,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
}
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<ConnectorCardProps> = ({
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
)}
>
{connectorType ? (
{isMCP ? (
<span
aria-hidden="true"
className="size-6 bg-current"
style={{
mask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
WebkitMask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
}}
/>
) : connectorType ? (
getConnectorIcon(connectorType, "size-6")
) : id === "youtube-crawler" ? (
<IconBrandYoutube className="size-6" />
@ -113,19 +122,12 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-[14px] font-semibold leading-tight truncate">{title}</span>
{deprecated ? (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide text-muted-foreground">
Deprecated
</span>
) : (
showWarnings &&
status.status !== "active" && (
<ConnectorStatusBadge
status={status.status}
statusMessage={statusMessage}
className="flex-shrink-0"
/>
)
{showWarnings && status.status !== "active" && (
<ConnectorStatusBadge
status={status.status}
statusMessage={statusMessage}
className="flex-shrink-0"
/>
)}
</div>
{isIndexing ? (

View file

@ -31,10 +31,10 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
>
<DialogHeader>
<DialogTitle className="text-xl sm:text-3xl font-semibold tracking-tight">
Manage External MCP Connectors
MCP Connectors
</DialogTitle>
<DialogDescription className="text-xs sm:text-base text-muted-foreground/80 mt-1 sm:mt-1.5">
Connect Surfsense to your favorite tools and services.
Connect external tools and services through MCP.
</DialogDescription>
</DialogHeader>

View file

@ -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,
@ -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";
@ -262,17 +258,17 @@ const ThreadWelcome: FC = () => {
const greeting = useMemo(() => getTimeBasedGreeting(user), [user]);
return (
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
<div className="my-auto flex w-full flex-col items-center gap-6 py-6 sm:contents sm:my-0 sm:gap-0 sm:py-0">
<div className="aui-thread-welcome-message flex flex-col items-center text-center sm:absolute sm:bottom-[calc(50%+5rem)] sm:left-0 sm:right-0">
<div className="aui-thread-welcome-root flex min-h-0 flex-1">
<section className="mx-auto grid w-full max-w-(--thread-max-width) content-center gap-6 pt-8 pb-[clamp(5rem,16vh,12rem)]">
<div className="aui-thread-welcome-message flex flex-col items-center px-4 text-center">
<h1 className="aui-thread-welcome-message-inner text-3xl md:text-[2.625rem] select-none">
{greeting}
</h1>
</div>
<div className="w-full flex items-start justify-center sm:absolute sm:top-[calc(50%-3.5rem)] sm:left-0 sm:right-0">
<div className="flex w-full items-start justify-center">
<Composer />
</div>
</div>
</section>
</div>
);
};
@ -448,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 (
<div className="relative z-0 -mb-5 flex min-w-0 items-center gap-2 rounded-t-3xl bg-popover px-4 pt-2 pb-6 shadow-sm shadow-black/5 dark:shadow-black/10">
<div className="flex min-w-0 items-center gap-2 text-[13px] font-normal text-muted-foreground select-none">
<AlertCircle className="size-4 shrink-0" />
<span className="truncate">
{canConfigure
? "Connect a chat model to start chatting."
: "No model available. Ask a workspace admin to connect a chat model."}
</span>
</div>
<div className="min-w-0 flex-1" />
{canConfigure ? (
<Button
type="button"
size="sm"
className="h-6 shrink-0 cursor-pointer gap-2 rounded-md px-2.5 text-xs font-medium select-none"
onClick={() => router.push(`/dashboard/${workspaceId}/workspace-settings/models`)}
>
<span className="sm:hidden">Connect</span>
<span className="hidden sm:inline">Connect a model</span>
</Button>
) : null}
</div>
);
};
const Composer: FC = () => {
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const setSubmittedMentions = useSetAtom(submittedMentionsAtom);
@ -487,6 +529,9 @@ const Composer: FC = () => {
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
const [connectToolsTrayVisible, setConnectToolsTrayVisible] = useState(false);
const { data: chatSetupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId ?? 0));
const isChatUnavailable = !!chatSetupStatus && chatSetupStatus.status !== "ready";
const currentPlaceholder = COMPOSER_PLACEHOLDER;
const { data: currentUser } = useAtomValue(currentUserAtom);
@ -934,10 +979,17 @@ const Composer: FC = () => {
) : null}
</Popover>
<div className="relative flex w-full flex-col">
{isChatUnavailable ? (
<ChatUnavailableNotice
workspaceId={workspaceId ?? 0}
canConfigure={chatSetupStatus?.can_configure ?? false}
/>
) : null}
<div
className={cn(
"aui-composer-attachment-dropzone relative z-10 flex w-full flex-col overflow-hidden rounded-3xl border border-input/20 bg-muted pt-2 shadow-sm shadow-black/5 outline-none transition-[border-color,box-shadow] hover:border-input/60 focus-within:border-input/60 dark:shadow-black/10",
connectToolsTrayVisible && "rounded-b-3xl shadow-none dark:shadow-none"
connectToolsTrayVisible && "rounded-b-3xl shadow-none dark:shadow-none",
isChatUnavailable && "shadow-none dark:shadow-none"
)}
>
<PendingScreenImageStrip />
@ -1071,9 +1123,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
if (url) setPendingScreenImages((prev) => [...prev, url]);
}, [electronAPI, setPendingScreenImages]);
const { data: globalModelConnections } = useAtomValue(globalModelConnectionsAtom);
const { data: modelConnections } = useAtomValue(modelConnectionsAtom);
const { data: modelRoles } = useAtomValue(modelRolesAtom);
const { data: setupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId));
const { data: agentTools } = useAtomValue(agentToolsAtom);
const disabledTools = useAtomValue(disabledToolsAtom);
@ -1154,21 +1204,13 @@ const ComposerAction: FC<ComposerActionProps> = ({
hydrateDisabled();
}, [hydrateDisabled]);
const hasModelConfigured = useMemo(() => {
const chatModelId = modelRoles?.chat_model_id ?? 0;
if (chatModelId === 0) {
return [...(globalModelConnections ?? []), ...(modelConnections ?? [])].some((connection) =>
connection.models.some((model) => model.enabled && Boolean(model.supports_chat))
);
}
return [...(globalModelConnections ?? []), ...(modelConnections ?? [])].some((connection) =>
connection.models.some(
(model) => model.id === chatModelId && model.enabled && Boolean(model.supports_chat)
)
);
}, [modelRoles?.chat_model_id, globalModelConnections, modelConnections]);
// A workspace with no usable chat model renders the composer with an inline
// notice (see ChatUnavailableNotice) rather than being redirected away, so
// the send button must stay disabled here. The backend also rejects any
// send that lacks a resolvable model, making this defense-in-depth.
const isWorkspaceChatReady = setupStatus?.status === "ready";
const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
const isSendDisabled = isComposerEmpty || !isWorkspaceChatReady || isBlockedByOtherUser;
return (
<div className="aui-composer-action-wrapper relative mx-3 mb-3 flex items-center justify-between">
@ -1194,7 +1236,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setConnectorDialogOpen(true)}>
<Unplug className="size-4" />
Manage External MCP Connectors
MCP Connectors
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setToolsPopoverOpen(true)}>
<Settings2 className="size-4" />
@ -1414,7 +1456,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setConnectorDialogOpen(true)}>
<Unplug className="h-4 w-4" />
Manage External MCP Connectors
MCP Connectors
</DropdownMenuItem>
<DropdownMenuSub
open={toolsPopoverOpen}
@ -1610,12 +1652,6 @@ const ComposerAction: FC<ComposerActionProps> = ({
)}
<ConnectedScraperIcons workspaceId={workspaceId} />
</div>
{!hasModelConfigured && (
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
<AlertCircle className="size-3" />
<span>Select a model</span>
</div>
)}
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2">
<ChatHeader
workspaceId={workspaceId}
@ -1628,11 +1664,9 @@ const ComposerAction: FC<ComposerActionProps> = ({
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"

View file

@ -37,6 +37,7 @@ import {
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { SidebarListItem } from "../layout/ui/sidebar/SidebarListItem";
import { DND_TYPES } from "./FolderNode";
@ -92,6 +93,7 @@ export const DocumentNode = React.memo(function DocumentNode({
const isMemoryDocument =
doc.document_type === "USER_MEMORY" || doc.document_type === "TEAM_MEMORY";
const isSelectable = canMention && !isUnavailable;
const isMobile = useIsMobile();
const handleCheckChange = useCallback(() => {
if (isSelectable) {
@ -164,9 +166,9 @@ export const DocumentNode = React.memo(function DocumentNode({
<ContextMenuTrigger asChild>
<SidebarListItem
ref={attachRef}
active={isMentioned}
active={isMentioned || dropdownOpen}
dragging={isDragging}
className="gap-2.5 px-1"
className="group/item relative gap-2.5 px-1"
style={{ paddingLeft: `${depth * 16 + 4}px` }}
role="button"
tabIndex={isUnavailable ? -1 : 0}
@ -214,32 +216,36 @@ export const DocumentNode = React.memo(function DocumentNode({
);
}
return (
<>
{isMemoryDocument ? (
<span aria-disabled="true" className="h-3.5 w-3.5 shrink-0 cursor-default">
<Checkbox
checked={false}
disabled
aria-disabled
className="h-3.5 w-3.5 pointer-events-none"
/>
</span>
) : canMention ? (
<span className="relative flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<span
className={cn(
"absolute inset-0 flex items-center justify-center transition-opacity",
canMention &&
(isMentioned
? "opacity-0"
: "max-sm:opacity-0 group-hover/item:opacity-0")
)}
>
{getDocumentTypeIcon(
doc.document_type as DocumentTypeEnum,
"h-3.5 w-3.5 text-muted-foreground"
)}
</span>
{canMention ? (
<Checkbox
checked={isMentioned}
aria-label={`Select ${doc.title}`}
onCheckedChange={handleCheckChange}
onClick={(e) => e.stopPropagation()}
className="h-3.5 w-3.5 shrink-0"
/>
) : (
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
{getDocumentTypeIcon(
doc.document_type as DocumentTypeEnum,
"h-3.5 w-3.5 text-muted-foreground"
className={cn(
"absolute h-3.5 w-3.5 transition-opacity max-sm:pointer-events-auto max-sm:opacity-100",
isMentioned
? "opacity-100"
: "pointer-events-none opacity-0 group-hover/item:pointer-events-auto group-hover/item:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100"
)}
</span>
)}
</>
/>
) : null}
</span>
);
})()}
@ -260,36 +266,30 @@ export const DocumentNode = React.memo(function DocumentNode({
</TooltipContent>
</Tooltip>
<span className="relative shrink-0 flex items-center justify-center h-6 w-6">
{getDocumentTypeIcon(
doc.document_type as DocumentTypeEnum,
"h-3.5 w-3.5 text-muted-foreground"
) && (
<span
className={cn(
"absolute inset-0 flex items-center justify-center transition-opacity pointer-events-none",
dropdownOpen ? "opacity-0" : "group-hover:opacity-0"
)}
>
{getDocumentTypeIcon(
doc.document_type as DocumentTypeEnum,
"h-3.5 w-3.5 text-muted-foreground"
)}
</span>
<div
className={cn(
"pointer-events-none absolute top-0 right-0 bottom-0 flex items-center rounded-r-md pr-1 pl-6",
isMentioned || dropdownOpen
? "bg-gradient-to-l from-accent from-60% to-transparent"
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
isMobile
? "opacity-0"
: isMentioned || dropdownOpen
? "opacity-100"
: "opacity-0 group-hover/item:opacity-100"
)}
>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"hidden sm:inline-flex h-6 w-6 shrink-0 hover:bg-transparent",
dropdownOpen
? "opacity-100 bg-accent hover:bg-accent"
: "opacity-0 group-hover:opacity-100"
"pointer-events-auto hidden h-6 w-6 shrink-0 hover:bg-transparent sm:inline-flex",
dropdownOpen && "bg-accent hover:bg-accent"
)}
onClick={(e) => e.stopPropagation()}
aria-label={`Document actions for ${doc.title}`}
>
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
@ -341,7 +341,7 @@ export const DocumentNode = React.memo(function DocumentNode({
)}
</DropdownMenuContent>
</DropdownMenu>
</span>
</div>
</SidebarListItem>
</ContextMenuTrigger>

View file

@ -34,6 +34,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import type { FolderSelectionState } from "./FolderTreeView";
@ -124,6 +125,7 @@ export const FolderNode = React.memo(function FolderNode({
onStopWatching,
onExportFolder,
}: FolderNodeProps) {
const isMobile = useIsMobile();
const [renameValue, setRenameValue] = useState(folder.name);
const inputRef = useRef<HTMLInputElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
@ -261,8 +263,9 @@ export const FolderNode = React.memo(function FolderNode({
tabIndex={0}
dragging={isDragging}
className={cn(
"relative gap-1 px-1",
"group/item relative gap-1 px-1",
isExpanded && "font-medium",
dropdownOpen && "bg-accent text-accent-foreground",
isOver && canDrop && dropZone === "middle" && "bg-accent ring-1 ring-primary/40",
isOver && canDrop && dropZone === "top" && "border-t-2 border-primary",
isOver && canDrop && dropZone === "bottom" && "border-b-2 border-primary",
@ -291,11 +294,11 @@ export const FolderNode = React.memo(function FolderNode({
)}
</span>
{processingState !== "idle" && selectionState === "none" ? (
<>
<span className="relative flex h-4 w-4 shrink-0 items-center justify-center">
{processingState !== "idle" && selectionState === "none" ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center group-hover:hidden">
<span className="flex h-3.5 w-3.5 items-center justify-center">
{processingState === "processing" ? (
<Spinner size="xs" className="text-primary" />
) : (
@ -309,29 +312,37 @@ export const FolderNode = React.memo(function FolderNode({
: "Some files failed to process"}
</TooltipContent>
</Tooltip>
<Checkbox
checked={false}
onCheckedChange={handleCheckChange}
onClick={(e) => e.stopPropagation()}
className="h-3.5 w-3.5 shrink-0 hidden group-hover:flex"
/>
</>
) : (
<Checkbox
checked={
selectionState === "all"
? true
: selectionState === "some"
? "indeterminate"
: false
}
onCheckedChange={handleCheckChange}
onClick={(e) => e.stopPropagation()}
className="h-3.5 w-3.5 shrink-0"
/>
)}
<FolderIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<>
<FolderIcon
className={cn(
"absolute h-4 w-4 text-muted-foreground transition-opacity",
selectionState === "none"
? "max-sm:opacity-0 group-hover/item:opacity-0"
: "opacity-0"
)}
/>
<Checkbox
checked={
selectionState === "all"
? true
: selectionState === "some"
? "indeterminate"
: false
}
aria-label={`Select ${folder.name}`}
onCheckedChange={handleCheckChange}
onClick={(e) => e.stopPropagation()}
className={cn(
"absolute h-3.5 w-3.5 transition-opacity max-sm:pointer-events-auto max-sm:opacity-100",
selectionState === "none"
? "pointer-events-none opacity-0 group-hover/item:pointer-events-auto group-hover/item:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100"
: "opacity-100"
)}
/>
</>
)}
</span>
{isRenaming ? (
<input
@ -350,23 +361,35 @@ export const FolderNode = React.memo(function FolderNode({
)}
{!isRenaming && (
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"hidden sm:inline-flex h-6 w-6 shrink-0 hover:bg-transparent transition-opacity",
dropdownOpen
? "opacity-100 bg-accent hover:bg-accent"
: "opacity-0 group-hover:opacity-100"
)}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<div
className={cn(
"pointer-events-none absolute top-0 right-0 bottom-0 flex items-center rounded-r-md pr-1 pl-6",
dropdownOpen
? "bg-gradient-to-l from-accent from-60% to-transparent"
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
isMobile
? "opacity-0"
: dropdownOpen
? "opacity-100"
: "opacity-0 group-hover/item:opacity-100"
)}
>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"pointer-events-auto hidden h-6 w-6 shrink-0 hover:bg-transparent transition-opacity sm:inline-flex",
dropdownOpen && "bg-accent hover:bg-accent"
)}
onClick={(e) => e.stopPropagation()}
aria-label={`Folder actions for ${folder.name}`}
>
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{isWatched && onRescan && (
<DropdownMenuItem
onClick={(e) => {
@ -436,8 +459,9 @@ export const FolderNode = React.memo(function FolderNode({
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</SidebarListItem>
</ContextMenuTrigger>

View file

@ -2,7 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { AlarmClock, AlertTriangle, Boxes, SquareTerminal } from "lucide-react";
import { AlarmClock, AlertTriangle, Shapes, SquareTerminal } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@ -57,10 +57,15 @@ import { LayoutShell } from "../ui/shell";
interface LayoutDataProviderProps {
workspaceId: string;
initialPlaygroundSidebarCollapsed: boolean;
children: React.ReactNode;
}
export function LayoutDataProvider({ workspaceId, children }: LayoutDataProviderProps) {
export function LayoutDataProvider({
workspaceId,
initialPlaygroundSidebarCollapsed,
children,
}: LayoutDataProviderProps) {
const t = useTranslations("dashboard");
const tCommon = useTranslations("common");
const tSidebar = useTranslations("sidebar");
@ -307,7 +312,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
{
title: "Artifacts",
url: `/dashboard/${workspaceId}/artifacts`,
icon: Boxes,
icon: Shapes,
isActive: isArtifactsActive,
},
{
@ -724,6 +729,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
onTabSwitch={handleTabSwitch}
onTabPrefetch={handleTabPrefetch}
playgroundSidebar={<PlaygroundSidebar workspaceId={workspaceId} />}
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
>
<Fragment key={chatResetKey}>{children}</Fragment>
</LayoutShell>

View file

@ -28,6 +28,8 @@ import {
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { trackWorkspaceCreated } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
@ -67,7 +69,22 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia
trackWorkspaceCreated(result.id, values.name);
router.push(`/dashboard/${result.id}/new-chat`);
// Seed the gate's query so it resolves without a loader flash, and
// route straight to onboarding vs. new-chat on the first hop.
if (result.llm_setup) {
queryClient.setQueryData(
cacheKeys.modelConnections.setupStatus(result.id),
result.llm_setup
);
}
// A fresh workspace can never be recovery, so this matches the gate,
// which is the authoritative net regardless.
const isInitialSetup = result.llm_setup?.stage === "initial_setup";
router.push(
isInitialSetup
? `/dashboard/${result.id}/onboard`
: `/dashboard/${result.id}/new-chat`
);
} catch (error) {
console.error("Failed to create workspace:", error);
setIsSubmitting(false);

View file

@ -41,6 +41,9 @@ const DocumentTabContent = dynamic(
}
);
const PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE = "surfsense_playground_sidebar_collapsed";
const PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
function MacDesktopTitleBar({
isSidebarCollapsed,
onToggleSidebar,
@ -113,6 +116,7 @@ interface LayoutShellProps {
onTabSwitch?: (tab: Tab) => void;
onTabPrefetch?: (tab: Tab) => void;
playgroundSidebar?: React.ReactNode;
initialPlaygroundSidebarCollapsed?: boolean;
}
function MainContentPanel({
@ -214,12 +218,15 @@ export function LayoutShell({
onTabSwitch,
onTabPrefetch,
playgroundSidebar,
initialPlaygroundSidebarCollapsed = false,
}: LayoutShellProps) {
const isMobile = useIsMobile();
const electronAPI = useElectronAPI();
const isMacDesktop = electronAPI?.versions.platform === "darwin";
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isPlaygroundSidebarCollapsed, setIsPlaygroundSidebarCollapsed] = useState(false);
const [isPlaygroundSidebarCollapsed, setIsPlaygroundSidebarCollapsed] = useState(
initialPlaygroundSidebarCollapsed
);
const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
const {
sidebarWidth,
@ -233,7 +240,12 @@ export function LayoutShell({
[isCollapsed, setIsCollapsed, toggleCollapsed]
);
const handlePlaygroundSidebarToggle = () => {
setIsPlaygroundSidebarCollapsed((collapsed) => !collapsed);
setIsPlaygroundSidebarCollapsed((collapsed) => {
const nextCollapsed = !collapsed;
const secureAttribute = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE}=${nextCollapsed}; Path=/; Max-Age=${PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE}; SameSite=Lax${secureAttribute}`;
return nextCollapsed;
});
};
// Mobile layout

View file

@ -30,18 +30,17 @@ function CopyButton({ text }: { text: string }) {
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 h-7 gap-1.5 px-2 text-xs"
className="absolute top-2 right-2 size-7 p-0"
onClick={handleCopy}
aria-label="Copy configuration"
aria-label={copied ? "Configuration copied" : "Copy configuration"}
>
{copied ? <Check className="size-3.5 text-brand" /> : <Copy className="size-3.5" />}
{copied ? "Copied" : "Copy"}
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</Button>
);
}
const TRANSPORTS: { id: McpTransport; label: string; hint: string }[] = [
{ id: "remote", label: "Hosted", hint: "mcp.surfsense.com nothing to install" },
{ id: "remote", label: "Hosted", hint: "mcp.surfsense.com, nothing to install" },
{ id: "stdio", label: "Self-host", hint: "run the server against your own backend" },
];
@ -64,7 +63,7 @@ export function AgentSetupTabs({ options }: { options?: Partial<McpSnippetOption
const active = TRANSPORTS.find((t) => t.id === transport) ?? TRANSPORTS[0];
return (
<div className="space-y-4">
<div className="min-w-0 space-y-4">
<div className="flex flex-wrap items-center gap-3">
<div className="inline-flex rounded-lg border bg-muted/40 p-0.5">
{TRANSPORTS.map((t) => (
@ -83,19 +82,21 @@ export function AgentSetupTabs({ options }: { options?: Partial<McpSnippetOption
<span className="text-xs text-muted-foreground">{active.hint}</span>
</div>
<Tabs defaultValue={MCP_CLIENTS[0].id} className="w-full">
<TabsList className="flex h-auto flex-wrap justify-start gap-1">
{MCP_CLIENTS.map((client) => (
<TabsTrigger key={client.id} value={client.id}>
{client.label}
</TabsTrigger>
))}
</TabsList>
<Tabs defaultValue={MCP_CLIENTS[0].id} className="min-w-0 w-full">
<div className="w-full max-w-full overflow-x-auto overscroll-x-contain">
<TabsList className="flex h-auto w-max min-w-full flex-nowrap justify-start gap-1">
{MCP_CLIENTS.map((client) => (
<TabsTrigger key={client.id} value={client.id}>
{client.label}
</TabsTrigger>
))}
</TabsList>
</div>
{MCP_CLIENTS.map((client) => {
const snippet = client[transport];
const config = snippet.build(resolved);
return (
<TabsContent key={client.id} value={client.id} className="space-y-3">
<TabsContent key={client.id} value={client.id} className="min-w-0 space-y-3">
<ol className="list-decimal space-y-1 pl-5 text-sm leading-relaxed text-muted-foreground">
{snippet.steps.map((step) => (
<li key={step}>{step}</li>

View file

@ -41,13 +41,11 @@ export function ConnectAgentDialog({ className }: { className?: string }) {
<SidebarButtonBadge>New</SidebarButtonBadge>
</span>
</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogContent className="max-h-[85vh] min-w-0 overflow-x-hidden overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Connect to Claude Code, Codex, OpenCode</DialogTitle>
<DialogDescription>
The SurfSense MCP server gives any coding agent these scrapers and your knowledge base
as native tools. You need an API key (create one under API Keys) then pick your agent
and paste its config.
Give your coding agent access to SurfSense scrapers and your knowledge base. Create an API key under API Keys, choose your agent, then paste the config.
</DialogDescription>
</DialogHeader>
<AgentSetupTabs options={{ baseUrl: BACKEND_URL || undefined }} />

View file

@ -66,7 +66,7 @@ function ContextMenuSubContent({
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md p-1 shadow-lg",
"bg-popover text-popover-foreground border border-popover-border data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md p-1 shadow-lg",
className
)}
{...props}
@ -83,7 +83,7 @@ function ContextMenuContent({
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md p-1 shadow-md",
"bg-popover text-popover-foreground border border-popover-border data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md p-1 shadow-md",
className
)}
{...props}

View file

@ -111,6 +111,13 @@ 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(),
stage: z.enum(["initial_setup", "recovery", "ready"]),
});
export const modelProviderRead = z.object({
provider: z.string(),
transport: z.string(),
@ -140,5 +147,6 @@ export type ModelUpdateRequest = z.infer<typeof modelUpdateRequest>;
export type ModelsBulkUpdateRequest = z.infer<typeof modelsBulkUpdateRequest>;
export type ModelRoles = z.infer<typeof modelRoles>;
export type GlobalLlmConfigStatus = z.infer<typeof globalLlmConfigStatus>;
export type LlmSetupStatus = z.infer<typeof llmSetupStatus>;
export type VerifyConnectionResponse = z.infer<typeof verifyConnectionResponse>;
export type ModelProviderRead = z.infer<typeof modelProviderRead>;

View file

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

View file

@ -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<LlmSetupStatus> => {
return baseApiService.get(
`/api/v1/workspaces/${workspaceId}/llm-setup-status`,
llmSetupStatus
);
};
getModelProviders = async (): Promise<ModelProviderRead[]> => {
return baseApiService.get(`/api/v1/model-providers`, modelProviderListResponse);
};

View file

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

View file

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

View file

@ -205,7 +205,7 @@
"manage_documents": "Manage Documents",
"connectors": "Connectors",
"add_connector": "Add Connector",
"manage_connectors": "Manage External MCP Connectors",
"manage_connectors": "MCP Connectors",
"logs": "Logs",
"all_workspaces": "All Workspaces",
"team": "Team"
@ -587,7 +587,7 @@
"add_provider_subtitle": "Configure your first model provider to get started",
"add_provider_button": "Add Provider",
"add_new_llm_provider": "Add New LLM Provider",
"configure_new_provider": "Configure a new language model provider for your AI assistant",
"configure_new_provider": "Configure a new chat model provider for your AI assistant",
"config_name": "Configuration Name",
"config_name_required": "Configuration Name *",
"config_name_placeholder": "e.g., My OpenAI GPT-4",

View file

@ -111,6 +111,7 @@
"fuzzy-search": "^3.2.1",
"geist": "^1.4.2",
"jotai": "^2.15.1",
"jotai-family": "^1.0.2",
"jotai-tanstack-query": "^0.11.0",
"katex": "^0.16.28",
"lenis": "^1.3.17",

View file

@ -254,6 +254,9 @@ importers:
jotai:
specifier: ^2.15.1
version: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4)
jotai-family:
specifier: ^1.0.2
version: 1.0.2(jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4))
jotai-tanstack-query:
specifier: ^0.11.0
version: 0.11.0(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
@ -7032,6 +7035,12 @@ packages:
jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
jotai-family@1.0.2:
resolution: {integrity: sha512-U1aTMGxmsmz2Z8gaJD1/ljmMnsmG4/dqrcsfwGbjWV7p6boB9Vy3+75YwUpwPj7GbLNJk9O8rl1VNi8d7+6Rxw==}
engines: {node: '>=12.20.0'}
peerDependencies:
jotai: '>=2.9.0'
jotai-optics@0.4.0:
resolution: {integrity: sha512-osbEt9AgS55hC4YTZDew2urXKZkaiLmLqkTS/wfW5/l0ib8bmmQ7kBXSFaosV6jDDWSp00IipITcJARFHdp42g==}
peerDependencies:
@ -16336,6 +16345,10 @@ snapshots:
jose@5.10.0: {}
jotai-family@1.0.2(jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4)):
dependencies:
jotai: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4)
jotai-optics@0.4.0(jotai@2.8.4(@types/react@19.2.14)(react@19.2.4))(optics-ts@2.4.1):
dependencies:
jotai: 2.8.4(@types/react@19.2.14)(react@19.2.4)

View file

@ -44,7 +44,7 @@ test.describe("ClickUp connector journey", () => {
waitUntil: "domcontentloaded",
});
await openConnectorPopup(page);
const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" });
const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" });
await expect(connectorDialog).toBeVisible();
await expect(connectorDialog.getByText("ClickUp")).toBeVisible();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,7 +21,7 @@ export async function openConnectorPopup(page: Page): Promise<void> {
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();
}
/**