Merge pull request #1576 from AnishSarkar22/feat/ci-ui-changes

feat: Polish workspace UI and complete workspace terminology migration
This commit is contained in:
Anish Sarkar 2026-07-06 16:19:05 +05:30 committed by GitHub
commit 7bf559cd5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
289 changed files with 6519 additions and 5444 deletions

View file

@ -0,0 +1,31 @@
"""Remove AI file sort flag.
Revision ID: 172
Revises: 171
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "172"
down_revision: str | None = "171"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.drop_column("workspaces", "ai_file_sort_enabled")
def downgrade() -> None:
op.add_column(
"workspaces",
sa.Column(
"ai_file_sort_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)

View file

@ -1732,10 +1732,6 @@ class Workspace(BaseModel, TimestampMixin):
Integer, nullable=True, default=0, server_default="0"
) # For vision/screenshot analysis, defaults to Auto mode
ai_file_sort_enabled = Column(
Boolean, nullable=False, default=False, server_default="false"
)
user_id = Column(
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
)

View file

@ -1,6 +1,6 @@
"""``document.entered_folder``: a document became a member of a folder.
Fires once per arrival, however the document got there (upload, AI sort, move).
Fires once per arrival, however the document got there (upload or move).
The payload carries the fields a user can filter a trigger on.
"""

View file

@ -427,8 +427,6 @@ class IndexingPipelineService:
log_index_success(ctx, chunk_count=chunk_count)
outcome_status = "success"
await self._enqueue_ai_sort_if_enabled(document)
except RETRYABLE_LLM_ERRORS as e:
ot.record_error(persist_span, e)
log_retryable_llm_error(ctx, e)
@ -564,29 +562,6 @@ class IndexingPipelineService:
)
return len(new_texts)
async def _enqueue_ai_sort_if_enabled(self, document: Document) -> None:
"""Fire-and-forget: enqueue incremental AI sort if the workspace has it enabled."""
try:
from app.db import Workspace
result = await self.session.execute(
select(Workspace.ai_file_sort_enabled).where(
Workspace.id == document.workspace_id
)
)
enabled = result.scalar()
if not enabled:
return
from app.tasks.celery_tasks.document_tasks import ai_sort_document_task
user_id = str(document.created_by_id) if document.created_by_id else ""
ai_sort_document_task.delay(document.workspace_id, user_id, document.id)
except Exception:
logging.getLogger(__name__).warning(
"Failed to enqueue AI sort for document %s", document.id, exc_info=True
)
async def index_batch_parallel(
self,
connector_docs: list[ConnectorDocument],

View file

@ -543,7 +543,7 @@ def record_kb_search_duration(
_record(
_kb_search_duration(),
duration_ms,
{"search_space.id": workspace_id, "search.surface": surface},
{"workspace.id": workspace_id, "search.surface": surface},
)

View file

@ -261,7 +261,7 @@ def kb_search_span(
"""Span around knowledge-base search routines."""
attrs: dict[str, Any] = {}
if workspace_id is not None:
attrs["search_space.id"] = int(workspace_id)
attrs["workspace.id"] = int(workspace_id)
if query_chars is not None:
attrs["query.chars"] = int(query_chars)
if extra:
@ -303,7 +303,7 @@ def chat_request_span(
if chat_id is not None:
attrs["chat.id"] = int(chat_id)
if workspace_id is not None:
attrs["search_space.id"] = int(workspace_id)
attrs["workspace.id"] = int(workspace_id)
if flow:
attrs["chat.flow"] = flow
if request_id:

View file

@ -189,7 +189,6 @@ async def read_workspaces(
citations_enabled=space.citations_enabled,
api_access_enabled=space.api_access_enabled,
qna_custom_instructions=space.qna_custom_instructions,
ai_file_sort_enabled=space.ai_file_sort_enabled,
member_count=member_count,
is_owner=is_owner,
)
@ -326,43 +325,6 @@ async def update_workspace_api_access(
) from e
@router.post("/workspaces/{workspace_id}/ai-sort")
async def trigger_ai_sort(
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""Trigger a full AI file sort for all documents in the workspace."""
try:
await check_permission(
session,
auth,
workspace_id,
Permission.SETTINGS_UPDATE.value,
"You don't have permission to trigger AI sort on this workspace",
)
result = await session.execute(
select(Workspace).filter(Workspace.id == workspace_id)
)
db_workspace = result.scalars().first()
if not db_workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
from app.tasks.celery_tasks.document_tasks import ai_sort_workspace_task
ai_sort_workspace_task.delay(workspace_id, str(user.id))
return {"message": "AI sort started"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to trigger AI sort: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to trigger AI sort: {e!s}"
) from e
@router.delete("/workspaces/{workspace_id}", response_model=dict)
async def delete_workspace(
workspace_id: int,

View file

@ -21,7 +21,6 @@ class WorkspaceUpdate(BaseModel):
description: str | None = None
citations_enabled: bool | None = None
qna_custom_instructions: str | None = None
ai_file_sort_enabled: bool | None = None
class WorkspaceApiAccessUpdate(BaseModel):
@ -36,7 +35,6 @@ class WorkspaceRead(WorkspaceBase, IDModel, TimestampModel):
api_access_enabled: bool = False
qna_custom_instructions: str | None = None
shared_memory_md: str | None = None
ai_file_sort_enabled: bool = False
model_config = ConfigDict(from_attributes=True)

View file

@ -1,329 +0,0 @@
"""AI File Sort Service: builds connector-type/date/category/subcategory folder paths."""
from __future__ import annotations
import json
import logging
import re
from datetime import UTC, datetime
from langchain_core.messages import HumanMessage
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from app.db import (
Chunk,
Document,
DocumentType,
SearchSourceConnector,
SearchSourceConnectorType,
)
from app.services.folder_service import ensure_folder_hierarchy_with_depth_validation
logger = logging.getLogger(__name__)
_DOCTYPE_TO_CONNECTOR_LABEL: dict[str, str] = {
DocumentType.EXTENSION: "Browser Extension",
DocumentType.CRAWLED_URL: "Web Crawl",
DocumentType.FILE: "File Upload",
DocumentType.SLACK_CONNECTOR: "Slack",
DocumentType.TEAMS_CONNECTOR: "Teams",
DocumentType.ONEDRIVE_FILE: "OneDrive",
DocumentType.NOTION_CONNECTOR: "Notion",
DocumentType.YOUTUBE_VIDEO: "YouTube",
DocumentType.GITHUB_CONNECTOR: "GitHub",
DocumentType.LINEAR_CONNECTOR: "Linear",
DocumentType.DISCORD_CONNECTOR: "Discord",
DocumentType.JIRA_CONNECTOR: "Jira",
DocumentType.CONFLUENCE_CONNECTOR: "Confluence",
DocumentType.CLICKUP_CONNECTOR: "ClickUp",
DocumentType.GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
DocumentType.GOOGLE_GMAIL_CONNECTOR: "Gmail",
DocumentType.GOOGLE_DRIVE_FILE: "Google Drive",
DocumentType.AIRTABLE_CONNECTOR: "Airtable",
DocumentType.LUMA_CONNECTOR: "Luma",
DocumentType.ELASTICSEARCH_CONNECTOR: "Elasticsearch",
DocumentType.BOOKSTACK_CONNECTOR: "BookStack",
DocumentType.CIRCLEBACK: "Circleback",
DocumentType.OBSIDIAN_CONNECTOR: "Obsidian",
DocumentType.NOTE: "Notes",
DocumentType.DROPBOX_FILE: "Dropbox",
DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Google Drive (Composio)",
DocumentType.COMPOSIO_GMAIL_CONNECTOR: "Gmail (Composio)",
DocumentType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Google Calendar (Composio)",
DocumentType.LOCAL_FOLDER_FILE: "Local Folder",
}
_CONNECTOR_TYPE_LABEL: dict[str, str] = {
SearchSourceConnectorType.SERPER_API: "Serper Search",
SearchSourceConnectorType.TAVILY_API: "Tavily Search",
SearchSourceConnectorType.SEARXNG_API: "SearXNG Search",
SearchSourceConnectorType.LINKUP_API: "Linkup Search",
SearchSourceConnectorType.BAIDU_SEARCH_API: "Baidu Search",
SearchSourceConnectorType.SLACK_CONNECTOR: "Slack",
SearchSourceConnectorType.TEAMS_CONNECTOR: "Teams",
SearchSourceConnectorType.ONEDRIVE_CONNECTOR: "OneDrive",
SearchSourceConnectorType.NOTION_CONNECTOR: "Notion",
SearchSourceConnectorType.GITHUB_CONNECTOR: "GitHub",
SearchSourceConnectorType.LINEAR_CONNECTOR: "Linear",
SearchSourceConnectorType.DISCORD_CONNECTOR: "Discord",
SearchSourceConnectorType.JIRA_CONNECTOR: "Jira",
SearchSourceConnectorType.CONFLUENCE_CONNECTOR: "Confluence",
SearchSourceConnectorType.CLICKUP_CONNECTOR: "ClickUp",
SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR: "Gmail",
SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR: "Google Drive",
SearchSourceConnectorType.AIRTABLE_CONNECTOR: "Airtable",
SearchSourceConnectorType.LUMA_CONNECTOR: "Luma",
SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR: "Elasticsearch",
SearchSourceConnectorType.WEBCRAWLER_CONNECTOR: "Web Crawl",
SearchSourceConnectorType.BOOKSTACK_CONNECTOR: "BookStack",
SearchSourceConnectorType.CIRCLEBACK_CONNECTOR: "Circleback",
SearchSourceConnectorType.OBSIDIAN_CONNECTOR: "Obsidian",
SearchSourceConnectorType.MCP_CONNECTOR: "MCP",
SearchSourceConnectorType.DROPBOX_CONNECTOR: "Dropbox",
SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Google Drive (Composio)",
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR: "Gmail (Composio)",
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Google Calendar (Composio)",
}
_MAX_CONTENT_CHARS = 4000
_MAX_CHUNKS_FOR_CONTEXT = 5
_CATEGORY_PROMPT = (
"Based on the document information below, classify it into a broad category "
"and a more specific subcategory.\n\n"
"Rules:\n"
"- category: 1-2 word broad theme (e.g. Science, Finance, Engineering, Communication, Media)\n"
"- subcategory: 1-2 word specific topic within the category "
"(e.g. Physics, Tax Reports, Backend, Team Updates)\n"
"- Use nouns only. Do not include generic terms like 'General' or 'Miscellaneous'.\n\n"
"Title: {title}\n\n"
"Content: {summary}\n\n"
'Respond with ONLY a JSON object: {{"category": "...", "subcategory": "..."}}'
)
_SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9 _\-()]")
_FALLBACK_CATEGORY = "Uncategorized"
_FALLBACK_SUBCATEGORY = "General"
def resolve_root_folder_label(
document: Document, connector: SearchSourceConnector | None
) -> str:
if connector is not None:
return _CONNECTOR_TYPE_LABEL.get(
connector.connector_type, str(connector.connector_type)
)
return _DOCTYPE_TO_CONNECTOR_LABEL.get(
document.document_type, str(document.document_type)
)
def resolve_date_folder(document: Document) -> str:
ts = document.updated_at or document.created_at
if ts is None:
ts = datetime.now(UTC)
return ts.strftime("%Y-%m-%d")
def sanitize_category_folder_name(
value: str | None, fallback: str = _FALLBACK_CATEGORY
) -> str:
if not value or not value.strip():
return fallback
cleaned = _SAFE_NAME_RE.sub("", value.strip())
cleaned = " ".join(cleaned.split())
if not cleaned:
return fallback
return cleaned[:50]
async def _resolve_document_text(
session: AsyncSession,
document: Document,
) -> str:
"""Build the best available text representation for taxonomy generation.
Prefers ``document.content``; falls back to joining the first few chunks
when content is empty or too short to be useful.
"""
text = (document.content or "").strip()
if len(text) >= 100:
return text[:_MAX_CONTENT_CHARS]
stmt = (
select(Chunk.content)
.where(Chunk.document_id == document.id)
.order_by(Chunk.position, Chunk.id)
.limit(_MAX_CHUNKS_FOR_CONTEXT)
)
result = await session.execute(stmt)
chunk_texts = [row[0] for row in result.all() if row[0]]
if chunk_texts:
combined = "\n\n".join(chunk_texts)
return combined[:_MAX_CONTENT_CHARS]
return text[:_MAX_CONTENT_CHARS]
def _get_cached_taxonomy(document: Document) -> tuple[str, str] | None:
"""Return (category, subcategory) from document metadata cache, or None."""
meta = document.document_metadata
if not isinstance(meta, dict):
return None
cat = meta.get("ai_sort_category")
subcat = meta.get("ai_sort_subcategory")
if cat and subcat and isinstance(cat, str) and isinstance(subcat, str):
return cat, subcat
return None
def _set_cached_taxonomy(document: Document, category: str, subcategory: str) -> None:
"""Persist the AI taxonomy on document metadata for deterministic re-sorts."""
meta = dict(document.document_metadata or {})
meta["ai_sort_category"] = category
meta["ai_sort_subcategory"] = subcategory
document.document_metadata = meta
async def generate_ai_taxonomy(
title: str,
summary_or_content: str,
llm,
) -> tuple[str, str]:
"""Return (category, subcategory) using a single structured LLM call."""
text = (summary_or_content or "").strip()
if not text:
return _FALLBACK_CATEGORY, _FALLBACK_SUBCATEGORY
if len(text) > _MAX_CONTENT_CHARS:
text = text[:_MAX_CONTENT_CHARS]
prompt = _CATEGORY_PROMPT.format(title=title or "Untitled", summary=text)
try:
result = await llm.ainvoke([HumanMessage(content=prompt)])
raw = result.content.strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
parsed = json.loads(raw)
category = sanitize_category_folder_name(
parsed.get("category"), _FALLBACK_CATEGORY
)
subcategory = sanitize_category_folder_name(
parsed.get("subcategory"), _FALLBACK_SUBCATEGORY
)
return category, subcategory
except Exception:
logger.warning("AI taxonomy generation failed, using fallback", exc_info=True)
return _FALLBACK_CATEGORY, _FALLBACK_SUBCATEGORY
def _build_path_segments(
root_label: str,
date_label: str,
category: str,
subcategory: str,
) -> list[dict]:
return [
{"name": root_label, "metadata": {"ai_sort": True, "ai_sort_level": 1}},
{"name": date_label, "metadata": {"ai_sort": True, "ai_sort_level": 2}},
{"name": category, "metadata": {"ai_sort": True, "ai_sort_level": 3}},
{"name": subcategory, "metadata": {"ai_sort": True, "ai_sort_level": 4}},
]
async def _resolve_taxonomy(
session: AsyncSession,
document: Document,
llm,
) -> tuple[str, str]:
"""Return (category, subcategory), reusing cached values when available."""
cached = _get_cached_taxonomy(document)
if cached is not None:
return cached
content_text = await _resolve_document_text(session, document)
category, subcategory = await generate_ai_taxonomy(
document.title, content_text, llm
)
_set_cached_taxonomy(document, category, subcategory)
return category, subcategory
async def ai_sort_document(
session: AsyncSession,
document: Document,
llm,
) -> Document:
"""Sort a single document into the 4-level AI folder hierarchy."""
connector: SearchSourceConnector | None = None
if document.connector_id is not None:
connector = await session.get(SearchSourceConnector, document.connector_id)
root_label = resolve_root_folder_label(document, connector)
date_label = resolve_date_folder(document)
category, subcategory = await _resolve_taxonomy(session, document, llm)
segments = _build_path_segments(root_label, date_label, category, subcategory)
leaf_folder = await ensure_folder_hierarchy_with_depth_validation(
session,
document.workspace_id,
segments,
)
document.folder_id = leaf_folder.id
await session.flush()
return document
async def ai_sort_all_documents(
session: AsyncSession,
workspace_id: int,
llm,
) -> tuple[int, int]:
"""Sort all documents in a workspace. Returns (sorted_count, failed_count)."""
stmt = (
select(Document)
.where(Document.workspace_id == workspace_id)
.options(selectinload(Document.connector))
)
result = await session.execute(stmt)
documents = list(result.scalars().all())
sorted_count = 0
failed_count = 0
for doc in documents:
try:
connector = doc.connector
root_label = resolve_root_folder_label(doc, connector)
date_label = resolve_date_folder(doc)
category, subcategory = await _resolve_taxonomy(session, doc, llm)
segments = _build_path_segments(
root_label, date_label, category, subcategory
)
leaf_folder = await ensure_folder_hierarchy_with_depth_validation(
session,
workspace_id,
segments,
)
doc.folder_id = leaf_folder.id
sorted_count += 1
except Exception:
logger.error("Failed to AI-sort document %s", doc.id, exc_info=True)
failed_count += 1
await session.commit()
logger.info(
"AI sort complete for workspace=%d: sorted=%d, failed=%d",
workspace_id,
sorted_count,
failed_count,
)
return sorted_count, failed_count

View file

@ -4,7 +4,6 @@ import asyncio
import contextlib
import logging
import os
import time
from uuid import UUID
from app.celery_app import celery_app
@ -1395,108 +1394,3 @@ async def _index_uploaded_folder_files_async(
if notification_id is not None:
_stop_heartbeat(notification_id)
# ===== AI File Sort tasks =====
AI_SORT_LOCK_TTL_SECONDS = 600 # 10 minutes
_ai_sort_redis = None
def _get_ai_sort_redis():
import redis
global _ai_sort_redis
if _ai_sort_redis is None:
_ai_sort_redis = redis.from_url(config.REDIS_APP_URL, decode_responses=True)
return _ai_sort_redis
def _ai_sort_lock_key(workspace_id: int) -> str:
return f"ai_sort:workspace:{workspace_id}:lock"
@celery_app.task(name="ai_sort_search_space", bind=True, max_retries=1)
def ai_sort_workspace_task(self, workspace_id: int, user_id: str):
"""Full AI sort for all documents in a workspace."""
return run_async_celery_task(
lambda: _ai_sort_workspace_async(workspace_id, user_id)
)
async def _ai_sort_workspace_async(workspace_id: int, user_id: str):
r = _get_ai_sort_redis()
lock_key = _ai_sort_lock_key(workspace_id)
if not r.set(lock_key, "running", nx=True, ex=AI_SORT_LOCK_TTL_SECONDS):
logger.info(
"AI sort already running for workspace=%d, skipping",
workspace_id,
)
return
t_start = time.perf_counter()
try:
from app.services.ai_file_sort_service import ai_sort_all_documents
from app.services.llm_service import get_agent_llm
async with get_celery_session_maker()() as session:
llm = await get_agent_llm(session, workspace_id, disable_streaming=True)
if llm is None:
logger.warning(
"No LLM configured for workspace=%d, skipping AI sort",
workspace_id,
)
return
sorted_count, failed_count = await ai_sort_all_documents(
session, workspace_id, llm
)
elapsed = time.perf_counter() - t_start
logger.info(
"AI sort workspace=%d done in %.1fs: sorted=%d failed=%d",
workspace_id,
elapsed,
sorted_count,
failed_count,
)
finally:
r.delete(lock_key)
@celery_app.task(
name="ai_sort_document", bind=True, max_retries=2, default_retry_delay=10
)
def ai_sort_document_task(self, workspace_id: int, user_id: str, document_id: int):
"""Incremental AI sort for a single document after indexing."""
return run_async_celery_task(
lambda: _ai_sort_document_async(workspace_id, user_id, document_id)
)
async def _ai_sort_document_async(workspace_id: int, user_id: str, document_id: int):
from app.db import Document
from app.services.ai_file_sort_service import ai_sort_document
from app.services.llm_service import get_agent_llm
async with get_celery_session_maker()() as session:
document = await session.get(Document, document_id)
if document is None:
logger.warning("Document %d not found, skipping AI sort", document_id)
return
llm = await get_agent_llm(session, workspace_id, disable_streaming=True)
if llm is None:
logger.warning(
"No LLM for workspace=%d, skipping AI sort of doc=%d",
workspace_id,
document_id,
)
return
await ai_sort_document(session, document, llm)
await session.commit()
logger.info(
"AI sorted document=%d into workspace=%d",
document_id,
workspace_id,
)

View file

@ -35,7 +35,6 @@ def _disable_otel(monkeypatch: pytest.MonkeyPatch):
("cleanup_stale_indexing_notifications", "cleanup"),
("reconcile_pending_stripe_credit_purchases", "reconcile"),
("check_periodic_schedules", "check"),
("ai_sort_search_space", "ai"),
("index_notion_pages", "index"),
("index_github_repos", "index"),
("index_google_drive_files", "index"),

View file

@ -1,275 +0,0 @@
"""Unit tests for AI file sort service: folder label resolution, date extraction, category sanitization."""
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock
import pytest
pytestmark = pytest.mark.unit
# ── resolve_root_folder_label ──
def _make_document(document_type: str, connector_id=None):
doc = MagicMock()
doc.document_type = document_type
doc.connector_id = connector_id
return doc
def _make_connector(connector_type: str):
conn = MagicMock()
conn.connector_type = connector_type
return conn
def test_root_label_uses_connector_type_when_available():
from app.services.ai_file_sort_service import resolve_root_folder_label
doc = _make_document("FILE", connector_id=1)
conn = _make_connector("GOOGLE_DRIVE_CONNECTOR")
assert resolve_root_folder_label(doc, conn) == "Google Drive"
def test_root_label_falls_back_to_document_type():
from app.services.ai_file_sort_service import resolve_root_folder_label
doc = _make_document("SLACK_CONNECTOR")
assert resolve_root_folder_label(doc, None) == "Slack"
def test_root_label_unknown_doctype_returns_raw_value():
from app.services.ai_file_sort_service import resolve_root_folder_label
doc = _make_document("UNKNOWN_TYPE")
assert resolve_root_folder_label(doc, None) == "UNKNOWN_TYPE"
# ── resolve_date_folder ──
def test_date_folder_from_updated_at():
from app.services.ai_file_sort_service import resolve_date_folder
doc = MagicMock()
doc.updated_at = datetime(2025, 3, 15, 10, 30, 0, tzinfo=UTC)
doc.created_at = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
assert resolve_date_folder(doc) == "2025-03-15"
def test_date_folder_falls_back_to_created_at():
from app.services.ai_file_sort_service import resolve_date_folder
doc = MagicMock()
doc.updated_at = None
doc.created_at = datetime(2024, 12, 25, 23, 59, 0, tzinfo=UTC)
assert resolve_date_folder(doc) == "2024-12-25"
def test_date_folder_both_none_uses_today():
from app.services.ai_file_sort_service import resolve_date_folder
doc = MagicMock()
doc.updated_at = None
doc.created_at = None
result = resolve_date_folder(doc)
today = datetime.now(UTC).strftime("%Y-%m-%d")
assert result == today
# ── sanitize_category_folder_name ──
def test_sanitize_normal_value():
from app.services.ai_file_sort_service import sanitize_category_folder_name
assert sanitize_category_folder_name("Machine Learning") == "Machine Learning"
def test_sanitize_strips_special_chars():
from app.services.ai_file_sort_service import sanitize_category_folder_name
assert sanitize_category_folder_name("Tax/Reports!") == "TaxReports"
def test_sanitize_empty_returns_fallback():
from app.services.ai_file_sort_service import sanitize_category_folder_name
assert sanitize_category_folder_name("") == "Uncategorized"
assert sanitize_category_folder_name(None) == "Uncategorized"
def test_sanitize_truncates_long_names():
from app.services.ai_file_sort_service import sanitize_category_folder_name
long_name = "A" * 100
result = sanitize_category_folder_name(long_name)
assert len(result) <= 50
# ── generate_ai_taxonomy ──
@pytest.mark.asyncio
async def test_generate_ai_taxonomy_parses_json():
from app.services.ai_file_sort_service import generate_ai_taxonomy
mock_llm = AsyncMock()
mock_result = MagicMock()
mock_result.content = '{"category": "Science", "subcategory": "Physics"}'
mock_llm.ainvoke.return_value = mock_result
cat, sub = await generate_ai_taxonomy(
"Physics Paper", "Some science document about physics", mock_llm
)
assert cat == "Science"
assert sub == "Physics"
@pytest.mark.asyncio
async def test_generate_ai_taxonomy_handles_markdown_code_block():
from app.services.ai_file_sort_service import generate_ai_taxonomy
mock_llm = AsyncMock()
mock_result = MagicMock()
mock_result.content = (
'```json\n{"category": "Finance", "subcategory": "Tax Reports"}\n```'
)
mock_llm.ainvoke.return_value = mock_result
cat, sub = await generate_ai_taxonomy("Tax Doc", "A tax report document", mock_llm)
assert cat == "Finance"
assert sub == "Tax Reports"
@pytest.mark.asyncio
async def test_generate_ai_taxonomy_includes_title_in_prompt():
from app.services.ai_file_sort_service import generate_ai_taxonomy
mock_llm = AsyncMock()
mock_result = MagicMock()
mock_result.content = '{"category": "Engineering", "subcategory": "Backend"}'
mock_llm.ainvoke.return_value = mock_result
await generate_ai_taxonomy("API Design Guide", "content about REST APIs", mock_llm)
prompt_text = mock_llm.ainvoke.call_args[0][0][0].content
assert "API Design Guide" in prompt_text
assert "content about REST APIs" in prompt_text
@pytest.mark.asyncio
async def test_generate_ai_taxonomy_fallback_on_error():
from app.services.ai_file_sort_service import generate_ai_taxonomy
mock_llm = AsyncMock()
mock_llm.ainvoke.side_effect = RuntimeError("LLM down")
cat, sub = await generate_ai_taxonomy("Title", "some content", mock_llm)
assert cat == "Uncategorized"
assert sub == "General"
@pytest.mark.asyncio
async def test_generate_ai_taxonomy_fallback_on_empty_content():
from app.services.ai_file_sort_service import generate_ai_taxonomy
mock_llm = AsyncMock()
cat, sub = await generate_ai_taxonomy("Title", "", mock_llm)
assert cat == "Uncategorized"
assert sub == "General"
mock_llm.ainvoke.assert_not_called()
@pytest.mark.asyncio
async def test_generate_ai_taxonomy_fallback_on_invalid_json():
from app.services.ai_file_sort_service import generate_ai_taxonomy
mock_llm = AsyncMock()
mock_result = MagicMock()
mock_result.content = "not valid json at all"
mock_llm.ainvoke.return_value = mock_result
cat, sub = await generate_ai_taxonomy("Title", "some content", mock_llm)
assert cat == "Uncategorized"
assert sub == "General"
# ── taxonomy caching ──
def test_get_cached_taxonomy_returns_none_when_no_metadata():
from app.services.ai_file_sort_service import _get_cached_taxonomy
doc = MagicMock()
doc.document_metadata = None
assert _get_cached_taxonomy(doc) is None
def test_get_cached_taxonomy_returns_none_when_keys_missing():
from app.services.ai_file_sort_service import _get_cached_taxonomy
doc = MagicMock()
doc.document_metadata = {"some_other_key": "value"}
assert _get_cached_taxonomy(doc) is None
def test_get_cached_taxonomy_returns_cached_values():
from app.services.ai_file_sort_service import _get_cached_taxonomy
doc = MagicMock()
doc.document_metadata = {
"ai_sort_category": "Finance",
"ai_sort_subcategory": "Tax Reports",
}
assert _get_cached_taxonomy(doc) == ("Finance", "Tax Reports")
def test_set_cached_taxonomy_persists_on_metadata():
from app.services.ai_file_sort_service import _set_cached_taxonomy
doc = MagicMock()
doc.document_metadata = {"existing_key": "keep_me"}
_set_cached_taxonomy(doc, "Science", "Physics")
assert doc.document_metadata["ai_sort_category"] == "Science"
assert doc.document_metadata["ai_sort_subcategory"] == "Physics"
assert doc.document_metadata["existing_key"] == "keep_me"
def test_set_cached_taxonomy_creates_metadata_when_none():
from app.services.ai_file_sort_service import _set_cached_taxonomy
doc = MagicMock()
doc.document_metadata = None
_set_cached_taxonomy(doc, "Engineering", "Backend")
assert doc.document_metadata == {
"ai_sort_category": "Engineering",
"ai_sort_subcategory": "Backend",
}
# ── _build_path_segments ──
def test_build_path_segments_structure():
from app.services.ai_file_sort_service import _build_path_segments
segments = _build_path_segments("Google Drive", "2025-03-15", "Science", "Physics")
assert len(segments) == 4
assert segments[0] == {
"name": "Google Drive",
"metadata": {"ai_sort": True, "ai_sort_level": 1},
}
assert segments[1] == {
"name": "2025-03-15",
"metadata": {"ai_sort": True, "ai_sort_level": 2},
}
assert segments[2] == {
"name": "Science",
"metadata": {"ai_sort": True, "ai_sort_level": 3},
}
assert segments[3] == {
"name": "Physics",
"metadata": {"ai_sort": True, "ai_sort_level": 4},
}

View file

@ -1,43 +0,0 @@
"""Unit tests for AI sort task Redis deduplication lock."""
from unittest.mock import MagicMock, patch
import pytest
pytestmark = pytest.mark.unit
def test_lock_key_format():
from app.tasks.celery_tasks.document_tasks import _ai_sort_lock_key
key = _ai_sort_lock_key(42)
assert key == "ai_sort:workspace:42:lock"
def test_lock_prevents_duplicate_run():
"""When the Redis lock already exists, the task should skip execution."""
mock_redis = MagicMock()
mock_redis.set.return_value = False # Lock already held
with (
patch(
"app.tasks.celery_tasks.document_tasks._get_ai_sort_redis",
return_value=mock_redis,
),
patch(
"app.tasks.celery_tasks.document_tasks.get_celery_session_maker"
) as mock_session_maker,
):
import asyncio
from app.tasks.celery_tasks.document_tasks import _ai_sort_workspace_async
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(_ai_sort_workspace_async(1, "user-123"))
finally:
loop.close()
# Session maker should never be called since lock was not acquired
mock_session_maker.assert_not_called()

View file

@ -49,8 +49,8 @@ async def test_creates_missing_folders_in_chain():
session.flush = mock_flush
segments = [
{"name": "Slack", "metadata": {"ai_sort": True, "ai_sort_level": 1}},
{"name": "2025-03-15", "metadata": {"ai_sort": True, "ai_sort_level": 2}},
{"name": "Slack", "metadata": {"source": "slack"}},
{"name": "2025-03-15", "metadata": {"source": "slack"}},
]
result = await ensure_folder_hierarchy_with_depth_validation(

View file

@ -112,12 +112,12 @@ const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
}));
const token = await storage.get("token");
const search_space_id = parseInt(await storage.get("search_space_id"), 10);
const workspace_id = parseInt(await storage.get("workspace_id"), 10);
const toSend = {
document_type: "EXTENSION",
content: content,
search_space_id: search_space_id,
workspace_id: workspace_id,
};
console.log("toSend", toSend);

View file

@ -106,12 +106,12 @@ const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
}));
const token = await storage.get("token");
const search_space_id = parseInt(await storage.get("search_space_id"), 10);
const workspace_id = parseInt(await storage.get("workspace_id"), 10);
const toSend = {
document_type: "EXTENSION",
content: content,
search_space_id: search_space_id,
workspace_id: workspace_id,
};
const requestOptions = {

View file

@ -0,0 +1,7 @@
allowBuilds:
'@parcel/watcher': set this to true or false
'@swc/core': set this to true or false
esbuild: set this to true or false
lmdb: set this to true or false
msgpackr-extract: set this to true or false
sharp: set this to true or false

View file

@ -33,6 +33,20 @@ import { getRenderedHtml } from "~utils/commons";
import type { WebHistory } from "~utils/interfaces";
import Loading from "./Loading";
// One-time migration: legacy persisted keys were `search_space` / `search_space_id`.
// Copy them to the new `workspace` / `workspace_id` keys on first read so the
// user's selected workspace survives the rename.
async function migrateLegacyWorkspaceKeys(storage: Storage): Promise<void> {
const legacyName = await storage.get("search_space");
if (legacyName !== undefined && (await storage.get("workspace")) === undefined) {
await storage.set("workspace", legacyName);
}
const legacyId = await storage.get("search_space_id");
if (legacyId !== undefined && (await storage.get("workspace_id")) === undefined) {
await storage.set("workspace_id", legacyId);
}
}
const HomePage = () => {
const { toast } = useToast();
const navigation = useNavigate();
@ -40,11 +54,11 @@ const HomePage = () => {
const [loading, setLoading] = useState(true);
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState<string>("");
const [searchspaces, setSearchSpaces] = useState([]);
const [workspaces, setWorkspaces] = useState([]);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
const checkSearchSpaces = async () => {
const checkWorkspaces = async () => {
const storage = new Storage({ area: "local" });
const token = await storage.get("token");
@ -55,7 +69,7 @@ const HomePage = () => {
}
try {
const response = await fetch(await buildBackendUrl("/api/v1/searchspaces"), {
const response = await fetch(await buildBackendUrl("/api/v1/workspaces"), {
headers: {
Authorization: `Bearer ${token}`,
}
@ -66,7 +80,7 @@ const HomePage = () => {
} else {
const res = await response.json();
console.log(res);
setSearchSpaces(res);
setWorkspaces(res);
}
} catch (error) {
await storage.remove("token");
@ -77,7 +91,7 @@ const HomePage = () => {
}
};
checkSearchSpaces();
checkWorkspaces();
}, []);
useEffect(() => {
@ -98,10 +112,11 @@ const HomePage = () => {
});
const storage = new Storage({ area: "local" });
const searchspace = await storage.get("search_space");
await migrateLegacyWorkspaceKeys(storage);
const workspace = await storage.get("workspace");
if (searchspace) {
setValue(searchspace);
if (workspace) {
setValue(workspace);
}
await storage.set("showShadowDom", true);
@ -262,17 +277,17 @@ const HomePage = () => {
const saveDatamessage = async () => {
if (value === "") {
toast({
title: "Select a SearchSpace !",
title: "Select a Workspace !",
});
return;
}
const storage = new Storage({ area: "local" });
const search_space_id = await storage.get("search_space_id");
const workspace_id = await storage.get("workspace_id");
if (!search_space_id) {
if (!workspace_id) {
toast({
title: "Invalid SearchSpace selected!",
title: "Invalid Workspace selected!",
variant: "destructive",
});
return;
@ -319,15 +334,15 @@ const HomePage = () => {
const storage = new Storage({ area: "local" });
await storage.remove("token");
await storage.remove("showShadowDom");
await storage.remove("search_space");
await storage.remove("search_space_id");
await storage.remove("workspace");
await storage.remove("workspace_id");
navigation("/login");
}
if (loading) {
return <Loading />;
} else {
return searchspaces.length === 0 ? (
return workspaces.length === 0 ? (
<div className="flex min-h-screen flex-col bg-gradient-to-br from-gray-900 to-gray-800">
<div className="flex flex-1 items-center justify-center p-4">
<div className="w-full max-w-md space-y-8">
@ -337,7 +352,7 @@ const HomePage = () => {
</div>
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-white">SurfSense</h1>
<div className="mt-4 rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-4 text-yellow-300">
<p className="text-sm">Please create a Search Space to continue</p>
<p className="text-sm">Please create a Workspace to continue</p>
</div>
</div>
@ -390,7 +405,7 @@ const HomePage = () => {
</div>
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-4 backdrop-blur-sm">
<Label className="mb-2 block text-sm font-medium text-gray-300">Search Space</Label>
<Label className="mb-2 block text-sm font-medium text-gray-300">Workspace</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
@ -399,8 +414,8 @@ const HomePage = () => {
className="w-full justify-between border-gray-700 bg-gray-900 text-white hover:bg-gray-700"
>
{value
? searchspaces.find((space) => space.name === value)?.name
: "Select Search Space..."}
? workspaces.find((space) => space.name === value)?.name
: "Select Workspace..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@ -411,23 +426,23 @@ const HomePage = () => {
className="border-gray-700 bg-gray-900 text-gray-200"
/>
<CommandList>
<CommandEmpty>No search spaces found.</CommandEmpty>
<CommandEmpty>No workspaces found.</CommandEmpty>
<CommandGroup>
{searchspaces.map((space) => (
{workspaces.map((space) => (
<CommandItem
key={space.name}
value={space.name}
onSelect={async (currentValue) => {
const storage = new Storage({ area: "local" });
if (currentValue === value) {
await storage.set("search_space", "");
await storage.set("search_space_id", 0);
await storage.set("workspace", "");
await storage.set("workspace_id", 0);
} else {
const selectedSpace = searchspaces.find(
const selectedSpace = workspaces.find(
(space) => space.name === currentValue
);
await storage.set("search_space", currentValue);
await storage.set("search_space_id", selectedSpace.id);
await storage.set("workspace", currentValue);
await storage.set("workspace_id", selectedSpace.id);
}
setValue(currentValue === value ? "" : currentValue);
setOpen(false);

File diff suppressed because one or more lines are too long

View file

@ -50,8 +50,8 @@ export const IPC_CHANNELS = {
GET_SHORTCUTS: 'shortcuts:get',
SET_SHORTCUTS: 'shortcuts:set',
// Active search space
GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active',
SET_ACTIVE_SEARCH_SPACE: 'search-space:set-active',
GET_ACTIVE_WORKSPACE: 'workspace:get-active',
SET_ACTIVE_WORKSPACE: 'workspace:set-active',
// Launch on system startup
GET_AUTO_LAUNCH: 'auto-launch:get',
SET_AUTO_LAUNCH: 'auto-launch:set',

View file

@ -27,7 +27,7 @@ import {
} from '../modules/folder-watcher';
import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts';
import { getAutoLaunchState, setAutoLaunch } from '../modules/auto-launch';
import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/active-search-space';
import { getActiveWorkspaceId, setActiveWorkspaceId } from '../modules/active-workspace';
import { reregisterQuickAsk } from '../modules/quick-ask';
import { reregisterGeneralAssist, reregisterScreenshotAssist } from '../modules/tray';
import {
@ -205,9 +205,9 @@ export function registerIpcHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT,
async (_event, virtualPath: string, searchSpaceId?: number | null) => {
async (_event, virtualPath: string, workspaceId?: number | null) => {
try {
const result = await readAgentLocalFileText(virtualPath, searchSpaceId);
const result = await readAgentLocalFileText(virtualPath, workspaceId);
return { ok: true, path: result.path, content: result.content };
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to read local file';
@ -218,9 +218,9 @@ export function registerIpcHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT,
async (_event, virtualPath: string, content: string, searchSpaceId?: number | null) => {
async (_event, virtualPath: string, content: string, workspaceId?: number | null) => {
try {
const result = await writeAgentLocalFileText(virtualPath, content, searchSpaceId);
const result = await writeAgentLocalFileText(virtualPath, content, workspaceId);
return { ok: true, path: result.path };
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to write local file';
@ -321,10 +321,10 @@ export function registerIpcHandlers(): void {
},
);
ipcMain.handle(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE, () => getActiveSearchSpaceId());
ipcMain.handle(IPC_CHANNELS.GET_ACTIVE_WORKSPACE, () => getActiveWorkspaceId());
ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, (_event, id: string) =>
setActiveSearchSpaceId(id)
ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_WORKSPACE, (_event, id: string) =>
setActiveWorkspaceId(id)
);
ipcMain.handle(IPC_CHANNELS.SET_SHORTCUTS, async (_event, config: Partial<ShortcutConfig>) => {
@ -370,12 +370,12 @@ export function registerIpcHandlers(): void {
};
});
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, (_event, searchSpaceId?: number | null) =>
getAgentFilesystemSettings(searchSpaceId)
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, (_event, workspaceId?: number | null) =>
getAgentFilesystemSettings(workspaceId)
);
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, (_event, searchSpaceId?: number | null) =>
getAgentFilesystemMounts(searchSpaceId)
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, (_event, workspaceId?: number | null) =>
getAgentFilesystemMounts(workspaceId)
);
ipcMain.handle(
@ -384,7 +384,7 @@ export function registerIpcHandlers(): void {
_event,
options: {
rootPath: string;
searchSpaceId?: number | null;
workspaceId?: number | null;
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
}
@ -397,10 +397,10 @@ export function registerIpcHandlers(): void {
(
_event,
payload: {
searchSpaceId?: number | null;
workspaceId?: number | null;
settings: { mode?: 'cloud' | 'desktop_local_folder'; localRootPaths?: string[] | null };
}
) => setAgentFilesystemSettings(payload?.searchSpaceId, payload?.settings ?? {})
) => setAgentFilesystemSettings(payload?.workspaceId, payload?.settings ?? {})
);
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT, () =>
@ -415,7 +415,7 @@ export function registerIpcHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP,
(_event, searchSpaceId?: number | null) =>
stopAgentFilesystemTreeWatch(searchSpaceId)
(_event, workspaceId?: number | null) =>
stopAgentFilesystemTreeWatch(workspaceId)
);
}

View file

@ -1,24 +0,0 @@
const STORE_KEY = 'activeSearchSpaceId';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let store: any = null;
async function getStore() {
if (!store) {
const { default: Store } = await import('electron-store');
store = new Store({
name: 'active-search-space',
defaults: { [STORE_KEY]: null as string | null },
});
}
return store;
}
export async function getActiveSearchSpaceId(): Promise<string | null> {
const s = await getStore();
return (s.get(STORE_KEY) as string | null) ?? null;
}
export async function setActiveSearchSpaceId(id: string): Promise<void> {
const s = await getStore();
s.set(STORE_KEY, id);
}

View file

@ -0,0 +1,33 @@
const STORE_KEY = 'activeWorkspaceId';
let store: any = null;
async function getStore() {
if (!store) {
const { default: Store } = await import('electron-store');
store = new Store({
name: 'active-workspace',
defaults: { [STORE_KEY]: null as string | null },
});
// One-time migration from the legacy `active-search-space` store so the
// user's last-selected workspace survives the rename.
if (store.get(STORE_KEY) == null) {
const legacy: any = new Store({
name: 'active-search-space',
defaults: { activeSearchSpaceId: null as string | null },
});
const prev = legacy.get('activeSearchSpaceId') as string | null;
if (prev != null) store.set(STORE_KEY, prev);
}
}
return store;
}
export async function getActiveWorkspaceId(): Promise<string | null> {
const s = await getStore();
return (s.get(STORE_KEY) as string | null) ?? null;
}
export async function setActiveWorkspaceId(id: string): Promise<void> {
const s = await getStore();
s.set(STORE_KEY, id);
}

View file

@ -8,7 +8,7 @@ const SAFETY_POLL_MS = 60_000;
const EVENT_DEBOUNCE_MS = 700;
export type AgentFilesystemTreeWatchOptions = {
searchSpaceId?: number | null;
workspaceId?: number | null;
rootPaths: string[];
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
@ -17,7 +17,7 @@ export type AgentFilesystemTreeWatchOptions = {
type TreeDirtyReason = 'watcher_event' | 'safety_poll';
type TreeDirtyEvent = {
searchSpaceId: number | null;
workspaceId: number | null;
reason: TreeDirtyReason;
rootPath: string;
changedPath: string | null;
@ -25,7 +25,7 @@ type TreeDirtyEvent = {
};
type WatchSession = {
searchSpaceId: number | null;
workspaceId: number | null;
optionsSignature: string;
rootPaths: string[];
excludePatterns: string[];
@ -40,15 +40,15 @@ type WatchSession = {
const sessions = new Map<string, WatchSession>();
function normalizeSearchSpaceId(searchSpaceId?: number | null): number | null {
if (typeof searchSpaceId === 'number' && Number.isFinite(searchSpaceId) && searchSpaceId > 0) {
return searchSpaceId;
function normalizeWorkspaceId(workspaceId?: number | null): number | null {
if (typeof workspaceId === 'number' && Number.isFinite(workspaceId) && workspaceId > 0) {
return workspaceId;
}
return null;
}
function getSessionKey(searchSpaceId?: number | null): string {
const normalized = normalizeSearchSpaceId(searchSpaceId);
function getSessionKey(workspaceId?: number | null): string {
const normalized = normalizeWorkspaceId(workspaceId);
return normalized === null ? 'default' : String(normalized);
}
@ -71,13 +71,13 @@ function normalizeExtensions(value: string[] | null | undefined): string[] | nul
}
function buildOptionsSignature(
searchSpaceId: number | null,
workspaceId: number | null,
rootPaths: string[],
excludePatterns: string[],
fileExtensions: string[] | null
): string {
return JSON.stringify({
searchSpaceId,
workspaceId,
rootPaths: [...rootPaths].sort(),
excludePatterns: [...excludePatterns].sort(),
fileExtensions: fileExtensions ? [...fileExtensions].sort() : null,
@ -99,10 +99,10 @@ async function buildRootSnapshotSignature(
rootPath: string
): Promise<string> {
let hash = 2166136261;
hash = hashText(`space:${session.searchSpaceId ?? 'default'}|root:${rootPath}`, hash);
hash = hashText(`space:${session.workspaceId ?? 'default'}|root:${rootPath}`, hash);
const files = await listAgentFilesystemFiles({
rootPath,
searchSpaceId: session.searchSpaceId,
workspaceId: session.workspaceId,
excludePatterns: session.excludePatterns,
fileExtensions: session.fileExtensions,
});
@ -118,13 +118,13 @@ async function buildRootSnapshotSignature(
}
function sendTreeDirtyEvent(
searchSpaceId: number | null,
workspaceId: number | null,
reason: TreeDirtyReason,
rootPath: string,
changedPath: string | null
): void {
const payload: TreeDirtyEvent = {
searchSpaceId,
workspaceId,
reason,
rootPath,
changedPath,
@ -158,7 +158,7 @@ function scheduleDirtyEmit(
session.pendingDirtyByRoot.clear();
for (const [pendingRootPath, payload] of pending) {
sendTreeDirtyEvent(
session.searchSpaceId,
session.workspaceId,
payload.reason,
pendingRootPath,
payload.changedPath
@ -183,21 +183,21 @@ async function closeSession(session: WatchSession): Promise<void> {
export async function startAgentFilesystemTreeWatch(
options: AgentFilesystemTreeWatchOptions
): Promise<{ ok: true }> {
const searchSpaceId = normalizeSearchSpaceId(options.searchSpaceId);
const workspaceId = normalizeWorkspaceId(options.workspaceId);
const rootPaths = Array.from(
new Set(normalizeList(options.rootPaths).map((rootPath) => normalizeRootPath(rootPath)))
);
const excludePatterns = Array.from(new Set(normalizeList(options.excludePatterns)));
const fileExtensions = normalizeExtensions(options.fileExtensions);
const sessionKey = getSessionKey(searchSpaceId);
const sessionKey = getSessionKey(workspaceId);
if (rootPaths.length === 0) {
await stopAgentFilesystemTreeWatch(searchSpaceId);
await stopAgentFilesystemTreeWatch(workspaceId);
return { ok: true };
}
const optionsSignature = buildOptionsSignature(
searchSpaceId,
workspaceId,
rootPaths,
excludePatterns,
fileExtensions
@ -228,7 +228,7 @@ export async function startAgentFilesystemTreeWatch(
);
const session: WatchSession = {
searchSpaceId,
workspaceId,
optionsSignature,
rootPaths,
excludePatterns,
@ -291,9 +291,9 @@ export async function startAgentFilesystemTreeWatch(
}
export async function stopAgentFilesystemTreeWatch(
searchSpaceId?: number | null
workspaceId?: number | null
): Promise<{ ok: true }> {
const sessionKey = getSessionKey(searchSpaceId);
const sessionKey = getSessionKey(workspaceId);
const session = sessions.get(sessionKey);
if (!session) return { ok: true };
sessions.delete(sessionKey);

View file

@ -119,9 +119,9 @@ async function normalizeLocalRootPathsCanonical(paths: unknown): Promise<string[
return [...uniquePaths];
}
function normalizeSearchSpaceKey(searchSpaceId?: number | null): string {
if (typeof searchSpaceId === "number" && Number.isFinite(searchSpaceId) && searchSpaceId > 0) {
return String(searchSpaceId);
function normalizeWorkspaceKey(workspaceId?: number | null): string {
if (typeof workspaceId === "number" && Number.isFinite(workspaceId) && workspaceId > 0) {
return String(workspaceId);
}
return DEFAULT_SPACE_KEY;
}
@ -147,9 +147,9 @@ function getDefaultStore(): AgentFilesystemSettingsStore {
function getSettingsFromStore(
store: AgentFilesystemSettingsStore,
searchSpaceId?: number | null
workspaceId?: number | null
): AgentFilesystemSettings {
const key = normalizeSearchSpaceKey(searchSpaceId);
const key = normalizeWorkspaceKey(workspaceId);
return store.spaces[key] ?? getDefaultSettings();
}
@ -194,22 +194,22 @@ async function loadAgentFilesystemSettingsStore(): Promise<AgentFilesystemSettin
}
export async function getAgentFilesystemSettings(
searchSpaceId?: number | null
workspaceId?: number | null
): Promise<AgentFilesystemSettings> {
const store = await loadAgentFilesystemSettingsStore();
return getSettingsFromStore(store, searchSpaceId);
return getSettingsFromStore(store, workspaceId);
}
export async function setAgentFilesystemSettings(
searchSpaceId: number | null | undefined,
workspaceId: number | null | undefined,
settings: {
mode?: AgentFilesystemMode;
localRootPaths?: string[] | null;
}
): Promise<AgentFilesystemSettings> {
const store = await loadAgentFilesystemSettingsStore();
const key = normalizeSearchSpaceKey(searchSpaceId);
const current = getSettingsFromStore(store, searchSpaceId);
const key = normalizeWorkspaceKey(workspaceId);
const current = getSettingsFromStore(store, workspaceId);
const nextMode =
settings.mode === "cloud" || settings.mode === "desktop_local_folder"
? settings.mode
@ -291,7 +291,7 @@ export type LocalRootMount = {
export type AgentFilesystemListOptions = {
rootPath: string;
searchSpaceId?: number | null;
workspaceId?: number | null;
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
};
@ -332,9 +332,9 @@ function buildRootMounts(rootPaths: string[]): LocalRootMount[] {
}
export async function getAgentFilesystemMounts(
searchSpaceId?: number | null
workspaceId?: number | null
): Promise<LocalRootMount[]> {
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
const rootPaths = await resolveCurrentRootPaths(workspaceId);
return buildRootMounts(rootPaths);
}
@ -371,7 +371,7 @@ function normalizeExcludeSet(excludePatterns: string[] | null | undefined): Set<
export async function listAgentFilesystemFiles(
options: AgentFilesystemListOptions
): Promise<AgentFilesystemFileEntry[]> {
const allowedRootPaths = await resolveCurrentRootPaths(options.searchSpaceId);
const allowedRootPaths = await resolveCurrentRootPaths(options.workspaceId);
const requestedRootPath = await canonicalizeRootPath(options.rootPath);
const normalizedRequestedRoot = normalizeComparablePath(requestedRootPath);
const allowedRoots = new Set(
@ -474,8 +474,8 @@ function toMountedVirtualPath(mount: string, rootPath: string, absolutePath: str
return `/${mount}${relativePath}`;
}
async function resolveCurrentRootPaths(searchSpaceId?: number | null): Promise<string[]> {
const settings = await getAgentFilesystemSettings(searchSpaceId);
async function resolveCurrentRootPaths(workspaceId?: number | null): Promise<string[]> {
const settings = await getAgentFilesystemSettings(workspaceId);
if (settings.localRootPaths.length === 0) {
throw new Error("No local filesystem roots selected");
}
@ -484,9 +484,9 @@ async function resolveCurrentRootPaths(searchSpaceId?: number | null): Promise<s
export async function readAgentLocalFileText(
virtualPath: string,
searchSpaceId?: number | null
workspaceId?: number | null
): Promise<{ path: string; content: string }> {
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
const rootPaths = await resolveCurrentRootPaths(workspaceId);
const mounts = buildRootMounts(rootPaths);
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
const rootMount = findMountByName(mounts, mount);
@ -507,9 +507,9 @@ export async function readAgentLocalFileText(
export async function writeAgentLocalFileText(
virtualPath: string,
content: string,
searchSpaceId?: number | null
workspaceId?: number | null
): Promise<{ path: string }> {
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
const rootPaths = await resolveCurrentRootPaths(workspaceId);
const mounts = buildRootMounts(rootPaths);
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
const rootMount = findMountByName(mounts, mount);

View file

@ -5,6 +5,7 @@ import * as path from 'path';
import * as fs from 'fs';
import { IPC_CHANNELS } from '../ipc/channels';
import { trackEvent } from './analytics';
import { migrateWatchedFolderConfigs } from './migrate-watched-folders';
export interface WatchedFolderConfig {
path: string;
@ -12,7 +13,7 @@ export interface WatchedFolderConfig {
excludePatterns: string[];
fileExtensions: string[] | null;
rootFolderId: number | null;
searchSpaceId: number;
workspaceId: number;
active: boolean;
}
@ -27,7 +28,7 @@ type FolderSyncAction = 'add' | 'change' | 'unlink';
export interface FolderSyncFileChangedEvent {
id: string;
rootFolderId: number | null;
searchSpaceId: number;
workspaceId: number;
folderPath: string;
folderName: string;
relativePath: string;
@ -68,6 +69,12 @@ async function getStore() {
[STORE_KEY]: [] as WatchedFolderConfig[],
},
});
// One-time read-migration: legacy persisted configs stored the workspace as
// `searchSpaceId`. Map it to `workspaceId` and write back once so existing
// watched folders keep their sync target after the rename.
const raw = store.get(STORE_KEY, []) as Array<Record<string, unknown>>;
const { configs, migrated } = migrateWatchedFolderConfigs<WatchedFolderConfig>(raw);
if (migrated) store.set(STORE_KEY, configs);
}
return store;
}
@ -267,7 +274,7 @@ async function startWatcher(config: WatchedFolderConfig) {
if (storedMtime === undefined) {
sendFileChangedEvent({
rootFolderId: config.rootFolderId,
searchSpaceId: config.searchSpaceId,
workspaceId: config.workspaceId,
folderPath: config.path,
folderName: config.name,
relativePath: rel,
@ -278,7 +285,7 @@ async function startWatcher(config: WatchedFolderConfig) {
} else if (Math.abs(currentMtime - storedMtime) >= MTIME_TOLERANCE_S * 1000) {
sendFileChangedEvent({
rootFolderId: config.rootFolderId,
searchSpaceId: config.searchSpaceId,
workspaceId: config.workspaceId,
folderPath: config.path,
folderName: config.name,
relativePath: rel,
@ -295,7 +302,7 @@ async function startWatcher(config: WatchedFolderConfig) {
if (!(rel in currentMap)) {
sendFileChangedEvent({
rootFolderId: config.rootFolderId,
searchSpaceId: config.searchSpaceId,
workspaceId: config.workspaceId,
folderPath: config.path,
folderName: config.name,
relativePath: rel,
@ -346,7 +353,7 @@ async function startWatcher(config: WatchedFolderConfig) {
sendFileChangedEvent({
rootFolderId: config.rootFolderId,
searchSpaceId: config.searchSpaceId,
workspaceId: config.workspaceId,
folderPath: config.path,
folderName: config.name,
relativePath,
@ -403,7 +410,7 @@ export async function addWatchedFolder(
}
trackEvent('desktop_folder_watch_added', {
search_space_id: config.searchSpaceId,
workspace_id: config.workspaceId,
root_folder_id: config.rootFolderId,
active: config.active,
has_exclude_patterns: (config.excludePatterns?.length ?? 0) > 0,
@ -431,7 +438,7 @@ export async function removeWatchedFolder(
if (removed) {
trackEvent('desktop_folder_watch_removed', {
search_space_id: removed.searchSpaceId,
workspace_id: removed.workspaceId,
root_folder_id: removed.rootFolderId,
});
}

View file

@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { migrateWatchedFolderConfigs } from "./migrate-watched-folders.ts";
// Run with: node --test src/modules/migrate-watched-folders.test.ts
test("maps legacy searchSpaceId to workspaceId and flags migration", () => {
const { configs, migrated } = migrateWatchedFolderConfigs<{ workspaceId?: number }>([
{ path: "/tmp/a", searchSpaceId: 42 },
]);
assert.equal(migrated, true);
assert.equal(configs[0].workspaceId, 42);
assert.equal("searchSpaceId" in (configs[0] as object), false);
});
test("leaves configs that already have workspaceId untouched", () => {
const { configs, migrated } = migrateWatchedFolderConfigs<{ workspaceId?: number }>([
{ path: "/tmp/b", workspaceId: 5 },
]);
assert.equal(migrated, false);
assert.equal(configs[0].workspaceId, 5);
});

View file

@ -0,0 +1,23 @@
/**
* One-time read-migration for persisted watched-folder configs: legacy configs
* stored the workspace as `searchSpaceId`. Map it to `workspaceId` so existing
* watched folders keep their sync target after the rename. Pure + dependency-free
* so it can be unit-checked without loading electron-store.
*
* Returns the migrated configs and whether anything changed (so callers can
* write back only when needed).
*/
export function migrateWatchedFolderConfigs<T>(
raw: Array<Record<string, unknown>>
): { configs: T[]; migrated: boolean } {
let migrated = false;
const configs = raw.map((c) => {
if (c.workspaceId === undefined && c.searchSpaceId !== undefined) {
migrated = true;
const { searchSpaceId, ...rest } = c;
return { ...rest, workspaceId: searchSpaceId } as unknown as T;
}
return c as unknown as T;
});
return { configs, migrated };
}

View file

@ -4,14 +4,14 @@ import { IPC_CHANNELS } from '../ipc/channels';
import { checkAccessibilityPermission, getFrontmostApp, simulateCopy, simulatePaste } from './platform';
import { getServerOrigin } from './server';
import { getShortcuts } from './shortcuts';
import { getActiveSearchSpaceId } from './active-search-space';
import { getActiveWorkspaceId } from './active-workspace';
import { trackEvent } from './analytics';
let currentShortcut = '';
let quickAskWindow: BrowserWindow | null = null;
let pendingText = '';
let pendingMode = '';
let pendingSearchSpaceId: string | null = null;
let pendingWorkspaceId: string | null = null;
let sourceApp = '';
let savedClipboard = '';
@ -57,7 +57,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
skipTaskbar: true,
});
const spaceId = pendingSearchSpaceId;
const spaceId = pendingWorkspaceId;
const route = spaceId ? `/dashboard/${spaceId}/new-chat` : '/dashboard';
quickAskWindow.loadURL(`${getServerOrigin()}${route}?quickAssist=true`);
@ -87,7 +87,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
async function openQuickAsk(text: string): Promise<void> {
pendingText = text;
pendingMode = 'quick-assist';
pendingSearchSpaceId = await getActiveSearchSpaceId();
pendingWorkspaceId = await getActiveWorkspaceId();
const cursor = screen.getCursorScreenPoint();
const pos = clampToScreen(cursor.x, cursor.y, 450, 750);
createQuickAskWindow(pos.x, pos.y);

View file

@ -3,7 +3,7 @@ import path from 'path';
import { trackEvent } from './analytics';
import { showErrorDialog } from './errors';
import { getServerOrigin, getServerPort } from './server';
import { setActiveSearchSpaceId } from './active-search-space';
import { setActiveWorkspaceId } from './active-workspace';
const isDev = !app.isPackaged;
const isMac = process.platform === 'darwin';
@ -140,14 +140,14 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow {
});
// Auto-sync active search space from URL navigation
const syncSearchSpace = (url: string) => {
const syncWorkspace = (url: string) => {
const match = url.match(/\/dashboard\/(\d+)/);
if (match) {
setActiveSearchSpaceId(match[1]);
setActiveWorkspaceId(match[1]);
}
};
mainWindow.webContents.on('did-navigate', (_event, url) => syncSearchSpace(url));
mainWindow.webContents.on('did-navigate-in-page', (_event, url) => syncSearchSpace(url));
mainWindow.webContents.on('did-navigate', (_event, url) => syncWorkspace(url));
mainWindow.webContents.on('did-navigate-in-page', (_event, url) => syncWorkspace(url));
if (isDev) {
mainWindow.webContents.openDevTools();

View file

@ -74,10 +74,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Browse files via native dialog
browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES),
readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths),
readAgentLocalFileText: (virtualPath: string, searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath, searchSpaceId),
writeAgentLocalFileText: (virtualPath: string, content: string, searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content, searchSpaceId),
readAgentLocalFileText: (virtualPath: string, workspaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath, workspaceId),
writeAgentLocalFileText: (virtualPath: string, content: string, workspaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content, workspaceId),
// Auth token sync across windows
getAccessToken: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACCESS_TOKEN),
@ -104,9 +104,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke(IPC_CHANNELS.SET_AUTO_LAUNCH, { enabled, openAsHidden }),
// Active search space
getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE),
setActiveSearchSpace: (id: string) =>
ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, id),
getActiveWorkspace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_WORKSPACE),
setActiveWorkspace: (id: string) =>
ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_WORKSPACE, id),
// Analytics bridge — lets posthog-js running inside the Next.js renderer
// mirror identify/reset/capture into the Electron main-process PostHog
@ -118,27 +118,27 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }),
getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT),
// Agent filesystem mode
getAgentFilesystemSettings: (searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, searchSpaceId),
getAgentFilesystemMounts: (searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, searchSpaceId),
getAgentFilesystemSettings: (workspaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, workspaceId),
getAgentFilesystemMounts: (workspaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, workspaceId),
listAgentFilesystemFiles: (options: {
rootPath: string;
searchSpaceId?: number | null;
workspaceId?: number | null;
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_LIST_FILES, options),
startAgentFilesystemTreeWatch: (options: {
searchSpaceId?: number | null;
workspaceId?: number | null;
rootPaths: string[];
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_START, options),
stopAgentFilesystemTreeWatch: (searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP, searchSpaceId),
stopAgentFilesystemTreeWatch: (workspaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP, workspaceId),
onAgentFilesystemTreeDirty: (
callback: (data: {
searchSpaceId: number | null;
workspaceId: number | null;
reason: 'watcher_event' | 'safety_poll';
rootPath: string;
changedPath: string | null;
@ -148,7 +148,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
const listener = (
_event: unknown,
data: {
searchSpaceId: number | null;
workspaceId: number | null;
reason: 'watcher_event' | 'safety_poll';
rootPath: string;
changedPath: string | null;
@ -163,7 +163,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
setAgentFilesystemSettings: (settings: {
mode?: "cloud" | "desktop_local_folder";
localRootPaths?: string[] | null;
}, searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, { searchSpaceId, settings }),
}, workspaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, { workspaceId, settings }),
pickAgentFilesystemRoot: () => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT),
});

View file

@ -12,5 +12,5 @@
"noEmit": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "scripts"]
"exclude": ["node_modules", "dist", "scripts", "src/**/*.test.ts"]
}

View file

@ -52,7 +52,7 @@ Open **Settings → SurfSense** in Obsidian and fill in:
| --- | --- |
| Server URL | `https://surfsense.com` for SurfSense Cloud, or your self-hosted URL |
| API token | Create a personal access token from the *Connectors → Obsidian* dialog or *User settings → API access* in the SurfSense web app |
| Search space | Pick the search space this vault should sync into |
| Search space | Pick the workspace this vault should sync into |
| Vault name | Defaults to your Obsidian vault name; rename if you have multiple vaults |
| Sync mode | *Auto* (recommended) or *Manual* |
| Exclude patterns | Glob patterns of folders/files to skip (e.g. `.trash`, `_attachments`, `templates/**`) |
@ -82,7 +82,7 @@ addendum.
- `vault_id`: a random UUID minted in the plugin's `data.json` on first run
- `vault_name`: the Obsidian vault folder name
- `search_space_id`: the SurfSense search space you picked
- `workspace_id`: the SurfSense workspace you picked
**Sent per note on `/sync`, `/rename`, `/delete`:**

3153
surfsense_obsidian/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
allowBuilds:
esbuild: set this to true or false

View file

@ -7,7 +7,7 @@ import type {
NotePayload,
RenameAck,
RenameItem,
SearchSpace,
Workspace,
SyncAck,
} from "./types";
@ -94,14 +94,14 @@ export class SurfSenseApiClient {
return await this.request<HealthResponse>("GET", "/api/v1/obsidian/health");
}
async listSearchSpaces(): Promise<SearchSpace[]> {
const resp = await this.request<SearchSpace[] | { items: SearchSpace[] }>(
async listWorkspaces(): Promise<Workspace[]> {
const resp = await this.request<Workspace[] | { items: Workspace[] }>(
"GET",
"/api/v1/searchspaces/"
"/api/v1/workspaces/"
);
if (Array.isArray(resp)) return resp;
if (resp && Array.isArray((resp as { items?: SearchSpace[] }).items)) {
return (resp as { items: SearchSpace[] }).items;
if (resp && Array.isArray((resp as { items?: Workspace[] }).items)) {
return (resp as { items: Workspace[] }).items;
}
return [];
}
@ -114,7 +114,7 @@ export class SurfSenseApiClient {
}
async connect(input: {
searchSpaceId: number;
workspaceId: number;
vaultId: string;
vaultName: string;
vaultFingerprint: string;
@ -125,7 +125,7 @@ export class SurfSenseApiClient {
{
vault_id: input.vaultId,
vault_name: input.vaultName,
search_space_id: input.searchSpaceId,
workspace_id: input.workspaceId,
vault_fingerprint: input.vaultFingerprint,
}
);

View file

@ -119,7 +119,7 @@ export default class SurfSensePlugin extends Plugin {
name: "Sync current note",
checkCallback: (checking) => {
const file = this.app.workspace.getActiveFile();
if (!file || file.extension.toLowerCase() !== "md") return false;
if (file?.extension.toLowerCase() !== "md") return false;
if (checking) return true;
this.queue.enqueueUpsert(file.path);
void this.engine.flushQueue();
@ -186,7 +186,7 @@ export default class SurfSensePlugin extends Plugin {
if (!changed) return;
this.engine?.refreshStatus();
this.notifyStatusChange();
if (this.settings.searchSpaceId !== null) {
if (this.settings.workspaceId !== null) {
void this.engine.ensureConnected();
}
}
@ -252,16 +252,24 @@ export default class SurfSensePlugin extends Plugin {
}
async loadSettings() {
const data = (await this.loadData()) as Partial<SurfsensePluginSettings> | null;
// One-time migration: the workspace was previously persisted as `searchSpaceId`.
// Destructure it out so the migrated value moves into `workspaceId` and the
// dead key is not spread back in / re-persisted.
const data = (await this.loadData()) as
| (Partial<SurfsensePluginSettings> & { searchSpaceId?: number | null })
| null;
const { searchSpaceId: legacyWorkspaceId, ...persisted } = data ?? {};
this.settings = {
...DEFAULT_SETTINGS,
...(data ?? {}),
queue: (data?.queue ?? []).map((i: QueueItem) => ({ ...i })),
tombstones: { ...(data?.tombstones ?? {}) },
includeFolders: [...(data?.includeFolders ?? [])],
excludeFolders: [...(data?.excludeFolders ?? [])],
excludePatterns: data?.excludePatterns?.length
? [...data.excludePatterns]
...persisted,
workspaceId:
persisted.workspaceId ?? legacyWorkspaceId ?? DEFAULT_SETTINGS.workspaceId,
queue: (persisted.queue ?? []).map((i: QueueItem) => ({ ...i })),
tombstones: { ...(persisted.tombstones ?? {}) },
includeFolders: [...(persisted.includeFolders ?? [])],
excludeFolders: [...(persisted.excludeFolders ?? [])],
excludePatterns: persisted.excludePatterns?.length
? [...persisted.excludePatterns]
: [...DEFAULT_SETTINGS.excludePatterns],
};
}

View file

@ -13,13 +13,13 @@ import { normalizeFolder, parseExcludePatterns } from "./excludes";
import { FolderSuggestModal } from "./folder-suggest-modal";
import type SurfSensePlugin from "./main";
import { STATUS_VISUALS } from "./status-visuals";
import type { SearchSpace } from "./types";
import type { Workspace } from "./types";
/** Plugin settings tab. */
export class SurfSenseSettingTab extends PluginSettingTab {
private readonly plugin: SurfSensePlugin;
private searchSpaces: SearchSpace[] = [];
private workspaces: Workspace[] = [];
private loadingSpaces = false;
private connectionIndicator: HTMLElement | null = null;
private readonly onStatusChange = (): void => this.updateConnectionIndicator();
@ -51,7 +51,7 @@ export class SurfSenseSettingTab extends PluginSettingTab {
const next = value.trim();
const previous = this.plugin.settings.serverUrl;
if (previous !== "" && next !== previous) {
this.plugin.settings.searchSpaceId = null;
this.plugin.settings.workspaceId = null;
this.plugin.settings.connectorId = null;
}
this.plugin.settings.serverUrl = next;
@ -80,7 +80,7 @@ export class SurfSenseSettingTab extends PluginSettingTab {
const next = value.trim();
const previous = this.plugin.settings.apiToken;
if (previous !== "" && next !== previous) {
this.plugin.settings.searchSpaceId = null;
this.plugin.settings.workspaceId = null;
this.plugin.settings.connectorId = null;
}
this.plugin.settings.apiToken = next;
@ -102,7 +102,7 @@ export class SurfSenseSettingTab extends PluginSettingTab {
await this.plugin.api.verifyToken();
new Notice("Surfsense: token verified.");
this.plugin.engine.refreshStatus({ force: true });
await this.refreshSearchSpaces();
await this.refreshWorkspaces();
this.display();
} catch (err) {
this.handleApiError(err);
@ -115,21 +115,21 @@ export class SurfSenseSettingTab extends PluginSettingTab {
new Setting(containerEl)
.setName("Search space")
.setDesc(
"Which Surfsense search space this vault syncs into. Reload after changing your token.",
"Which Surfsense workspace this vault syncs into. Reload after changing your token.",
)
.addDropdown((drop) => {
drop.addOption("", this.loadingSpaces ? "Loading…" : "Select a search space");
for (const space of this.searchSpaces) {
drop.addOption("", this.loadingSpaces ? "Loading…" : "Select a workspace");
for (const space of this.workspaces) {
drop.addOption(String(space.id), space.name);
}
if (settings.searchSpaceId !== null) {
drop.setValue(String(settings.searchSpaceId));
if (settings.workspaceId !== null) {
drop.setValue(String(settings.workspaceId));
}
drop.onChange(async (value) => {
this.plugin.settings.searchSpaceId = value ? Number(value) : null;
this.plugin.settings.workspaceId = value ? Number(value) : null;
this.plugin.settings.connectorId = null;
await this.plugin.saveSettings();
if (this.plugin.settings.searchSpaceId !== null) {
if (this.plugin.settings.workspaceId !== null) {
try {
await this.plugin.engine.ensureConnected();
await this.plugin.engine.maybeReconcile(true);
@ -144,9 +144,9 @@ export class SurfSenseSettingTab extends PluginSettingTab {
.addExtraButton((btn) =>
btn
.setIcon("refresh-ccw")
.setTooltip("Reload search spaces")
.setTooltip("Reload workspaces")
.onClick(async () => {
await this.refreshSearchSpaces();
await this.refreshWorkspaces();
this.display();
}),
);
@ -319,13 +319,13 @@ export class SurfSenseSettingTab extends PluginSettingTab {
indicator.setAttr("title", visual.label);
}
private async refreshSearchSpaces(): Promise<void> {
private async refreshWorkspaces(): Promise<void> {
this.loadingSpaces = true;
try {
this.searchSpaces = await this.plugin.api.listSearchSpaces();
this.workspaces = await this.plugin.api.listWorkspaces();
} catch (err) {
this.handleApiError(err);
this.searchSpaces = [];
this.workspaces = [];
} finally {
this.loadingSpaces = false;
}

View file

@ -48,7 +48,7 @@ export interface SyncEngineSettings {
vaultId: string;
apiToken: string;
connectorId: number | null;
searchSpaceId: number | null;
workspaceId: number | null;
includeFolders: string[];
excludeFolders: string[];
excludePatterns: string[];
@ -96,7 +96,7 @@ export class SyncEngine {
this.setStatus("syncing", "Connecting to SurfSense…");
const settings = this.deps.getSettings();
if (!settings.searchSpaceId) {
if (!settings.workspaceId) {
// No target yet — /health still surfaces auth/network errors.
try {
const health = await this.deps.apiClient.health();
@ -124,7 +124,7 @@ export class SyncEngine {
*/
async ensureConnected(): Promise<boolean> {
const settings = this.deps.getSettings();
if (!settings.searchSpaceId) {
if (!settings.workspaceId) {
this.setStatus("idle");
return false;
}
@ -132,7 +132,7 @@ export class SyncEngine {
try {
const fingerprint = await computeVaultFingerprint(this.deps.app);
const resp = await this.deps.apiClient.connect({
searchSpaceId: settings.searchSpaceId,
workspaceId: settings.workspaceId,
vaultId: settings.vaultId,
vaultName: this.deps.app.vault.getName(),
vaultFingerprint: fingerprint,
@ -272,7 +272,7 @@ export class SyncEngine {
this.refreshStatus({ force: true });
return;
}
if (!settings.searchSpaceId) {
if (!settings.workspaceId) {
try {
const health = await this.deps.apiClient.health();
this.applyHealth(health);
@ -543,7 +543,7 @@ export class SyncEngine {
const isError =
last === "auth-error" || last === "offline" || last === "error";
const s = this.deps.getSettings();
const setupComplete = !!(s.apiToken && s.searchSpaceId && s.connectorId);
const setupComplete = !!(s.apiToken && s.workspaceId && s.connectorId);
if (isError && setupComplete) return;
}
this.setStatus(this.queueStatusKind(), this.statusDetail());
@ -571,7 +571,7 @@ export class SyncEngine {
kind = "needs-setup";
detail = this.setupHint(s);
} else if (kind !== "auth-error" && kind !== "offline" && kind !== "error") {
if (!s.searchSpaceId || !s.connectorId) {
if (!s.workspaceId || !s.connectorId) {
kind = "needs-setup";
detail = this.setupHint(s);
}
@ -582,7 +582,7 @@ export class SyncEngine {
private setupHint(s: SyncEngineSettings): string {
if (!s.apiToken) return "Paste your API token in settings.";
if (!s.searchSpaceId) return "Pick a search space in settings.";
if (!s.workspaceId) return "Pick a workspace in settings.";
return "Connecting…";
}

View file

@ -3,7 +3,7 @@
export interface SurfsensePluginSettings {
serverUrl: string;
apiToken: string;
searchSpaceId: number | null;
workspaceId: number | null;
connectorId: number | null;
/** UUID for the vault — lives here so Obsidian Sync replicates it across devices. */
vaultId: string;
@ -25,7 +25,7 @@ export interface SurfsensePluginSettings {
export const DEFAULT_SETTINGS: SurfsensePluginSettings = {
serverUrl: "https://surfsense.com",
apiToken: "",
searchSpaceId: null,
workspaceId: null,
connectorId: null,
vaultId: "",
syncIntervalMinutes: 10,
@ -107,7 +107,7 @@ export interface HeadingRef {
level: number;
}
export interface SearchSpace {
export interface Workspace {
id: number;
name: string;
description?: string;
@ -117,7 +117,7 @@ export interface SearchSpace {
export interface ConnectResponse {
connector_id: number;
vault_id: string;
search_space_id: number;
workspace_id: number;
capabilities: string[];
server_time_utc: string;
[key: string]: unknown;

View file

@ -45,7 +45,7 @@ export function AutomationDetailContent({
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
You don't have permission to view automations in this search space.
You don't have permission to view automations in this workspace.
</p>
</div>
);

View file

@ -32,7 +32,7 @@ export function AutomationEditContent({ workspaceId, automationId }: AutomationE
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
You don't have permission to edit automations in this search space.
You don't have permission to edit automations in this workspace.
</p>
</div>
);

View file

@ -13,7 +13,7 @@ interface AutomationsContentProps {
/**
* Client orchestrator for the automations list page. Pulls the active
* search space's first page (via ``useAutomations`` ``automationsListAtom``)
* workspace's first page (via ``useAutomations`` ``automationsListAtom``)
* and the user's permissions, then decides between empty / loading / table.
*
* Read access is mandatory; anything else is hidden behind RBAC. The
@ -46,7 +46,7 @@ export function AutomationsContent({ workspaceId }: AutomationsContentProps) {
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
You don't have permission to view automations in this search space.
You don't have permission to view automations in this workspace.
</p>
</div>
);

View file

@ -40,7 +40,7 @@ export function AutomationsEmptyState({ workspaceId, canCreate }: AutomationsEmp
</div>
) : (
<p className="mt-6 text-xs text-muted-foreground">
You don't have permission to create automations in this search space.
You don't have permission to create automations in this workspace.
</p>
)}
</div>

View file

@ -120,7 +120,7 @@ export function AutomationBuilderForm({
const [submitting, setSubmitting] = useState(false);
// Eligible models + the search-space-seeded defaults. Models are chosen per
// Eligible models + the workspace-seeded defaults. Models are chosen per
// automation on create; in edit mode the backend preserves the captured
// snapshot, so the picker is create-only.
const eligibleModels = useAutomationEligibleModels();

View file

@ -73,6 +73,9 @@ function toChipInput(mention: MentionedDocumentInfo): MentionChipInput {
if (mention.kind === "folder") {
return { id: mention.id, title: mention.title, kind: "folder" };
}
if (mention.kind === "thread") {
return { id: mention.id, title: mention.title, kind: "thread" };
}
return {
id: mention.id,
title: mention.title,

View file

@ -47,7 +47,7 @@ export function DeleteAutomationDialog({
async function handleConfirm() {
setSubmitting(true);
try {
await deleteAutomation({ automationId, workspaceId });
await deleteAutomation({ automationId, workspaceId: workspaceId });
onDeleted?.();
onOpenChange(false);
} finally {

View file

@ -31,7 +31,7 @@ export function AutomationNewContent({ workspaceId }: AutomationNewContentProps)
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
You don't have permission to create automations in this search space.
You don't have permission to create automations in this workspace.
</p>
</div>
);

View file

@ -0,0 +1,11 @@
import { AllChatsWorkspaceContent } from "@/components/layout/ui/sidebar";
export default async function ChatsPage({ params }: { params: Promise<{ workspace_id: string }> }) {
const { workspace_id } = await params;
return (
<div className="w-full select-none">
<AllChatsWorkspaceContent workspaceId={workspace_id} />
</div>
);
}

View file

@ -52,12 +52,12 @@ export function DashboardClientLayout({
const isOnboardingPage = pathname?.includes("/onboard");
const isOwner = access?.is_owner ?? false;
const isSearchSpaceReady = activeWorkspaceId === workspaceId;
const isWorkspaceReady = activeWorkspaceId === workspaceId;
useEffect(() => {
if (isSearchSpaceReady) return;
if (isWorkspaceReady) return;
setHasCheckedOnboarding(false);
}, [isSearchSpaceReady]);
}, [isWorkspaceReady]);
useEffect(() => {
if (isOnboardingPage) {
@ -66,7 +66,7 @@ export function DashboardClientLayout({
}
if (
isSearchSpaceReady &&
isWorkspaceReady &&
!loading &&
!accessLoading &&
!globalConfigsLoading &&
@ -75,7 +75,7 @@ export function DashboardClientLayout({
!hasCheckedOnboarding
) {
// Onboarding is only relevant when no operator-provided
// global_llm_config.yaml exists. When it does, search spaces inherit
// 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);
@ -102,7 +102,7 @@ export function DashboardClientLayout({
setHasCheckedOnboarding(true);
}
}, [
isSearchSpaceReady,
isWorkspaceReady,
loading,
accessLoading,
globalConfigsLoading,
@ -153,13 +153,13 @@ export function DashboardClientLayout({
setActiveWorkspaceIdState(activeSeacrhSpaceId);
// Sync to Electron store if stored value is null (first navigation)
if (electronAPI?.getActiveSearchSpace && electronAPI.setActiveSearchSpace) {
const setActiveSearchSpace = electronAPI.setActiveSearchSpace;
if (electronAPI?.getActiveWorkspace && electronAPI.setActiveWorkspace) {
const setActiveWorkspace = electronAPI.setActiveWorkspace;
electronAPI
.getActiveSearchSpace()
.getActiveWorkspace()
.then((stored: string | null) => {
if (!stored) {
setActiveSearchSpace(activeSeacrhSpaceId);
setActiveWorkspace(activeSeacrhSpaceId);
}
})
.catch(() => {});
@ -169,7 +169,7 @@ export function DashboardClientLayout({
// Determine if we should show loading
const shouldShowLoading =
!hasCheckedOnboarding &&
(!isSearchSpaceReady ||
(!isWorkspaceReady ||
loading ||
accessLoading ||
globalConfigsLoading ||

View file

@ -579,7 +579,7 @@ export default function NewChatPage() {
error,
flow,
context: {
workspaceId,
workspaceId: workspaceId,
threadId,
},
});
@ -717,7 +717,7 @@ export default function NewChatPage() {
data: typeof threadMessagesQuery.data;
}>({ threadId: null, data: undefined });
// Reset thread-local runtime state on route/search-space changes. Data fetching
// Reset thread-local runtime state on route/workspace changes. Data fetching
// is handled by React Query below so the chat shell can render immediately.
useEffect(() => {
const nextThreadId = urlChatId > 0 ? urlChatId : null;

View file

@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
export default async function SearchSpaceDashboardPage({
export default async function WorkspaceDashboardPage({
params,
}: {
params: Promise<{ workspace_id: string }>;

View file

@ -96,7 +96,7 @@ import type { Role } from "@/contracts/types/roles.types";
import { invitesApiService } from "@/lib/apis/invites-api.service";
import { rolesApiService } from "@/lib/apis/roles-api.service";
import { formatRelativeDate } from "@/lib/format-date";
import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events";
import { trackWorkspaceInviteSent, trackWorkspaceUsersViewed } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
@ -229,7 +229,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
useEffect(() => {
if (members.length > 0 && !membersLoading) {
const ownerCount = members.filter((m) => m.is_owner).length;
trackSearchSpaceUsersViewed(workspaceId, members.length, ownerCount);
trackWorkspaceUsersViewed(workspaceId, members.length, ownerCount);
}
}, [members, membersLoading, workspaceId]);
@ -581,7 +581,7 @@ function MemberRow({
<AlertDialogTitle>Remove member?</AlertDialogTitle>
<AlertDialogDescription>
This will remove <span className="font-medium">{member.user_email}</span>{" "}
from this search space. They will lose access to all resources.
from this workspace. They will lose access to all resources.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@ -654,7 +654,7 @@ function CreateInviteDialog({
setCreatedInvite(invite);
const roleName = roleId ? roles.find((r) => r.id.toString() === roleId)?.name : undefined;
trackSearchSpaceInviteSent(workspaceId, {
trackWorkspaceInviteSent(workspaceId, {
roleName,
hasExpiry: !!expiresAt,
hasMaxUses: !!maxUses,
@ -709,7 +709,7 @@ function CreateInviteDialog({
Invite Created!
</DialogTitle>
<DialogDescription>
Share this link to invite people to your search space.
Share this link to invite people to your workspace.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2 md:py-4">
@ -748,7 +748,7 @@ function CreateInviteDialog({
<DialogHeader>
<DialogTitle>Invite Members</DialogTitle>
<DialogDescription>
Create a link to invite people to this search space.
Create a link to invite people to this workspace.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2 md:py-4">

View file

@ -1,5 +0,0 @@
import { AgentStatusContent } from "../components/AgentStatusContent";
export default function Page() {
return <AgentStatusContent />;
}

View file

@ -226,9 +226,7 @@ export function AgentPermissionsContent() {
}
if (!workspaceId) {
return (
<p className="text-sm text-muted-foreground">Open a search space to manage agent rules.</p>
);
return <p className="text-sm text-muted-foreground">Open a workspace to manage agent rules.</p>;
}
return (

View file

@ -1,321 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertTriangle, CircleCheck, CircleSlash, Info } from "lucide-react";
import { Fragment, useMemo } from "react";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import type { AgentFeatureFlags } from "@/lib/apis/agent-flags-api.service";
import { cn } from "@/lib/utils";
type FlagKey = keyof AgentFeatureFlags;
interface FlagDef {
key: FlagKey;
label: string;
description: string;
envVar: string;
}
interface FlagGroup {
id: string;
title: string;
subtitle: string;
flags: FlagDef[];
}
const FLAG_GROUPS: FlagGroup[] = [
{
id: "tier1",
title: "Tier 1 — Agent quality",
subtitle: "Context editing, retries, fallbacks, doom-loop, tool-call repair.",
flags: [
{
key: "enable_context_editing",
label: "Context editing",
description: "Trim tool outputs and spill old text into backend storage.",
envVar: "SURFSENSE_ENABLE_CONTEXT_EDITING",
},
{
key: "enable_compaction_v2",
label: "Compaction v2",
description: "SurfSense-aware compaction replacing safe summarization.",
envVar: "SURFSENSE_ENABLE_COMPACTION_V2",
},
{
key: "enable_retry_after",
label: "Retry-After",
description: "Honour rate-limit retry-after headers automatically.",
envVar: "SURFSENSE_ENABLE_RETRY_AFTER",
},
{
key: "enable_model_fallback",
label: "Model fallback",
description: "Fail over to a backup model on persistent errors.",
envVar: "SURFSENSE_ENABLE_MODEL_FALLBACK",
},
{
key: "enable_model_call_limit",
label: "Model call limit",
description: "Cap total model calls per turn to prevent budget run-aways.",
envVar: "SURFSENSE_ENABLE_MODEL_CALL_LIMIT",
},
{
key: "enable_tool_call_limit",
label: "Tool call limit",
description: "Cap total tool calls per turn.",
envVar: "SURFSENSE_ENABLE_TOOL_CALL_LIMIT",
},
{
key: "enable_tool_call_repair",
label: "Tool-call name repair",
description: "Recover from lower-cased / fuzzy tool names emitted by smaller models.",
envVar: "SURFSENSE_ENABLE_TOOL_CALL_REPAIR",
},
{
key: "enable_doom_loop",
label: "Doom-loop detection",
description: "Detect repeated identical tool calls and ask the user to confirm.",
envVar: "SURFSENSE_ENABLE_DOOM_LOOP",
},
],
},
{
id: "tier2",
title: "Tier 2 — Safety",
subtitle: "Permission rules, busy-mutex, smarter tool selection.",
flags: [
{
key: "enable_permission",
label: "Permission middleware",
description: "Apply allow/deny/ask rules from the Agent Permissions tab.",
envVar: "SURFSENSE_ENABLE_PERMISSION",
},
{
key: "enable_busy_mutex",
label: "Busy mutex",
description: "Prevent two concurrent runs from corrupting the same thread.",
envVar: "SURFSENSE_ENABLE_BUSY_MUTEX",
},
{
key: "enable_llm_tool_selector",
label: "LLM tool selector",
description: "Use a smaller model to pre-filter the tool list per turn.",
envVar: "SURFSENSE_ENABLE_LLM_TOOL_SELECTOR",
},
],
},
{
id: "tier4",
title: "Tier 4 — Skills + subagents",
subtitle: "Built-in skills, specialized subagents, KB planner runnable.",
flags: [
{
key: "enable_skills",
label: "Skills",
description: "Load on-demand skill packs (kb-research, report-writing, …).",
envVar: "SURFSENSE_ENABLE_SKILLS",
},
{
key: "enable_specialized_subagents",
label: "Specialized subagents",
description: "Spin up explore / report_writer / connector_negotiator subagents.",
envVar: "SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS",
},
],
},
{
id: "tier5",
title: "Tier 5 — Audit + revert",
subtitle: "Action log + revert route used by the Agent Actions dialog.",
flags: [
{
key: "enable_action_log",
label: "Action log",
description: "Persist every tool call to agent_action_log.",
envVar: "SURFSENSE_ENABLE_ACTION_LOG",
},
{
key: "enable_revert_route",
label: "Revert route",
description: "Allow reverting reversible actions from the action log.",
envVar: "SURFSENSE_ENABLE_REVERT_ROUTE",
},
],
},
{
id: "tier6",
title: "Tier 6 — Plugins",
subtitle: "Optional middleware loaded from entry points.",
flags: [
{
key: "enable_plugin_loader",
label: "Plugin loader",
description: "Load surfsense.plugins entry-point middleware.",
envVar: "SURFSENSE_ENABLE_PLUGIN_LOADER",
},
],
},
{
id: "obs",
title: "Observability",
subtitle: "Telemetry pipelines (orthogonal to feature gating).",
flags: [
{
key: "enable_otel",
label: "OpenTelemetry",
description: "Emit OTel spans (also requires OTEL_EXPORTER_OTLP_ENDPOINT).",
envVar: "SURFSENSE_ENABLE_OTEL",
},
],
},
{
id: "desktop",
title: "Desktop",
subtitle: "Desktop-only capabilities exposed by the backend deployment.",
flags: [
{
key: "enable_desktop_local_filesystem",
label: "Local filesystem",
description: "Allow Desktop chat sessions to operate directly on selected local folders.",
envVar: "ENABLE_DESKTOP_LOCAL_FILESYSTEM",
},
],
},
];
function FlagRow({ def, value }: { def: FlagDef; value: boolean }) {
return (
<div className="flex items-start justify-between gap-4 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{def.label}</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{def.envVar}
</code>
</div>
<p className="text-xs text-muted-foreground">{def.description}</p>
</div>
<Badge
variant={value ? "default" : "secondary"}
className={cn(
"shrink-0 gap-1",
value
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600"
: "text-muted-foreground"
)}
>
{value ? <CircleCheck className="size-3" /> : <CircleSlash className="size-3" />}
{value ? "On" : "Off"}
</Badge>
</div>
);
}
export function AgentStatusContent() {
const { data: flags, isLoading, isError, error } = useAtomValue(agentFlagsAtom);
const enabledCount = useMemo(() => {
if (!flags) return 0;
return Object.entries(flags).filter(([k, v]) => k !== "disable_new_agent_stack" && v === true)
.length;
}, [flags]);
if (isLoading) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-12 w-full rounded-md" />
<Skeleton className="h-32 w-full rounded-md" />
<Skeleton className="h-32 w-full rounded-md" />
</div>
);
}
if (isError || !flags) {
return (
<Alert variant="destructive">
<AlertTriangle />
<AlertTitle>Failed to load agent status</AlertTitle>
<AlertDescription>
{error instanceof Error ? error.message : "Unknown error."}
</AlertDescription>
</Alert>
);
}
const masterOff = flags.disable_new_agent_stack;
return (
<div className="space-y-6">
{masterOff ? (
<Alert variant="destructive">
<AlertTriangle />
<AlertTitle>Master kill-switch is on</AlertTitle>
<AlertDescription>
<p>
Showing that{" "}
<code className="rounded bg-muted px-1 text-[10px]">
SURFSENSE_DISABLE_NEW_AGENT_STACK=true
</code>
, which forces every new middleware off, regardless of the individual flags below.
Restart the backend after changing it.
</p>
</AlertDescription>
</Alert>
) : (
<Alert>
<Info />
<AlertTitle className="flex items-center gap-2">
Agent stack
<Badge
variant="secondary"
className="rounded bg-popover px-1 py-0.5 text-[9px] text-popover-foreground"
>
{enabledCount} on
</Badge>
</AlertTitle>
<AlertDescription>
<p>
Showing a read-only mirror of the backend's <code>AgentFeatureFlags</code>. Flip an
env var and restart the backend to change a value.
</p>
</AlertDescription>
</Alert>
)}
{FLAG_GROUPS.map((group, groupIdx) => {
const allOff = group.flags.every((f) => !flags[f.key]);
return (
<div key={group.id}>
{groupIdx > 0 && <Separator className="my-4 bg-border" />}
<div className="rounded-lg border border-border/60 bg-card">
<div className="flex items-start justify-between gap-3 px-4 py-3">
<div>
<p className="text-sm font-semibold">{group.title}</p>
<p className="text-xs text-muted-foreground">{group.subtitle}</p>
</div>
{allOff && (
<Badge variant="outline" className="text-[10px] text-muted-foreground">
all off
</Badge>
)}
</div>
<Separator className="bg-border" />
<div className="px-4">
{group.flags.map((def, flagIdx) => (
<Fragment key={def.key}>
{flagIdx > 0 && <Separator className="bg-border" />}
<FlagRow def={def} value={flags[def.key]} />
</Fragment>
))}
</div>
</div>
</div>
);
})}
</div>
);
}

View file

@ -13,15 +13,15 @@ import {
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import type { SearchSpace } from "@/contracts/types/workspace.types";
import type { Workspace } from "@/contracts/types/workspace.types";
import { useElectronAPI } from "@/hooks/use-platform";
import { searchSpacesApiService } from "@/lib/apis/workspaces-api.service";
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
export function DesktopContent() {
const api = useElectronAPI();
const [loading, setLoading] = useState(true);
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
const [autoLaunchEnabled, setAutoLaunchEnabled] = useState(false);
@ -40,14 +40,14 @@ export function DesktopContent() {
setAutoLaunchSupported(hasAutoLaunchApi);
Promise.all([
api.getActiveSearchSpace?.() ?? Promise.resolve(null),
searchSpacesApiService.getSearchSpaces(),
api.getActiveWorkspace?.() ?? Promise.resolve(null),
workspacesApiService.getWorkspaces(),
hasAutoLaunchApi ? api.getAutoLaunch() : Promise.resolve(null),
])
.then(([spaceId, spaces, autoLaunch]) => {
if (!mounted) return;
setActiveSpaceId(spaceId);
if (spaces) setSearchSpaces(spaces);
if (spaces) setWorkspaces(spaces);
if (autoLaunch) {
setAutoLaunchEnabled(autoLaunch.enabled);
setAutoLaunchHidden(autoLaunch.openAsHidden);
@ -136,30 +136,30 @@ export function DesktopContent() {
}
};
const handleSearchSpaceChange = (value: string) => {
const handleWorkspaceChange = (value: string) => {
setActiveSpaceId(value);
api.setActiveSearchSpace?.(value);
toast.success("Default search space updated");
api.setActiveWorkspace?.(value);
toast.success("Default workspace updated");
};
return (
<div className="flex flex-col gap-4 md:gap-6">
<section>
<div className="pb-2 md:pb-3">
<h2 className="text-base md:text-lg font-semibold">Default Search Space</h2>
<h2 className="text-base md:text-lg font-semibold">Default Workspace</h2>
<p className="text-xs md:text-sm text-muted-foreground">
Choose which search space General Assist, Screenshot Assist, and Quick Assist use by
Choose which workspace General Assist, Screenshot Assist, and Quick Assist use by
default.
</p>
</div>
<div>
{searchSpaces.length > 0 ? (
<Select value={activeSpaceId ?? undefined} onValueChange={handleSearchSpaceChange}>
{workspaces.length > 0 ? (
<Select value={activeSpaceId ?? undefined} onValueChange={handleWorkspaceChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a search space" />
<SelectValue placeholder="Select a workspace" />
</SelectTrigger>
<SelectContent>
{searchSpaces.map((space) => (
{workspaces.map((space) => (
<SelectItem key={space.id} value={String(space.id)}>
{space.name}
</SelectItem>
@ -167,9 +167,7 @@ export function DesktopContent() {
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">
No search spaces found. Create one first.
</p>
<p className="text-sm text-muted-foreground">No workspaces found. Create one first.</p>
)}
</div>
</section>

View file

@ -17,8 +17,8 @@ import {
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import type { SearchSpace } from "@/contracts/types/workspace.types";
import { searchSpacesApiService } from "@/lib/apis/workspaces-api.service";
import type { Workspace } from "@/contracts/types/workspace.types";
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
import { authenticatedFetch } from "@/lib/auth-fetch";
import { buildBackendUrl } from "@/lib/env-config";
import { cn } from "@/lib/utils";
@ -79,7 +79,7 @@ export function MessagingChannelsContent() {
const workspaceId = Number(params.workspace_id);
const [gatewayConfig, setGatewayConfig] = useState<GatewayConfigState>(null);
const [connections, setConnections] = useState<GatewayConnection[]>([]);
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [pairing, setPairing] = useState<Pairing | null>(null);
const [pairingPlatform, setPairingPlatform] = useState<PairingPlatform | null>(null);
const [baileysHealth, setBaileysHealth] = useState<BaileysHealth | null>(null);
@ -114,11 +114,11 @@ export function MessagingChannelsContent() {
const refresh = useCallback(async () => {
const [nextConnections, spaces, nextGatewayConfig] = await Promise.all([
fetchConnections(),
searchSpacesApiService.getSearchSpaces(),
workspacesApiService.getWorkspaces(),
fetchGatewayConfig(),
]);
setConnections(nextConnections);
setSearchSpaces(spaces);
setWorkspaces(spaces);
setGatewayConfig(nextGatewayConfig);
}, [fetchConnections, fetchGatewayConfig]);
@ -210,10 +210,7 @@ export function MessagingChannelsContent() {
await refreshPlatform(connection.platform as GatewayPlatform);
}
async function updateConnectionSearchSpace(
connection: GatewayConnection,
nextWorkspaceId: string
) {
async function updateConnectionWorkspace(connection: GatewayConnection, nextWorkspaceId: string) {
const previousConnections = connections;
const parsedWorkspaceId = Number(nextWorkspaceId);
const targetKey = connectionKey(connection);
@ -324,14 +321,14 @@ export function MessagingChannelsContent() {
<div className="flex flex-wrap items-center gap-2">
<Select
value={String(connection.workspace_id)}
onValueChange={(value) => updateConnectionSearchSpace(connection, value)}
disabled={searchSpaces.length === 0}
onValueChange={(value) => updateConnectionWorkspace(connection, value)}
disabled={workspaces.length === 0}
>
<SelectTrigger className="h-8 min-w-[180px] flex-1 text-xs">
<SelectValue placeholder="Select search space" />
<SelectValue placeholder="Select workspace" />
</SelectTrigger>
<SelectContent>
{searchSpaces.map((space) => (
{workspaces.map((space) => (
<SelectItem key={space.id} value={String(space.id)}>
{space.name}
</SelectItem>
@ -409,7 +406,7 @@ export function MessagingChannelsContent() {
<AlertDescription>
<p>
Soon you'll be able to connect WhatsApp, Telegram, Slack, and Discord to your
SurfSense agent so you can ask questions, route messages to search spaces, and get
SurfSense agent so you can ask questions, route messages to workspaces, and get
answers from your knowledge base without leaving your chat app.
</p>
</AlertDescription>

View file

@ -10,7 +10,6 @@ import {
ReceiptText,
ShieldCheck,
WandSparkles,
Workflow,
} from "lucide-react";
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
@ -27,7 +26,6 @@ export type UserSettingsTab =
| "prompts"
| "community-prompts"
| "agent-permissions"
| "agent-status"
| "purchases"
| "desktop"
| "hotkeys"
@ -80,11 +78,6 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
label: "Agent Permissions",
icon: <ShieldCheck className="h-4 w-4" />,
},
{
value: "agent-status" as const,
label: "Agent Status",
icon: <Workflow className="h-4 w-4" />,
},
{
value: "messaging-channels" as const,
label: "Messaging Channels",
@ -122,7 +115,7 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
const hrefFor = (tab: UserSettingsTab) => `/dashboard/${workspaceId}/user-settings/${tab}`;
return (
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:pt-10 md:flex-row">
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:flex-row">
<div className="md:w-[220px] md:shrink-0">
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
<nav className="hidden flex-col gap-0.5 md:flex">

View file

@ -9,25 +9,20 @@ import { useCallback, useMemo, useState } from "react";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
export type SearchSpaceSettingsTab =
| "general"
| "models"
| "team-roles"
| "prompts"
| "public-links";
export type WorkspaceSettingsTab = "general" | "models" | "team-roles" | "prompts" | "public-links";
const DEFAULT_TAB: SearchSpaceSettingsTab = "general";
const DEFAULT_TAB: WorkspaceSettingsTab = "general";
interface SearchSpaceSettingsLayoutShellProps {
interface WorkspaceSettingsLayoutShellProps {
workspaceId: string;
children: React.ReactNode;
}
export function SearchSpaceSettingsLayoutShell({
export function WorkspaceSettingsLayoutShell({
workspaceId,
children,
}: SearchSpaceSettingsLayoutShellProps) {
const t = useTranslations("searchSpaceSettings");
}: WorkspaceSettingsLayoutShellProps) {
const t = useTranslations("workspaceSettings");
const segment = useSelectedLayoutSegment();
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
@ -69,17 +64,17 @@ export function SearchSpaceSettingsLayoutShell({
[t]
);
const activeTab: SearchSpaceSettingsTab =
const activeTab: WorkspaceSettingsTab =
segment && navItems.some((item) => item.value === segment)
? (segment as SearchSpaceSettingsTab)
? (segment as WorkspaceSettingsTab)
: DEFAULT_TAB;
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
const hrefFor = (tab: SearchSpaceSettingsTab) =>
const hrefFor = (tab: WorkspaceSettingsTab) =>
`/dashboard/${workspaceId}/workspace-settings/${tab}`;
return (
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:pt-6 md:flex-row">
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:flex-row">
<div className="md:w-[220px] md:shrink-0">
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
<nav className="hidden flex-col gap-0.5 md:flex">

View file

@ -1,8 +1,8 @@
import type React from "react";
import { use } from "react";
import { SearchSpaceSettingsLayoutShell } from "./layout-shell";
import { WorkspaceSettingsLayoutShell } from "./layout-shell";
export default function SearchSpaceSettingsLayout({
export default function WorkspaceSettingsLayout({
params,
children,
}: {
@ -12,8 +12,8 @@ export default function SearchSpaceSettingsLayout({
const { workspace_id } = use(params);
return (
<SearchSpaceSettingsLayoutShell workspaceId={workspace_id}>
<WorkspaceSettingsLayoutShell workspaceId={workspace_id}>
{children}
</SearchSpaceSettingsLayoutShell>
</WorkspaceSettingsLayoutShell>
);
}

View file

@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
export default async function SearchSpaceSettingsPage({
export default async function WorkspaceSettingsPage({
params,
}: {
params: Promise<{ workspace_id: string }>;

View file

@ -6,8 +6,8 @@ import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { searchSpacesAtom } from "@/atoms/workspaces/workspace-query.atoms";
import { CreateSearchSpaceDialog } from "@/components/layout";
import { workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms";
import { CreateWorkspaceDialog } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
@ -42,7 +42,7 @@ function ErrorScreen({ message }: { message: string }) {
}
function EmptyState({ onCreateClick }: { onCreateClick: () => void }) {
const t = useTranslations("searchSpace");
const t = useTranslations("workspace");
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
@ -74,26 +74,26 @@ export default function DashboardPage() {
const router = useRouter();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const { data: searchSpaces = [], isLoading, error } = useAtomValue(searchSpacesAtom);
const { data: workspaces = [], isLoading, error } = useAtomValue(workspacesAtom);
useEffect(() => {
if (isLoading) return;
if (searchSpaces.length > 0) {
if (workspaces.length > 0) {
// Read the query string at the time of redirect — no subscription needed.
// (Vercel Best Practice: rerender-defer-reads 5.2)
const query = window.location.search;
router.replace(`/dashboard/${searchSpaces[0].id}/new-chat${query}`);
router.replace(`/dashboard/${workspaces[0].id}/new-chat${query}`);
}
}, [isLoading, searchSpaces, router]);
}, [isLoading, workspaces, router]);
// Show loading while fetching or while we have spaces and are about to redirect
const shouldShowLoading = isLoading || searchSpaces.length > 0;
const shouldShowLoading = isLoading || workspaces.length > 0;
// Use global loading screen - spinner animation won't reset
useGlobalLoadingEffect(shouldShowLoading);
if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;
if (error) return <ErrorScreen message={error?.message || "Failed to load workspaces"} />;
if (shouldShowLoading) {
return null;
@ -102,7 +102,7 @@ export default function DashboardPage() {
return (
<>
<EmptyState onCreateClick={() => setShowCreateDialog(true)} />
<CreateSearchSpaceDialog open={showCreateDialog} onOpenChange={setShowCreateDialog} />
<CreateWorkspaceDialog open={showCreateDialog} onOpenChange={setShowCreateDialog} />
</>
);
}

View file

@ -14,7 +14,7 @@ import { Separator } from "@/components/ui/separator";
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
import { Spinner } from "@/components/ui/spinner";
import { useElectronAPI } from "@/hooks/use-platform";
import { searchSpacesApiService } from "@/lib/apis/workspaces-api.service";
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
import { getPostLoginRedirectPath } from "@/lib/auth-utils";
type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist";
@ -239,7 +239,7 @@ export default function DesktopLoginPage() {
setIsGoogleRedirecting(true);
try {
await api?.startGoogleOAuth?.();
await autoSetSearchSpace();
await autoSetWorkspace();
router.push(getPostLoginRedirectPath());
} catch (error) {
setIsGoogleRedirecting(false);
@ -247,13 +247,13 @@ export default function DesktopLoginPage() {
}
};
const autoSetSearchSpace = async () => {
const autoSetWorkspace = async () => {
try {
const stored = await api?.getActiveSearchSpace?.();
const stored = await api?.getActiveWorkspace?.();
if (stored) return;
const spaces = await searchSpacesApiService.getSearchSpaces();
const spaces = await workspacesApiService.getWorkspaces();
if (spaces?.length) {
await api?.setActiveSearchSpace?.(String(spaces[0].id));
await api?.setActiveWorkspace?.(String(spaces[0].id));
}
} catch {
// non-critical — dashboard-sync will catch it later
@ -272,7 +272,7 @@ export default function DesktopLoginPage() {
}
await api.loginPassword(email, password);
await autoSetSearchSpace();
await autoSetWorkspace();
setTimeout(() => {
router.push(getPostLoginRedirectPath());

View file

@ -34,9 +34,9 @@ import { useSession } from "@/hooks/use-session";
import { invitesApiService } from "@/lib/apis/invites-api.service";
import { setRedirectPath } from "@/lib/auth-utils";
import {
trackSearchSpaceInviteAccepted,
trackSearchSpaceInviteDeclined,
trackSearchSpaceUserAdded,
trackWorkspaceInviteAccepted,
trackWorkspaceInviteDeclined,
trackWorkspaceUserAdded,
} from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
@ -97,12 +97,8 @@ export default function InviteAcceptPage() {
setAcceptedData(result);
// Track invite accepted and user added events
trackSearchSpaceInviteAccepted(
result.workspace_id,
result.workspace_name,
result.role_name
);
trackSearchSpaceUserAdded(result.workspace_id, result.workspace_name, result.role_name);
trackWorkspaceInviteAccepted(result.workspace_id, result.workspace_name, result.role_name);
trackWorkspaceUserAdded(result.workspace_id, result.workspace_name, result.role_name);
}
} catch (err: any) {
setError(err.message || "Failed to accept invite");
@ -113,7 +109,7 @@ export default function InviteAcceptPage() {
const handleDecline = () => {
// Track invite declined event
trackSearchSpaceInviteDeclined(inviteInfo?.workspace_name);
trackWorkspaceInviteDeclined(inviteInfo?.workspace_name);
router.push("/dashboard");
};
@ -187,7 +183,7 @@ export default function InviteAcceptPage() {
</div>
<div>
<p className="font-medium">{acceptedData.workspace_name}</p>
<p className="text-sm text-muted-foreground">Search Space</p>
<p className="text-sm text-muted-foreground">Workspace</p>
</div>
</div>
<div className="flex items-center gap-3">
@ -206,7 +202,7 @@ export default function InviteAcceptPage() {
className="w-full gap-2"
onClick={() => router.push(`/dashboard/${acceptedData.workspace_id}`)}
>
Go to Search Space
Go to Workspace
<ArrowRight className="h-4 w-4" />
</Button>
</CardFooter>
@ -256,7 +252,7 @@ export default function InviteAcceptPage() {
</motion.div>
<CardTitle className="text-2xl">You're Invited!</CardTitle>
<CardDescription>
Sign in to join {inviteInfo?.workspace_name || "this search space"}
Sign in to join {inviteInfo?.workspace_name || "this workspace"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@ -267,7 +263,7 @@ export default function InviteAcceptPage() {
</div>
<div>
<p className="font-medium">{inviteInfo?.workspace_name}</p>
<p className="text-sm text-muted-foreground">Search Space</p>
<p className="text-sm text-muted-foreground">Workspace</p>
</div>
</div>
{inviteInfo?.role_name && (
@ -303,7 +299,7 @@ export default function InviteAcceptPage() {
</motion.div>
<CardTitle className="text-2xl">You're Invited!</CardTitle>
<CardDescription>
Accept this invite to join {inviteInfo?.workspace_name || "this search space"}
Accept this invite to join {inviteInfo?.workspace_name || "this workspace"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@ -314,7 +310,7 @@ export default function InviteAcceptPage() {
</div>
<div>
<p className="font-medium">{inviteInfo?.workspace_name}</p>
<p className="text-sm text-muted-foreground">Search Space</p>
<p className="text-sm text-muted-foreground">Workspace</p>
</div>
</div>
{inviteInfo?.role_name && (

View file

@ -12,78 +12,78 @@ export const agentToolsAtom = atomWithQuery((_get) => ({
const STORAGE_PREFIX = "surfsense-disabled-tools-";
function loadDisabledTools(searchSpaceId: string): string[] {
function loadDisabledTools(workspaceId: string): string[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}${searchSpaceId}`);
const raw = localStorage.getItem(`${STORAGE_PREFIX}${workspaceId}`);
return raw ? (JSON.parse(raw) as string[]) : [];
} catch {
return [];
}
}
function saveDisabledTools(searchSpaceId: string, tools: string[]) {
function saveDisabledTools(workspaceId: string, tools: string[]) {
if (typeof window === "undefined") return;
if (tools.length === 0) {
localStorage.removeItem(`${STORAGE_PREFIX}${searchSpaceId}`);
localStorage.removeItem(`${STORAGE_PREFIX}${workspaceId}`);
} else {
localStorage.setItem(`${STORAGE_PREFIX}${searchSpaceId}`, JSON.stringify(tools));
localStorage.setItem(`${STORAGE_PREFIX}${workspaceId}`, JSON.stringify(tools));
}
}
const disabledToolsBaseAtom = atom<string[]>([]);
/** Tracks whether the atom has been hydrated from localStorage for the current search space */
/** Tracks whether the atom has been hydrated from localStorage for the current workspace */
const hydratedForAtom = atom<string | null>(null);
/**
* Read/write atom for the set of disabled tool names.
* Persists to localStorage keyed by search space ID.
* Persists to localStorage keyed by workspace ID.
*/
export const disabledToolsAtom = atom(
(get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
const hydratedFor = get(hydratedForAtom);
if (searchSpaceId && hydratedFor !== searchSpaceId) {
return loadDisabledTools(searchSpaceId);
if (workspaceId && hydratedFor !== workspaceId) {
return loadDisabledTools(workspaceId);
}
return get(disabledToolsBaseAtom);
},
(get, set, update: string[] | ((prev: string[]) => string[])) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
const prev = get(disabledToolsBaseAtom);
const next = typeof update === "function" ? update(prev) : update;
set(disabledToolsBaseAtom, next);
set(hydratedForAtom, searchSpaceId);
if (searchSpaceId) {
saveDisabledTools(searchSpaceId, next);
set(hydratedForAtom, workspaceId);
if (workspaceId) {
saveDisabledTools(workspaceId, next);
}
}
);
/**
* Hydrate disabled tools from localStorage when search space changes.
* Call this from a useEffect in a component that has access to the search space.
* Hydrate disabled tools from localStorage when workspace changes.
* Call this from a useEffect in a component that has access to the workspace.
*/
export const hydrateDisabledToolsAtom = atom(null, (get, set) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
if (!searchSpaceId) return;
const stored = loadDisabledTools(searchSpaceId);
const workspaceId = get(activeWorkspaceIdAtom);
if (!workspaceId) return;
const stored = loadDisabledTools(workspaceId);
set(disabledToolsBaseAtom, stored);
set(hydratedForAtom, searchSpaceId);
set(hydratedForAtom, workspaceId);
});
/** Toggle a single tool's enabled/disabled state */
export const toggleToolAtom = atom(null, (get, set, toolName: string) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
const current = get(disabledToolsBaseAtom);
const next = current.includes(toolName)
? current.filter((t) => t !== toolName)
: [...current, toolName];
set(disabledToolsBaseAtom, next);
set(hydratedForAtom, searchSpaceId);
if (searchSpaceId) {
saveDisabledTools(searchSpaceId, next);
set(hydratedForAtom, workspaceId);
if (workspaceId) {
saveDisabledTools(workspaceId, next);
}
});

View file

@ -26,15 +26,15 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
// Cache invalidation strategy:
// - Automation writes invalidate the search-space list + the touched detail.
// - Automation writes invalidate the workspace list + the touched detail.
// - Trigger writes only invalidate the parent automation detail (triggers
// come back inline in AutomationDetail).
// We deliberately invalidate the whole "automations" prefix on the list side
// because list is keyed by (searchSpaceId, limit, offset) and we don't track
// because list is keyed by (workspaceId, limit, offset) and we don't track
// the active pagination in this layer.
function invalidateList(searchSpaceId: number) {
queryClient.invalidateQueries({ queryKey: ["automations", "list", searchSpaceId] });
function invalidateList(workspaceId: number) {
queryClient.invalidateQueries({ queryKey: ["automations", "list", workspaceId] });
}
function invalidateDetail(automationId: number) {
@ -113,17 +113,17 @@ export const updateAutomationMutationAtom = atomWithMutation(() => ({
export const deleteAutomationMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (vars: { automationId: number; searchSpaceId: number }) => {
mutationFn: async (vars: { automationId: number; workspaceId: number }) => {
await automationsApiService.deleteAutomation(vars.automationId);
return vars;
},
onSuccess: (vars) => {
invalidateList(vars.searchSpaceId);
invalidateList(vars.workspaceId);
invalidateDetail(vars.automationId);
toast.success("Automation deleted");
trackAutomationDeleted({
automation_id: vars.automationId,
workspace_id: vars.searchSpaceId,
workspace_id: vars.workspaceId,
});
},
onError: (error: Error, vars) => {

View file

@ -3,7 +3,7 @@ import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms"
import { automationsApiService } from "@/lib/apis/automations-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
// First page of the active search space's automations.
// First page of the active workspace's automations.
// Detail + paginated/parameterized reads live in hooks (see use-automation.ts,
// use-automation-runs.ts) so atoms stay tied to "current scope" and don't
// proliferate atom families for every (id, limit, offset) tuple.
@ -11,18 +11,18 @@ const DEFAULT_LIMIT = 50;
const DEFAULT_OFFSET = 0;
export const automationsListAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
return {
queryKey: cacheKeys.automations.list(Number(searchSpaceId ?? 0), DEFAULT_LIMIT, DEFAULT_OFFSET),
enabled: !!searchSpaceId,
queryKey: cacheKeys.automations.list(Number(workspaceId ?? 0), DEFAULT_LIMIT, DEFAULT_OFFSET),
enabled: !!workspaceId,
staleTime: 60 * 1000,
queryFn: async () => {
if (!searchSpaceId) {
if (!workspaceId) {
return { items: [], total: 0 };
}
return automationsApiService.listAutomations({
workspace_id: Number(searchSpaceId),
workspace_id: Number(workspaceId),
limit: DEFAULT_LIMIT,
offset: DEFAULT_OFFSET,
});

View file

@ -4,14 +4,14 @@ import { reportPanelAtom } from "./report-panel.atom";
interface CurrentThreadState {
id: number | null;
searchSpaceId: number | null;
workspaceId: number | null;
visibility: ChatVisibility | null;
hasComments: boolean;
}
interface CurrentThreadMetadataPatch {
id: number | null;
searchSpaceId?: number | null;
workspaceId?: number | null;
visibility?: ChatVisibility | null;
hasComments?: boolean;
}
@ -24,7 +24,7 @@ interface CurrentThreadMetadataUpdate {
const initialState: CurrentThreadState = {
id: null,
searchSpaceId: null,
workspaceId: null,
visibility: null,
hasComments: false,
};
@ -44,11 +44,11 @@ export const setCurrentThreadMetadataAtom = atom(
set(currentThreadAtom, {
...current,
id: metadata.id,
searchSpaceId:
"searchSpaceId" in metadata
? (metadata.searchSpaceId ?? null)
workspaceId:
"workspaceId" in metadata
? (metadata.workspaceId ?? null)
: isSameThread
? current.searchSpaceId
? current.workspaceId
: null,
visibility:
"visibility" in metadata

View file

@ -13,38 +13,38 @@ import { queryClient } from "@/lib/query-client/client";
import { activeWorkspaceIdAtom } from "../workspaces/workspace-query.atoms";
export const createConnectorMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
return {
mutationKey: cacheKeys.connectors.all(searchSpaceId ?? ""),
enabled: !!searchSpaceId,
mutationKey: cacheKeys.connectors.all(workspaceId ?? ""),
enabled: !!workspaceId,
mutationFn: async (request: CreateConnectorRequest) => {
return connectorsApiService.createConnector(request);
},
onSuccess: () => {
if (!searchSpaceId) return;
if (!workspaceId) return;
queryClient.invalidateQueries({
queryKey: cacheKeys.connectors.all(searchSpaceId),
queryKey: cacheKeys.connectors.all(workspaceId),
});
},
};
});
export const updateConnectorMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
return {
mutationKey: cacheKeys.connectors.all(searchSpaceId ?? ""),
enabled: !!searchSpaceId,
mutationKey: cacheKeys.connectors.all(workspaceId ?? ""),
enabled: !!workspaceId,
mutationFn: async (request: UpdateConnectorRequest) => {
return connectorsApiService.updateConnector(request);
},
onSuccess: (_, request: UpdateConnectorRequest) => {
if (!searchSpaceId) return;
if (!workspaceId) return;
queryClient.invalidateQueries({
queryKey: cacheKeys.connectors.all(searchSpaceId),
queryKey: cacheKeys.connectors.all(workspaceId),
});
queryClient.invalidateQueries({
queryKey: cacheKeys.connectors.byId(String(request.id)),
@ -54,19 +54,19 @@ export const updateConnectorMutationAtom = atomWithMutation((get) => {
});
export const deleteConnectorMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
return {
mutationKey: cacheKeys.connectors.all(searchSpaceId ?? ""),
enabled: !!searchSpaceId,
mutationKey: cacheKeys.connectors.all(workspaceId ?? ""),
enabled: !!workspaceId,
mutationFn: async (request: DeleteConnectorRequest) => {
return connectorsApiService.deleteConnector(request);
},
onSuccess: (_, request: DeleteConnectorRequest) => {
if (!searchSpaceId) return;
if (!workspaceId) return;
queryClient.setQueryData(
cacheKeys.connectors.all(searchSpaceId),
cacheKeys.connectors.all(workspaceId),
(oldData: GetConnectorsResponse | undefined) => {
if (!oldData) return oldData;
return oldData.filter((connector) => connector.id !== request.id);
@ -80,19 +80,19 @@ export const deleteConnectorMutationAtom = atomWithMutation((get) => {
});
export const indexConnectorMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
return {
mutationKey: cacheKeys.connectors.index(),
enabled: !!searchSpaceId,
enabled: !!workspaceId,
mutationFn: async (request: IndexConnectorRequest) => {
return connectorsApiService.indexConnector(request);
},
onSuccess: (response: IndexConnectorResponse) => {
if (!searchSpaceId) return;
if (!workspaceId) return;
queryClient.invalidateQueries({
queryKey: cacheKeys.connectors.all(searchSpaceId),
queryKey: cacheKeys.connectors.all(workspaceId),
});
queryClient.invalidateQueries({
queryKey: cacheKeys.connectors.byId(String(response.connector_id)),

View file

@ -4,16 +4,16 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
import { activeWorkspaceIdAtom } from "../workspaces/workspace-query.atoms";
export const connectorsAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
return {
queryKey: cacheKeys.connectors.all(searchSpaceId!),
enabled: !!searchSpaceId,
queryKey: cacheKeys.connectors.all(workspaceId!),
enabled: !!workspaceId,
staleTime: 5 * 60 * 1000, // 5 minutes
queryFn: async () => {
return connectorsApiService.getConnectors({
queryParams: {
workspace_id: searchSpaceId!,
workspace_id: workspaceId!,
},
});
},

View file

@ -14,12 +14,12 @@ import { queryClient } from "@/lib/query-client/client";
import { globalDocumentsQueryParamsAtom } from "./ui.atoms";
export const createDocumentMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
const documentsQueryParams = get(globalDocumentsQueryParamsAtom);
return {
mutationKey: cacheKeys.documents.globalQueryParams(documentsQueryParams),
enabled: !!searchSpaceId,
enabled: !!workspaceId,
mutationFn: async (request: CreateDocumentRequest) => {
return documentsApiService.createDocument(request);
},
@ -34,12 +34,12 @@ export const createDocumentMutationAtom = atomWithMutation((get) => {
});
export const uploadDocumentMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
const documentsQueryParams = get(globalDocumentsQueryParamsAtom);
return {
mutationKey: cacheKeys.documents.globalQueryParams(documentsQueryParams),
enabled: !!searchSpaceId,
enabled: !!workspaceId,
mutationFn: async (request: UploadDocumentRequest) => {
return documentsApiService.uploadDocument(request);
},
@ -47,19 +47,19 @@ export const uploadDocumentMutationAtom = atomWithMutation((get) => {
onSuccess: () => {
// Note: Toast notification is handled by the caller (DocumentUploadTab) to use i18n
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined),
queryKey: cacheKeys.logs.summary(workspaceId ?? undefined),
});
},
};
});
export const updateDocumentMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
const documentsQueryParams = get(globalDocumentsQueryParamsAtom);
return {
mutationKey: cacheKeys.documents.globalQueryParams(documentsQueryParams),
enabled: !!searchSpaceId,
enabled: !!workspaceId,
mutationFn: async (request: UpdateDocumentRequest) => {
return documentsApiService.updateDocument(request);
},
@ -77,12 +77,12 @@ export const updateDocumentMutationAtom = atomWithMutation((get) => {
});
export const deleteDocumentMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
const documentsQueryParams = get(globalDocumentsQueryParamsAtom);
return {
mutationKey: cacheKeys.documents.globalQueryParams(documentsQueryParams),
enabled: !!searchSpaceId,
enabled: !!workspaceId,
mutationFn: async (request: DeleteDocumentRequest) => {
return documentsApiService.deleteDocument(request);
},

View file

@ -4,7 +4,7 @@ import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
/**
* Set of folder IDs that are currently expanded in the tree, keyed by search space ID.
* Set of folder IDs that are currently expanded in the tree, keyed by workspace ID.
* Persisted to localStorage so expand/collapse state survives page refreshes.
*/
export const expandedFolderIdsAtom = atomWithStorage<Record<number, number[]>>(
@ -13,7 +13,7 @@ export const expandedFolderIdsAtom = atomWithStorage<Record<number, number[]>>(
);
/**
* Expanded folder keys for Local filesystem tree, keyed by search space ID.
* Expanded folder keys for Local filesystem tree, keyed by workspace ID.
* Persisted so local tree expansion survives remounts/reloads.
*/
export const localExpandedFolderKeysAtom = atomWithStorage<Record<number, string[]>>(

View file

@ -6,13 +6,11 @@ export const globalDocumentsQueryParamsAtom = atom<GetDocumentsRequest["queryPar
page: 0,
});
export const documentsSidebarOpenAtom = atom(false);
export interface AgentCreatedDocument {
id: number;
title: string;
documentType: string;
searchSpaceId: number;
workspaceId: number;
folderId: number | null;
createdById: string | null;
}

View file

@ -6,7 +6,7 @@ interface EditorPanelState {
kind: "document" | "local_file" | "memory";
documentId: number | null;
localFilePath: string | null;
searchSpaceId: number | null;
workspaceId: number | null;
memoryScope: "user" | "team" | null;
title: string | null;
}
@ -16,7 +16,7 @@ const initialState: EditorPanelState = {
kind: "document",
documentId: null,
localFilePath: null,
searchSpaceId: null,
workspaceId: null,
memoryScope: null,
title: null,
};
@ -33,18 +33,18 @@ export const openEditorPanelAtom = atom(
get,
set,
payload:
| { documentId: number; searchSpaceId: number; title?: string; kind?: "document" }
| { documentId: number; workspaceId: number; title?: string; kind?: "document" }
| {
kind: "local_file";
localFilePath: string;
title?: string;
searchSpaceId?: number;
workspaceId?: number;
}
| {
kind: "memory";
memoryScope: "user" | "team";
title?: string;
searchSpaceId?: number;
workspaceId?: number;
}
) => {
if (!get(editorPanelAtom).isOpen) {
@ -56,7 +56,7 @@ export const openEditorPanelAtom = atom(
kind: "local_file",
documentId: null,
localFilePath: payload.localFilePath,
searchSpaceId: payload.searchSpaceId ?? null,
workspaceId: payload.workspaceId ?? null,
memoryScope: null,
title: payload.title ?? null,
});
@ -70,7 +70,7 @@ export const openEditorPanelAtom = atom(
kind: "memory",
documentId: null,
localFilePath: null,
searchSpaceId: payload.searchSpaceId ?? null,
workspaceId: payload.workspaceId ?? null,
memoryScope: payload.memoryScope,
title: payload.title ?? null,
});
@ -83,7 +83,7 @@ export const openEditorPanelAtom = atom(
kind: "document",
documentId: payload.documentId,
localFilePath: null,
searchSpaceId: payload.searchSpaceId,
workspaceId: payload.workspaceId,
memoryScope: null,
title: payload.title ?? null,
});

View file

@ -79,7 +79,7 @@ export const acceptInviteMutationAtom = atomWithMutation(() => ({
return invitesApiService.acceptInvite(request);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: cacheKeys.searchSpaces.all });
queryClient.invalidateQueries({ queryKey: cacheKeys.workspaces.all });
toast.success("Invite accepted successfully");
},
onError: (error: Error) => {

View file

@ -4,18 +4,18 @@ import { invitesApiService } from "@/lib/apis/invites-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export const invitesAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
return {
queryKey: cacheKeys.invites.all(searchSpaceId?.toString() ?? ""),
enabled: !!searchSpaceId,
queryKey: cacheKeys.invites.all(workspaceId?.toString() ?? ""),
enabled: !!workspaceId,
staleTime: 5 * 60 * 1000, // 5 minutes
queryFn: async () => {
if (!searchSpaceId) {
if (!workspaceId) {
return [];
}
return invitesApiService.getInvites({
workspace_id: Number(searchSpaceId),
workspace_id: Number(workspaceId),
});
},
};

View file

@ -13,10 +13,10 @@ import { queryClient } from "@/lib/query-client/client";
* Create Log Mutation
*/
export const createLogMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
return {
mutationKey: cacheKeys.logs.list(searchSpaceId ?? undefined),
enabled: !!searchSpaceId,
mutationKey: cacheKeys.logs.list(workspaceId ?? undefined),
enabled: !!workspaceId,
mutationFn: async (request: CreateLogRequest) => logsApiService.createLog(request),
onSuccess: () => {
// Invalidate all log-related queries (list, summary, detail, withQueryParams)
@ -29,10 +29,10 @@ export const createLogMutationAtom = atomWithMutation((get) => {
* Update Log Mutation
*/
export const updateLogMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
return {
mutationKey: cacheKeys.logs.list(searchSpaceId ?? undefined),
enabled: !!searchSpaceId,
mutationKey: cacheKeys.logs.list(workspaceId ?? undefined),
enabled: !!workspaceId,
mutationFn: async ({ logId, data }: { logId: number; data: UpdateLogRequest }) =>
logsApiService.updateLog(logId, data),
onSuccess: (_data, variables) => {
@ -45,10 +45,10 @@ export const updateLogMutationAtom = atomWithMutation((get) => {
* Delete Log Mutation
*/
export const deleteLogMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
return {
mutationKey: cacheKeys.logs.list(searchSpaceId ?? undefined),
enabled: !!searchSpaceId,
mutationKey: cacheKeys.logs.list(workspaceId ?? undefined),
enabled: !!workspaceId,
mutationFn: async (request: DeleteLogRequest) => logsApiService.deleteLog(request),
onSuccess: (_data, request) => {
queryClient.invalidateQueries({ queryKey: ["logs"] });

View file

@ -3,8 +3,8 @@ import { toast } from "sonner";
import type {
DeleteMembershipRequest,
DeleteMembershipResponse,
LeaveSearchSpaceRequest,
LeaveSearchSpaceResponse,
LeaveWorkspaceRequest,
LeaveWorkspaceResponse,
UpdateMembershipRequest,
UpdateMembershipResponse,
} from "@/contracts/types/members.types";
@ -48,20 +48,20 @@ export const deleteMemberMutationAtom = atomWithMutation(() => {
};
});
export const leaveSearchSpaceMutationAtom = atomWithMutation(() => {
export const leaveWorkspaceMutationAtom = atomWithMutation(() => {
return {
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: LeaveSearchSpaceRequest) => {
return membersApiService.leaveSearchSpace(request);
mutationFn: async (request: LeaveWorkspaceRequest) => {
return membersApiService.leaveWorkspace(request);
},
onSuccess: (_: LeaveSearchSpaceResponse, request: LeaveSearchSpaceRequest) => {
toast.success("Successfully left the search space");
onSuccess: (_: LeaveWorkspaceResponse, request: LeaveWorkspaceRequest) => {
toast.success("Successfully left the workspace");
queryClient.invalidateQueries({
queryKey: cacheKeys.members.all(request.workspace_id.toString()),
});
},
onError: () => {
toast.error("Failed to leave search space");
toast.error("Failed to leave workspace");
},
};
});

View file

@ -1,40 +1,41 @@
import { useAtomValue } from "jotai";
import { atomWithQuery } from "jotai-tanstack-query";
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
import { membersApiService } from "@/lib/apis/members-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export const membersAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
return {
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
enabled: !!searchSpaceId,
queryKey: cacheKeys.members.all(workspaceId?.toString() ?? ""),
enabled: !!workspaceId,
staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration
refetchInterval: 2 * 60 * 1000, // 2 minutes
queryFn: async () => {
if (!searchSpaceId) {
if (!workspaceId) {
return [];
}
return membersApiService.getMembers({
workspace_id: Number(searchSpaceId),
workspace_id: Number(workspaceId),
});
},
};
});
export const myAccessAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
return {
queryKey: cacheKeys.members.myAccess(searchSpaceId?.toString() ?? ""),
enabled: !!searchSpaceId,
queryKey: cacheKeys.members.myAccess(workspaceId?.toString() ?? ""),
enabled: !!workspaceId,
staleTime: 5 * 60 * 1000, // 5 minutes
queryFn: async () => {
if (!searchSpaceId) {
if (!workspaceId) {
return null;
}
return membersApiService.getMyAccess({
workspace_id: Number(searchSpaceId),
workspace_id: Number(workspaceId),
});
},
};
@ -71,6 +72,6 @@ export function canPerform(
* const canManageMembers = usePermissionGate('manage_members');
*/
export function usePermissionGate(permission: string): boolean {
const access = useAtomValue(myAccessAtom);
const { data: access } = useAtomValue(myAccessAtom);
return canPerform(access, permission);
}

View file

@ -18,18 +18,18 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
import { activeWorkspaceIdAtom } from "../workspaces/workspace-query.atoms";
function invalidateModelConnections(searchSpaceId: number) {
function invalidateModelConnections(workspaceId: number) {
queryClient.invalidateQueries({
queryKey: cacheKeys.modelConnections.all(searchSpaceId),
queryKey: cacheKeys.modelConnections.all(workspaceId),
});
queryClient.invalidateQueries({
queryKey: cacheKeys.modelConnections.roles(searchSpaceId),
queryKey: cacheKeys.modelConnections.roles(workspaceId),
});
}
function upsertModelConnection(searchSpaceId: number, connection: ConnectionRead) {
function upsertModelConnection(workspaceId: number, connection: ConnectionRead) {
queryClient.setQueryData<ConnectionRead[]>(
cacheKeys.modelConnections.all(searchSpaceId),
cacheKeys.modelConnections.all(workspaceId),
(current = []) => {
if (current.some((item) => item.id === connection.id)) {
return current.map((item) => (item.id === connection.id ? connection : item));
@ -40,19 +40,19 @@ function upsertModelConnection(searchSpaceId: number, connection: ConnectionRead
}
export const createModelConnectionMutationAtom = atomWithMutation((get) => {
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
const workspaceId = Number(get(activeWorkspaceIdAtom));
return {
mutationKey: ["model-connections", "create"],
mutationFn: (request: ConnectionCreateRequest) =>
modelConnectionsApiService.createConnection(request),
onSuccess: (connection: ConnectionRead, request: ConnectionCreateRequest) => {
const resolvedSearchSpaceId = Number(
request.workspace_id ?? connection.workspace_id ?? searchSpaceId
const resolvedWorkspaceId = Number(
request.workspace_id ?? connection.workspace_id ?? workspaceId
);
toast.success("Connection created");
if (resolvedSearchSpaceId > 0) {
upsertModelConnection(resolvedSearchSpaceId, connection);
invalidateModelConnections(resolvedSearchSpaceId);
if (resolvedWorkspaceId > 0) {
upsertModelConnection(resolvedWorkspaceId, connection);
invalidateModelConnections(resolvedWorkspaceId);
}
},
onError: (error: Error) => toast.error(error.message || "Failed to create connection"),
@ -60,34 +60,34 @@ export const createModelConnectionMutationAtom = atomWithMutation((get) => {
});
export const updateModelConnectionMutationAtom = atomWithMutation((get) => {
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
const workspaceId = Number(get(activeWorkspaceIdAtom));
return {
mutationKey: ["model-connections", "update"],
mutationFn: ({ id, data }: { id: number; data: ConnectionUpdateRequest }) =>
modelConnectionsApiService.updateConnection(id, data),
onSuccess: () => {
toast.success("Connection updated");
invalidateModelConnections(searchSpaceId);
invalidateModelConnections(workspaceId);
},
onError: (error: Error) => toast.error(error.message || "Failed to update connection"),
};
});
export const deleteModelConnectionMutationAtom = atomWithMutation((get) => {
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
const workspaceId = Number(get(activeWorkspaceIdAtom));
return {
mutationKey: ["model-connections", "delete"],
mutationFn: (id: number) => modelConnectionsApiService.deleteConnection(id),
onSuccess: () => {
toast.success("Connection deleted");
invalidateModelConnections(searchSpaceId);
invalidateModelConnections(workspaceId);
},
onError: (error: Error) => toast.error(error.message || "Failed to delete connection"),
};
});
export const verifyModelConnectionMutationAtom = atomWithMutation((get) => {
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
const workspaceId = Number(get(activeWorkspaceIdAtom));
return {
mutationKey: ["model-connections", "verify"],
mutationFn: (id: number) => modelConnectionsApiService.verifyConnection(id),
@ -103,14 +103,14 @@ export const verifyModelConnectionMutationAtom = atomWithMutation((get) => {
: "Couldn't list models. Chat may still work — add model IDs manually."
);
}
invalidateModelConnections(searchSpaceId);
invalidateModelConnections(workspaceId);
},
onError: (error: Error) => toast.error(error.message || "Failed to verify connection"),
};
});
export const discoverConnectionModelsMutationAtom = atomWithMutation((get) => {
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
const workspaceId = Number(get(activeWorkspaceIdAtom));
return {
mutationKey: ["model-connections", "discover"],
mutationFn: (id: number) => modelConnectionsApiService.discoverModels(id),
@ -118,7 +118,7 @@ export const discoverConnectionModelsMutationAtom = atomWithMutation((get) => {
toast.success(
models.length ? `${models.length} models discovered` : "No models found for this connection"
);
invalidateModelConnections(searchSpaceId);
invalidateModelConnections(workspaceId);
},
onError: (error: Error) => toast.error(error.message || "Failed to discover models"),
};
@ -149,64 +149,64 @@ export const testPreviewModelMutationAtom = atomWithMutation(() => {
});
export const addManualModelMutationAtom = atomWithMutation((get) => {
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
const workspaceId = Number(get(activeWorkspaceIdAtom));
return {
mutationKey: ["models", "add-manual"],
mutationFn: ({ connectionId, data }: { connectionId: number; data: ModelCreateRequest }) =>
modelConnectionsApiService.addManualModel(connectionId, data),
onSuccess: () => {
toast.success("Model added");
invalidateModelConnections(searchSpaceId);
invalidateModelConnections(workspaceId);
},
onError: (error: Error) => toast.error(error.message || "Failed to add model"),
};
});
export const updateModelMutationAtom = atomWithMutation((get) => {
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
const workspaceId = Number(get(activeWorkspaceIdAtom));
return {
mutationKey: ["models", "update"],
mutationFn: ({ id, data }: { id: number; data: ModelUpdateRequest }) =>
modelConnectionsApiService.updateModel(id, data),
onSuccess: () => invalidateModelConnections(searchSpaceId),
onSuccess: () => invalidateModelConnections(workspaceId),
onError: (error: Error) => toast.error(error.message || "Failed to update model"),
};
});
export const bulkUpdateModelsMutationAtom = atomWithMutation((get) => {
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
const workspaceId = Number(get(activeWorkspaceIdAtom));
return {
mutationKey: ["models", "bulk-update"],
mutationFn: ({ connectionId, data }: { connectionId: number; data: ModelsBulkUpdateRequest }) =>
modelConnectionsApiService.bulkUpdateModels(connectionId, data),
onSuccess: () => invalidateModelConnections(searchSpaceId),
onSuccess: () => invalidateModelConnections(workspaceId),
onError: (error: Error) => toast.error(error.message || "Failed to update models"),
};
});
export const testModelMutationAtom = atomWithMutation((get) => {
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
const workspaceId = Number(get(activeWorkspaceIdAtom));
return {
mutationKey: ["models", "test"],
mutationFn: (id: number) => modelConnectionsApiService.testModel(id),
onSuccess: (result: VerifyConnectionResponse) => {
if (result.ok) toast.success("Model test succeeded");
else toast.error(result.message || "Model test failed");
invalidateModelConnections(searchSpaceId);
invalidateModelConnections(workspaceId);
},
onError: (error: Error) => toast.error(error.message || "Failed to test model"),
};
});
export const updateModelRolesMutationAtom = atomWithMutation((get) => {
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
const workspaceId = Number(get(activeWorkspaceIdAtom));
return {
mutationKey: ["model-roles", "update"],
mutationFn: (roles: ModelRoles) =>
modelConnectionsApiService.updateModelRoles(searchSpaceId, roles),
modelConnectionsApiService.updateModelRoles(workspaceId, roles),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: cacheKeys.modelConnections.roles(searchSpaceId),
queryKey: cacheKeys.modelConnections.roles(workspaceId),
});
},
onError: (error: Error) => toast.error(error.message || "Failed to update model roles"),

View file

@ -26,21 +26,21 @@ export const modelProvidersAtom = atomWithQuery(() => ({
}));
export const modelConnectionsAtom = atomWithQuery((get) => {
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
const workspaceId = Number(get(activeWorkspaceIdAtom));
return {
queryKey: cacheKeys.modelConnections.all(searchSpaceId),
enabled: !!searchSpaceId,
queryKey: cacheKeys.modelConnections.all(workspaceId),
enabled: !!workspaceId,
staleTime: 5 * 60 * 1000,
queryFn: () => modelConnectionsApiService.getConnections(searchSpaceId),
queryFn: () => modelConnectionsApiService.getConnections(workspaceId),
};
});
export const modelRolesAtom = atomWithQuery((get) => {
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
const workspaceId = Number(get(activeWorkspaceIdAtom));
return {
queryKey: cacheKeys.modelConnections.roles(searchSpaceId),
enabled: !!searchSpaceId,
queryKey: cacheKeys.modelConnections.roles(workspaceId),
enabled: !!workspaceId,
staleTime: 5 * 60 * 1000,
queryFn: () => modelConnectionsApiService.getModelRoles(searchSpaceId),
queryFn: () => modelConnectionsApiService.getModelRoles(workspaceId),
};
});

View file

@ -4,18 +4,18 @@ import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export const publicChatSnapshotsAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeWorkspaceIdAtom);
const workspaceId = get(activeWorkspaceIdAtom);
return {
queryKey: cacheKeys.publicChatSnapshots.bySearchSpace(Number(searchSpaceId) || 0),
enabled: !!searchSpaceId,
queryKey: cacheKeys.publicChatSnapshots.byWorkspace(Number(workspaceId) || 0),
enabled: !!workspaceId,
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!searchSpaceId) {
if (!workspaceId) {
return { snapshots: [] };
}
return chatThreadsApiService.listPublicChatSnapshotsForSearchSpace({
workspace_id: Number(searchSpaceId),
return chatThreadsApiService.listPublicChatSnapshotsForWorkspace({
workspace_id: Number(workspaceId),
});
},
};

View file

@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { migrateLegacyTabs } from "./migrate-tabs";
// Run with: pnpm exec tsx --test atoms/tabs/migrate-tabs.test.ts
test("maps legacy searchSpaceId to workspaceId on read", () => {
const migrated = migrateLegacyTabs({
tabs: [{ id: "chat-new", type: "chat", searchSpaceId: 7 } as never],
activeTabId: "chat-new",
});
const tab = migrated.tabs[0] as { workspaceId?: number };
assert.equal(tab.workspaceId, 7);
});
test("leaves an already-migrated workspaceId untouched", () => {
const migrated = migrateLegacyTabs({
tabs: [{ id: "d1", type: "document", workspaceId: 3, searchSpaceId: 9 } as never],
});
const tab = migrated.tabs[0] as { workspaceId?: number };
assert.equal(tab.workspaceId, 3);
});

View file

@ -0,0 +1,19 @@
/**
* One-time read-migration for persisted tabs: legacy state stored the workspace
* as `searchSpaceId`. Map it to `workspaceId` on read so already-open tabs keep
* their workspace association after the rename. Pure + dependency-free so it can
* be unit-checked without loading the atom module.
*/
export function migrateLegacyTabs<T extends { tabs: Array<{ workspaceId?: number }> }>(
state: T
): T {
return {
...state,
tabs: state.tabs.map((t) => {
const legacy = t as { workspaceId?: number; searchSpaceId?: number };
return legacy.workspaceId === undefined && legacy.searchSpaceId !== undefined
? { ...t, workspaceId: legacy.searchSpaceId }
: t;
}),
};
}

View file

@ -1,6 +1,7 @@
import { atom } from "jotai";
import { atomWithStorage, createJSONStorage } from "jotai/utils";
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
import { migrateLegacyTabs } from "./migrate-tabs";
export type TabType = "chat" | "document";
@ -15,7 +16,7 @@ export interface Tab {
hasComments?: boolean;
/** For document tabs */
documentId?: number;
searchSpaceId?: number;
workspaceId?: number;
}
interface TabsState {
@ -40,11 +41,17 @@ const initialState: TabsState = {
const deletedChatIdsAtom = atom<Set<number>>(new Set<number>());
// Persist tabs in localStorage so they survive a hard refresh and let the user
// keep tabs open across multiple search spaces (browser-like behavior).
// keep tabs open across multiple workspaces (browser-like behavior).
const localStorageAdapter = createJSONStorage<TabsState>(
() => (typeof window !== "undefined" ? localStorage : undefined) as Storage
);
// Wrap getItem in place so the adapter keeps its original (sync) type while
// migrating legacy persisted state on read.
const baseGetItem = localStorageAdapter.getItem.bind(localStorageAdapter);
localStorageAdapter.getItem = (key, initialValue) =>
migrateLegacyTabs(baseGetItem(key, initialValue));
export const tabsStateAtom = atomWithStorage<TabsState>(
"surfsense:tabs",
initialState,
@ -81,14 +88,14 @@ export const syncChatTabAtom = atom(
chatId,
title,
chatUrl,
searchSpaceId,
workspaceId,
visibility,
hasComments,
}: {
chatId: number | null;
title?: string;
chatUrl?: string;
searchSpaceId: number;
workspaceId: number;
visibility?: ChatVisibility;
hasComments?: boolean;
}
@ -111,7 +118,7 @@ export const syncChatTabAtom = atom(
...t,
title: title || t.title,
chatUrl: chatUrl || t.chatUrl,
searchSpaceId: searchSpaceId ?? t.searchSpaceId,
workspaceId: workspaceId ?? t.workspaceId,
...(visibility !== undefined ? { visibility } : {}),
...(hasComments !== undefined ? { hasComments } : {}),
}
@ -122,18 +129,18 @@ export const syncChatTabAtom = atom(
}
// If navigating to a new chat (no chatId), ensure there's a "new chat" tab
// scoped to the current search space.
// scoped to the current workspace.
if (!chatId) {
const hasNewChatTab = state.tabs.some((t) => t.id === "chat-new");
if (hasNewChatTab) {
set(tabsStateAtom, {
...state,
activeTabId: "chat-new",
tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, searchSpaceId, chatUrl } : t)),
tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, workspaceId, chatUrl } : t)),
});
} else {
set(tabsStateAtom, {
tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, searchSpaceId, chatUrl }],
tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, workspaceId, chatUrl }],
activeTabId: "chat-new",
});
}
@ -148,7 +155,7 @@ export const syncChatTabAtom = atom(
title: title || "New Chat",
chatId,
chatUrl,
searchSpaceId,
workspaceId,
...(visibility !== undefined ? { visibility } : {}),
...(hasComments !== undefined ? { hasComments } : {}),
};
@ -197,11 +204,7 @@ export const openDocumentTabAtom = atom(
(
get,
set,
{
documentId,
searchSpaceId,
title,
}: { documentId: number; searchSpaceId: number; title?: string }
{ documentId, workspaceId, title }: { documentId: number; workspaceId: number; title?: string }
) => {
const state = get(tabsStateAtom);
const tabId = makeDocumentTabId(documentId);
@ -221,7 +224,7 @@ export const openDocumentTabAtom = atom(
type: "document",
title: title || `Document ${documentId}`,
documentId,
searchSpaceId,
workspaceId,
};
set(tabsStateAtom, {
@ -300,7 +303,7 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => {
return remaining.find((t) => t.id === newActiveId) ?? null;
});
/** Reset tabs when switching search spaces. */
/** Reset tabs when switching workspaces. */
export const resetTabsAtom = atom(null, (_get, set) => {
set(tabsStateAtom, { ...initialState });
set(deletedChatIdsAtom, new Set<number>());

View file

@ -1,96 +1,96 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateSearchSpaceRequest,
DeleteSearchSpaceRequest,
UpdateSearchSpaceApiAccessRequest,
UpdateSearchSpaceRequest,
CreateWorkspaceRequest,
DeleteWorkspaceRequest,
UpdateWorkspaceApiAccessRequest,
UpdateWorkspaceRequest,
} from "@/contracts/types/workspace.types";
import { searchSpacesApiService } from "@/lib/apis/workspaces-api.service";
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
import { activeWorkspaceIdAtom } from "./workspace-query.atoms";
export const createSearchSpaceMutationAtom = atomWithMutation(() => {
export const createWorkspaceMutationAtom = atomWithMutation(() => {
return {
mutationKey: ["create-search-space"],
mutationFn: async (request: CreateSearchSpaceRequest) => {
return searchSpacesApiService.createSearchSpace(request);
mutationKey: ["create-workspace"],
mutationFn: async (request: CreateWorkspaceRequest) => {
return workspacesApiService.createWorkspace(request);
},
onSuccess: () => {
toast.success("Search space created successfully");
queryClient.invalidateQueries({
queryKey: cacheKeys.searchSpaces.all,
queryKey: cacheKeys.workspaces.all,
});
},
};
});
export const updateSearchSpaceMutationAtom = atomWithMutation((get) => {
const activeSearchSpaceId = get(activeWorkspaceIdAtom);
export const updateWorkspaceMutationAtom = atomWithMutation((get) => {
const activeWorkspaceId = get(activeWorkspaceIdAtom);
return {
mutationKey: ["update-search-space", activeSearchSpaceId],
enabled: !!activeSearchSpaceId,
mutationFn: async (request: UpdateSearchSpaceRequest) => {
return searchSpacesApiService.updateSearchSpace(request);
mutationKey: ["update-workspace", activeWorkspaceId],
enabled: !!activeWorkspaceId,
mutationFn: async (request: UpdateWorkspaceRequest) => {
return workspacesApiService.updateWorkspace(request);
},
onSuccess: (_, request: UpdateSearchSpaceRequest) => {
onSuccess: (_, request: UpdateWorkspaceRequest) => {
toast.success("Search space updated successfully");
queryClient.invalidateQueries({
queryKey: cacheKeys.searchSpaces.all,
queryKey: cacheKeys.workspaces.all,
});
if (request.id) {
queryClient.invalidateQueries({
queryKey: cacheKeys.searchSpaces.detail(String(request.id)),
queryKey: cacheKeys.workspaces.detail(String(request.id)),
});
}
},
};
});
export const updateSearchSpaceApiAccessMutationAtom = atomWithMutation((get) => {
const activeSearchSpaceId = get(activeWorkspaceIdAtom);
export const updateWorkspaceApiAccessMutationAtom = atomWithMutation((get) => {
const activeWorkspaceId = get(activeWorkspaceIdAtom);
return {
mutationKey: ["update-search-space-api-access", activeSearchSpaceId],
enabled: !!activeSearchSpaceId,
mutationFn: async (request: UpdateSearchSpaceApiAccessRequest) => {
return searchSpacesApiService.updateSearchSpaceApiAccess(request);
mutationKey: ["update-workspace-api-access", activeWorkspaceId],
enabled: !!activeWorkspaceId,
mutationFn: async (request: UpdateWorkspaceApiAccessRequest) => {
return workspacesApiService.updateWorkspaceApiAccess(request);
},
onSuccess: (_, request: UpdateSearchSpaceApiAccessRequest) => {
onSuccess: (_, request: UpdateWorkspaceApiAccessRequest) => {
toast.success("API access updated successfully");
queryClient.invalidateQueries({
queryKey: cacheKeys.searchSpaces.all,
queryKey: cacheKeys.workspaces.all,
});
queryClient.invalidateQueries({
queryKey: cacheKeys.searchSpaces.detail(String(request.id)),
queryKey: cacheKeys.workspaces.detail(String(request.id)),
});
},
};
});
export const deleteSearchSpaceMutationAtom = atomWithMutation((get) => {
const activeSearchSpaceId = get(activeWorkspaceIdAtom);
export const deleteWorkspaceMutationAtom = atomWithMutation((get) => {
const activeWorkspaceId = get(activeWorkspaceIdAtom);
return {
mutationKey: ["delete-search-space", activeSearchSpaceId],
enabled: !!activeSearchSpaceId,
mutationFn: async (request: DeleteSearchSpaceRequest) => {
return searchSpacesApiService.deleteSearchSpace(request);
mutationKey: ["delete-workspace", activeWorkspaceId],
enabled: !!activeWorkspaceId,
mutationFn: async (request: DeleteWorkspaceRequest) => {
return workspacesApiService.deleteWorkspace(request);
},
onSuccess: (_, request: DeleteSearchSpaceRequest) => {
onSuccess: (_, request: DeleteWorkspaceRequest) => {
toast.success("Search space deleted successfully");
queryClient.invalidateQueries({
queryKey: cacheKeys.searchSpaces.all,
queryKey: cacheKeys.workspaces.all,
});
if (request.id) {
queryClient.removeQueries({
queryKey: cacheKeys.searchSpaces.detail(String(request.id)),
queryKey: cacheKeys.workspaces.detail(String(request.id)),
});
}
},

View file

@ -1,25 +1,25 @@
import { atom } from "jotai";
import { atomWithQuery } from "jotai-tanstack-query";
import type { GetSearchSpacesRequest } from "@/contracts/types/workspace.types";
import { searchSpacesApiService } from "@/lib/apis/workspaces-api.service";
import type { GetWorkspacesRequest } from "@/contracts/types/workspace.types";
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export const activeWorkspaceIdAtom = atom<string | null>(null);
export const searchSpacesQueryParamsAtom = atom<GetSearchSpacesRequest["queryParams"]>({
export const workspacesQueryParamsAtom = atom<GetWorkspacesRequest["queryParams"]>({
skip: 0,
limit: 10,
owned_only: false,
});
export const searchSpacesAtom = atomWithQuery((get) => {
const queryParams = get(searchSpacesQueryParamsAtom);
export const workspacesAtom = atomWithQuery((get) => {
const queryParams = get(workspacesQueryParamsAtom);
return {
queryKey: cacheKeys.searchSpaces.withQueryParams(queryParams),
queryKey: cacheKeys.workspaces.withQueryParams(queryParams),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
return searchSpacesApiService.getSearchSpaces({
return workspacesApiService.getWorkspaces({
queryParams,
});
},

View file

@ -14,7 +14,7 @@ This release brings major improvements to **collaboration and user experience**.
#### New Chat-First Interface
- **Dashboard Removed**: Users now land directly in a chat for faster access
- **Redesigned Search Spaces**: Moved to a left column with color-coded icons and hover tooltips
- **Redesigned Workspaces**: Moved to a left column with color-coded icons and hover tooltips
- **Collapsible Sidebar**: New sidebar design with collapsible sections for private and group chats
- **Streamlined Settings**: Accessible through intuitive dropdown menus
- **Mobile-Responsive Design**: Better experience on all devices

View file

@ -16,8 +16,8 @@ This update brings **public sharing, image generation**, a redesigned Documents
#### Public Sharing
- **Public Chats**: Share snapshots of chats via public links.
- **Sharing Permissions**: Search Space owners control who can create and manage public links.
- **Link Management Page**: View and revoke all public chats from Search Space Settings.
- **Sharing Permissions**: Workspace owners control who can create and manage public links.
- **Link Management Page**: View and revoke all public chats from Workspace Settings.
#### Auto (Load Balanced) Mode

View file

@ -80,7 +80,7 @@ SurfSense now treats every sensitive AI action as an explicit, reviewable step.
- **Mobile-First Polish**: Mobile citation drawer, long-press actions in chats and documents, a responsive documents sidebar, and a mobile-friendly onboarding tour.
- **Reworked Composer**: Tool actions are grouped into a cleaner menu with better icons, plus a helpful "connect tools" banner.
- **Settings & Team**: New tabbed user settings page (profile + API keys), team roles management with pagination, and a search space settings dialog.
- **Settings & Team**: New tabbed user settings page (profile + API keys), team roles management with pagination, and a workspace settings dialog.
- **Right Panel & Docked Sidebar**: A tabbed Sources/Report panel with smooth transitions, plus an optional docked documents sidebar.
- **Community Prompts**: Public prompt library with copy support, inline share toggles, and a see-more/less toggle for long prompts.
- **New Homepage**: Smooth scrolling, a use-cases grid, an updated walkthrough hero, a GitHub stars badge, and a new carousel for AI-generated video.

View file

@ -44,7 +44,7 @@ The SurfSense desktop app becomes a serious always-on **AI like ChatGPT** that a
- **Multi-Suggestion UI**: The suggestion popup now offers up to **3 options** to pick from, with clean cards and quick-assist detection.
- **Knowledge Base Grounding**: Suggestions are grounded in your connected knowledge base, not just generic model output.
- **macOS Permission Onboarding**: A clearer onboarding page walks macOS users through the permissions the app needs, only when they're actually needed.
- **Switch Search Spaces from the Overlay**: Change the active search space without opening the main app.
- **Switch Workspaces from the Overlay**: Change the active workspace without opening the main app.
- **General Assist**: A new general-purpose assist mode with cleaner shortcut icons and descriptions.
- **Stay Signed In Everywhere**: Sign-in is now synchronized between the desktop app and the web app.
- **Vision Model Settings**: A dedicated Vision Models tab in Settings lets you pick and manage vision models, including a dynamic model list from OpenRouter.

View file

@ -21,7 +21,7 @@ Turn your knowledge and profile details into export-ready resume documents.
- **Cross-Site Anonymous Chat Cookies**: Anonymous chat cookies now adapt their SameSite and Secure settings based on deployment context, making hosted and cross-domain setups more reliable.
- **Better Anonymous Chat History**: Message history handling in anonymous chat is more dependable, especially when users move between public chat states.
- **Safer Form Inputs**: Login, registration, profile, search space, and role forms now enforce sensible max-length limits directly in the UI.
- **Safer Form Inputs**: Login, registration, profile, workspace, and role forms now enforce sensible max-length limits directly in the UI.
- **Cleaner Page Landmarks**: Home and free-chat pages no longer nest main landmarks, improving HTML semantics and screen-reader navigation.
- **SEO Metadata Refresh**: Titles and descriptions across key pages now better communicate SurfSense's open-source, privacy-focused positioning.

View file

@ -491,7 +491,7 @@ export const AssistantMessage: FC = () => {
const commentPanelRef = useRef<HTMLDivElement>(null);
const commentTriggerRef = useRef<HTMLButtonElement>(null);
const messageId = useAuiState(({ message }) => message?.id);
const searchSpaceId = useAtomValue(activeWorkspaceIdAtom);
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
const dbMessageId = parseMessageId(messageId);
const commentsEnabled = useAtomValue(commentsEnabledAtom);
@ -520,7 +520,7 @@ export const AssistantMessage: FC = () => {
const commentCount = commentsData?.total_count ?? 0;
const hasComments = commentCount > 0;
const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
const showCommentTrigger = workspaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
// Close floating panel when clicking outside (but not on portaled popover/dropdown content)
useEffect(() => {

View file

@ -36,10 +36,10 @@ interface ConnectorIndicatorProps {
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
(_props, ref) => {
const searchSpaceId = useAtomValue(activeWorkspaceIdAtom);
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
// Real-time document type counts via Zero (updates instantly as docs are indexed)
const documentTypeCounts = useZeroDocumentTypeCounts(searchSpaceId);
const documentTypeCounts = useZeroDocumentTypeCounts(workspaceId);
// Read status inbox items from shared atom (populated by LayoutDataProvider)
// instead of creating a duplicate useInbox("status") hook.
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
@ -124,7 +124,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
loading: connectorsLoading,
error: connectorsError,
refreshConnectors: refreshConnectorsSync,
} = useConnectorsSync(searchSpaceId);
} = useConnectorsSync(workspaceId);
const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError);
const connectors = useSyncData ? connectorsFromSync : allConnectors || [];
@ -142,7 +142,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
inboxItems
);
// Get document types that have documents in the search space
// Get document types that have documents in the workspace
const activeDocumentTypes = documentTypeCounts
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
: [];
@ -163,7 +163,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
open: () => handleOpenChange(true),
}));
if (!searchSpaceId) return null;
if (!workspaceId) return null;
return (
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
@ -191,8 +191,8 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
>
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? (
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
{isYouTubeView && workspaceId ? (
<YouTubeCrawlerView workspaceId={workspaceId} onBack={handleBackFromYouTube} />
) : viewingMCPList ? (
<ConnectorAccountsListView
connectorType="MCP_CONNECTOR"
@ -253,7 +253,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
isSaving={isSaving}
isDisconnecting={isDisconnecting}
isIndexing={indexingConnectorIds.has(editingConnector.id)}
searchSpaceId={searchSpaceId?.toString()}
workspaceId={workspaceId?.toString()}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
@ -346,7 +346,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
<TabsContent value="all" className="m-0">
<AllConnectorsTab
searchQuery={searchQuery}
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
connectedTypes={connectedTypes}
connectingId={connectingId}
allConnectors={connectors}

View file

@ -159,17 +159,17 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
<div className="h-px bg-border/60" />
{/* Step 4 — Pick search space */}
{/* Step 4 — Pick workspace */}
<article>
<header className="mb-3 flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-md border border-slate-400/30 text-xs font-medium">
4
</div>
<h3 className="text-sm font-medium sm:text-base">Pick this search space</h3>
<h3 className="text-sm font-medium sm:text-base">Pick this workspace</h3>
</header>
<p className="text-[11px] text-muted-foreground sm:text-xs">
In the plugin's <span className="font-medium">Search space</span> setting, choose the
search space you want this vault to sync into. The connector will appear here
workspace you want this vault to sync into. The connector will appear here
automatically once the plugin makes its first sync.
</p>
</article>

View file

@ -7,7 +7,7 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
LINEAR_CONNECTOR: [
"Search through all your Linear issues and comments",
"Access issue titles, descriptions, and full discussion threads",
"Connect your team's project management directly to your search space",
"Connect your team's project management directly to your workspace",
"Keep your search results up-to-date with latest Linear content",
"Index your Linear issues for enhanced search capabilities",
],
@ -36,63 +36,63 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
SLACK_CONNECTOR: [
"Search through all your Slack messages and conversations",
"Access messages from public and private channels",
"Connect your team's communications directly to your search space",
"Connect your team's communications directly to your workspace",
"Keep your search results up-to-date with latest Slack content",
"Index your Slack conversations for enhanced search capabilities",
],
DISCORD_CONNECTOR: [
"Search through all your Discord messages and conversations",
"Access messages from all accessible channels",
"Connect your community's communications directly to your search space",
"Connect your community's communications directly to your workspace",
"Keep your search results up-to-date with latest Discord content",
"Index your Discord conversations for enhanced search capabilities",
],
NOTION_CONNECTOR: [
"Search through all your Notion pages and databases",
"Access page content, properties, and metadata",
"Connect your knowledge base directly to your search space",
"Connect your knowledge base directly to your workspace",
"Keep your search results up-to-date with latest Notion content",
"Index your Notion workspace for enhanced search capabilities",
],
CONFLUENCE_CONNECTOR: [
"Search through all your Confluence pages and spaces",
"Access page content, comments, and attachments",
"Connect your team's documentation directly to your search space",
"Connect your team's documentation directly to your workspace",
"Keep your search results up-to-date with latest Confluence content",
"Index your Confluence workspace for enhanced search capabilities",
],
BOOKSTACK_CONNECTOR: [
"Search through all your BookStack pages and books",
"Access page content, chapters, and documentation",
"Connect your documentation directly to your search space",
"Connect your documentation directly to your workspace",
"Keep your search results up-to-date with latest BookStack content",
"Index your BookStack instance for enhanced search capabilities",
],
GITHUB_CONNECTOR: [
"Search through code, issues, and documentation from GitHub repositories",
"Access repository content, pull requests, and discussions",
"Connect your codebase directly to your search space",
"Connect your codebase directly to your workspace",
"Keep your search results up-to-date with latest GitHub content",
"Index your GitHub repositories for enhanced search capabilities",
],
JIRA_CONNECTOR: [
"Search through all your Jira issues and tickets",
"Access issue descriptions, comments, and project data",
"Connect your project management directly to your search space",
"Connect your project management directly to your workspace",
"Keep your search results up-to-date with latest Jira content",
"Index your Jira projects for enhanced search capabilities",
],
CLICKUP_CONNECTOR: [
"Search through all your ClickUp tasks and projects",
"Access task descriptions, comments, and project data",
"Connect your task management directly to your search space",
"Connect your task management directly to your workspace",
"Keep your search results up-to-date with latest ClickUp content",
"Index your ClickUp workspace for enhanced search capabilities",
],
LUMA_CONNECTOR: [
"Search through all your Luma events",
"Access event details, descriptions, and attendee information",
"Connect your events directly to your search space",
"Connect your events directly to your workspace",
"Keep your search results up-to-date with latest Luma content",
"Index your Luma events for enhanced search capabilities",
],

View file

@ -165,7 +165,7 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
<AlertDescription>
Configure this URL in Circleback Settings Automations Create automation Send
webhook request. The webhook will automatically send meeting notes, transcripts, and
action items to this search space.
action items to this workspace.
</AlertDescription>
</Alert>
)}

View file

@ -8,7 +8,7 @@ export interface ConnectorConfigProps {
connector: SearchSourceConnector;
onConfigChange?: (config: Record<string, unknown>) => void;
onNameChange?: (name: string) => void;
searchSpaceId?: string;
workspaceId?: string;
}
export type ConnectorConfigComponent = FC<ConnectorConfigProps>;

View file

@ -41,7 +41,7 @@ interface ConnectorEditViewProps {
isSaving: boolean;
isDisconnecting: boolean;
isIndexing?: boolean;
searchSpaceId?: string;
workspaceId?: string;
onStartDateChange: (date: Date | undefined) => void;
onEndDateChange: (date: Date | undefined) => void;
onPeriodicEnabledChange: (enabled: boolean) => void;
@ -65,7 +65,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
isSaving,
isDisconnecting,
isIndexing = false,
searchSpaceId,
workspaceId,
onStartDateChange,
onEndDateChange,
onPeriodicEnabledChange,
@ -78,7 +78,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
onConfigChange,
onNameChange,
}) => {
const searchSpaceIdAtom = useAtomValue(activeWorkspaceIdAtom);
const workspaceIdAtom = useAtomValue(activeWorkspaceIdAtom);
const isAuthExpired = connector.config?.auth_expired === true;
const reauthEndpoint = getReauthEndpoint(connector);
const [reauthing, setReauthing] = useState(false);
@ -91,7 +91,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
(connector.is_indexable || connector.connector_type === EnumConnectorName.OBSIDIAN_CONNECTOR);
const handleReauth = useCallback(async () => {
const spaceId = searchSpaceId ?? searchSpaceIdAtom;
const spaceId = workspaceId ?? workspaceIdAtom;
if (!spaceId || !reauthEndpoint) return;
setReauthing(true);
try {
@ -119,7 +119,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
} finally {
setReauthing(false);
}
}, [searchSpaceId, searchSpaceIdAtom, reauthEndpoint, connector.id]);
}, [workspaceId, workspaceIdAtom, reauthEndpoint, connector.id]);
// Get connector-specific config component (MCP-backed connectors use a generic view)
const ConnectorConfigComponent = useMemo(() => {
@ -273,7 +273,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
connector={connector}
onConfigChange={onConfigChange}
onNameChange={onNameChange}
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
/>
)}

Some files were not shown because too many files have changed in this diff Show more