Merge remote-tracking branch 'upstream/dev' into feat/ui-revamp

This commit is contained in:
Anish Sarkar 2026-05-18 09:39:35 +05:30
commit 87caa4b6d0
29 changed files with 1622 additions and 891 deletions

View file

@ -83,7 +83,7 @@ services:
retries: 5 retries: 5
zero-cache: zero-cache:
image: rocicorp/zero:0.26.2 image: rocicorp/zero:1.4.0
ports: ports:
- "${ZERO_CACHE_PORT:-4848}:4848" - "${ZERO_CACHE_PORT:-4848}:4848"
extra_hosts: extra_hosts:

View file

@ -179,7 +179,7 @@ services:
# - celery_worker # - celery_worker
zero-cache: zero-cache:
image: rocicorp/zero:0.26.2 image: rocicorp/zero:1.4.0
ports: ports:
- "${ZERO_CACHE_PORT:-4848}:4848" - "${ZERO_CACHE_PORT:-4848}:4848"
extra_hosts: extra_hosts:

View file

@ -163,7 +163,7 @@ services:
# restart: unless-stopped # restart: unless-stopped
zero-cache: zero-cache:
image: rocicorp/zero:0.26.2 image: rocicorp/zero:1.4.0
ports: ports:
- "${ZERO_CACHE_PORT:-5929}:4848" - "${ZERO_CACHE_PORT:-5929}:4848"
extra_hosts: extra_hosts:

View file

@ -5,6 +5,17 @@ queries via Zero, instead of replicating all tables in public schema.
See: https://zero.rocicorp.dev/docs/zero-cache-config#app-publications See: https://zero.rocicorp.dev/docs/zero-cache-config#app-publications
NOTE for future migration authors: this is the ONLY migration allowed
to use bare ``CREATE PUBLICATION``. All subsequent mutations of
``zero_publication`` MUST use the ``COMMENT ON PUBLICATION`` bookend
pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the
``upgrade()`` function from migration
``143_force_zero_publication_resync.py`` as your starting template.
Raw ``DROP``/``CREATE PUBLICATION`` in new migrations would
re-introduce bug #1355 (zero-cache stuck on a stale replica snapshot
because Zero >= 1.0's change-streamer never sees the schema-change
event).
Revision ID: 116 Revision ID: 116
Revises: 115 Revises: 115
""" """

View file

@ -17,6 +17,16 @@ IMPORTANT — before AND after running this migration:
3. Delete / reset the zero-cache data volume 3. Delete / reset the zero-cache data volume
4. Restart zero-cache (it will do a fresh initial sync) 4. Restart zero-cache (it will do a fresh initial sync)
DO NOT COPY THIS PATTERN. The ``DROP PUBLICATION`` + ``CREATE
PUBLICATION`` dance below is the pre-#1355 anti-pattern: on Zero >=
1.0 it does not reliably wake the zero-cache change-streamer and can
leave the replica pinned to a stale snapshot. This file is
grandfathered in because it has already shipped to users; new
publication mutations MUST use the ``COMMENT ON PUBLICATION`` bookend
pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the
``upgrade()`` function from migration
``143_force_zero_publication_resync.py`` as your starting template.
Revision ID: 117 Revision ID: 117
Revises: 116 Revises: 116
""" """

View file

@ -1,5 +1,16 @@
"""Add LOCAL_FOLDER_FILE document type, folder metadata, and document_versions table """Add LOCAL_FOLDER_FILE document type, folder metadata, and document_versions table
DO NOT COPY THIS PATTERN. The bare ``ALTER PUBLICATION ... ADD/DROP
TABLE`` calls below pre-date the ``COMMENT ON PUBLICATION`` bookend
fix for bug #1355: on Zero >= 1.0 they do not reliably wake the
zero-cache change-streamer and can leave the replica pinned to a
stale snapshot. This file is grandfathered in because it has already
shipped to users; new publication mutations MUST use the
``COMMENT ON PUBLICATION`` bookend pattern wrapping an
``ALTER PUBLICATION ... SET TABLE`` -- copy the ``upgrade()`` function
from migration ``143_force_zero_publication_resync.py`` as your
starting template.
Revision ID: 118 Revision ID: 118
Revises: 117 Revises: 117
""" """

View file

@ -21,6 +21,16 @@ IMPORTANT - before AND after running this migration:
3. Delete / reset the zero-cache data volume 3. Delete / reset the zero-cache data volume
4. Restart zero-cache (it will do a fresh initial sync) 4. Restart zero-cache (it will do a fresh initial sync)
DO NOT COPY THIS PATTERN. The ``DROP PUBLICATION`` + ``CREATE
PUBLICATION`` dance below is the pre-#1355 anti-pattern: on Zero >=
1.0 it does not reliably wake the zero-cache change-streamer and can
leave the replica pinned to a stale snapshot. This file is
grandfathered in because it has already shipped to users; new
publication mutations MUST use the ``COMMENT ON PUBLICATION`` bookend
pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the
``upgrade()`` function from migration
``143_force_zero_publication_resync.py`` as your starting template.
Revision ID: 139 Revision ID: 139
Revises: 138 Revises: 138
""" """

View file

@ -32,6 +32,16 @@ Skipping the zero-cache stop will deadlock at the ACCESS EXCLUSIVE LOCK on
"user". Skipping the data-volume reset will leave IndexedDB clients seeing "user". Skipping the data-volume reset will leave IndexedDB clients seeing
column-not-found errors from a stale catalog snapshot. column-not-found errors from a stale catalog snapshot.
DO NOT COPY THIS PATTERN. The ``DROP PUBLICATION`` + ``CREATE
PUBLICATION`` dance below is the pre-#1355 anti-pattern: on Zero >=
1.0 it does not reliably wake the zero-cache change-streamer and can
leave the replica pinned to a stale snapshot. This file is
grandfathered in because it has already shipped to users; new
publication mutations MUST use the ``COMMENT ON PUBLICATION`` bookend
pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the
``upgrade()`` function from migration
``143_force_zero_publication_resync.py`` as your starting template.
Revision ID: 140 Revision ID: 140
Revises: 139 Revises: 139
""" """

View file

@ -0,0 +1,142 @@
"""force zero-cache to resync after upgrading to Zero >= 1.0
Re-emits the current ``zero_publication`` shape using
``ALTER PUBLICATION ... SET TABLE`` wrapped in
``COMMENT ON PUBLICATION`` bookends. This is the publication-change
hook documented for Zero ``>=1.0``:
https://zero.rocicorp.dev/docs/connecting-to-postgres#publication-changes
Background
----------
Migrations 117 / 139 / 140 mutated ``zero_publication`` using
``DROP PUBLICATION`` + ``CREATE PUBLICATION``. On Zero 0.26.2 that
sequence did not reliably wake the zero-cache change-streamer, so
affected installs ended up with a SQLite replica file (in the
``surfsense-zero-cache`` volume) that was snapshotted against the
pre-``user`` publication. The frontend Zero schema includes a
``userTable`` query, which then failed with
``SchemaVersionNotSupported`` and triggered the default
``onUpdateNeeded`` -> ``location.reload()`` every WebSocket keepalive
interval (~60s). See bug #1355.
This migration emits the canonical publication shape one more time,
this time using a pattern that fires Postgres event triggers and
Zero's schema-change hook. With ``ZERO_AUTO_RESET=true`` (the default)
and Zero ``>=1.0``, zero-cache responds by wiping its replica and
doing a fresh initial sync from the corrected publication.
The publication shape itself is unchanged versus migration 140 -- on
installs whose replica is already correct, this is a no-op aside
from the harmless event-trigger fire.
Revision ID: 143
Revises: 142
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "143"
down_revision: str | None = "142"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
PUBLICATION_NAME = "zero_publication"
# Must stay in sync with the column lists in migrations 117 / 139 / 140.
DOCUMENT_COLS = [
"id",
"title",
"document_type",
"search_space_id",
"folder_id",
"created_by_id",
"status",
"created_at",
"updated_at",
]
USER_COLS = [
"id",
"pages_limit",
"pages_used",
"premium_credit_micros_limit",
"premium_credit_micros_used",
]
def _has_zero_version(conn, table: str) -> bool:
return (
conn.execute(
sa.text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :tbl AND column_name = '_0_version'"
),
{"tbl": table},
).fetchone()
is not None
)
def _build_set_table_ddl(
*, documents_has_zero_ver: bool, user_has_zero_ver: bool
) -> str:
doc_cols = DOCUMENT_COLS + (['"_0_version"'] if documents_has_zero_ver else [])
user_cols = USER_COLS + (['"_0_version"'] if user_has_zero_ver else [])
doc_col_list = ", ".join(doc_cols)
user_col_list = ", ".join(user_cols)
return (
f"ALTER PUBLICATION {PUBLICATION_NAME} SET TABLE "
f"notifications, "
f"documents ({doc_col_list}), "
f"folders, "
f"search_source_connectors, "
f"new_chat_messages, "
f"chat_comments, "
f"chat_session_state, "
f'"user" ({user_col_list})'
)
def upgrade() -> None:
conn = op.get_bind()
exists = conn.execute(
sa.text("SELECT 1 FROM pg_publication WHERE pubname = :name"),
{"name": PUBLICATION_NAME},
).fetchone()
if not exists:
return
documents_has_zero_ver = _has_zero_version(conn, "documents")
user_has_zero_ver = _has_zero_version(conn, "user")
# The COMMENT-ALTER-COMMENT trio MUST run in a single transaction so
# Zero observes them as one schema-change event. Alembic's outer
# transaction already covers us, but a SAVEPOINT keeps the trio
# atomic with asyncpg, matching the pattern used in migrations
# 117 / 139 / 140.
tx = conn.begin_nested() if conn.in_transaction() else conn.begin()
with tx:
conn.execute(
sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-143-resync'")
)
conn.execute(
sa.text(
_build_set_table_ddl(
documents_has_zero_ver=documents_has_zero_ver,
user_has_zero_ver=user_has_zero_ver,
)
)
)
conn.execute(
sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-143-resync'")
)
def downgrade() -> None:
"""No-op. The publication shape is unchanged versus migration 140."""

View file

@ -1491,14 +1491,20 @@ async def stream_new_chat(
# Resolve @-mention chips to canonical virtual paths and rewrite # Resolve @-mention chips to canonical virtual paths and rewrite
# the user-typed text so the LLM sees ``\`/documents/...\``` instead # the user-typed text so the LLM sees ``\`/documents/...\``` instead
# of bare ``@title``. The persisted user-message text keeps # of bare ``@title``. The substitution lands in ``agent_user_query``
# ``@title`` so chip rendering on reload is unchanged — see # ONLY — the original ``user_query`` (with ``@title`` tokens) flows
# ``persistence._build_user_content``. # untouched into ``persist_user_turn`` below so chip rendering on
# reload still works (``UserTextPart`` → ``parseMentionSegments``
# matches ``@title``, not ``\`/documents/...\```). It also feeds
# the human-readable surfaces — SSE "Processing X" status, auto
# thread title, memory seed — which all want what the user typed.
# See ``persistence._build_user_content``.
# #
# Cloud mode only: local-folder mode keeps the legacy # Cloud mode only: local-folder mode keeps the legacy
# ``@title`` text path; mention support there is a follow-up # ``@title`` text path; mention support there is a follow-up
# task because the path scheme (mount-rooted) and the picker # task because the path scheme (mount-rooted) and the picker
# UI both need separate work. # UI both need separate work.
agent_user_query = user_query
accepted_folder_ids: list[int] = [] accepted_folder_ids: list[int] = []
if fs_mode == FilesystemMode.CLOUD.value and ( if fs_mode == FilesystemMode.CLOUD.value and (
mentioned_document_ids mentioned_document_ids
@ -1533,11 +1539,13 @@ async def stream_new_chat(
mentioned_surfsense_doc_ids=mentioned_surfsense_doc_ids, mentioned_surfsense_doc_ids=mentioned_surfsense_doc_ids,
mentioned_folder_ids=mentioned_folder_ids, mentioned_folder_ids=mentioned_folder_ids,
) )
user_query = substitute_in_text(user_query, resolved.token_to_path) agent_user_query = substitute_in_text(user_query, resolved.token_to_path)
accepted_folder_ids = resolved.mentioned_folder_ids accepted_folder_ids = resolved.mentioned_folder_ids
# Format the user query with context (SurfSense docs + reports only) # Format the user query with context (SurfSense docs + reports only).
final_query = user_query # Uses ``agent_user_query`` so the LLM sees backtick-wrapped paths
# instead of bare ``@title`` tokens.
final_query = agent_user_query
context_parts = [] context_parts = []
if mentioned_surfsense_docs: if mentioned_surfsense_docs:
@ -1568,7 +1576,7 @@ async def stream_new_chat(
if context_parts: if context_parts:
context = "\n\n".join(context_parts) context = "\n\n".join(context_parts)
final_query = f"{context}\n\n<user_query>{user_query}</user_query>" final_query = f"{context}\n\n<user_query>{agent_user_query}</user_query>"
if visibility == ChatVisibility.SEARCH_SPACE and current_user_display_name: if visibility == ChatVisibility.SEARCH_SPACE and current_user_display_name:
final_query = f"**[{current_user_display_name}]:** {final_query}" final_query = f"**[{current_user_display_name}]:** {final_query}"

View file

@ -350,6 +350,25 @@ def _drive_list_files(args: dict[str, Any]) -> dict[str, Any]:
folder id and serve the matching fixture list. folder id and serve the matching fixture list.
""" """
q = args.get("q", "") q = args.get("q", "")
if "in owners" in q:
return {
"data": {
"files": [
{
"id": "fake-file-owner-probe",
"name": "owner-probe",
"owners": [
{
"me": True,
"emailAddress": "e2e-fake@surfsense.example",
}
],
}
],
"nextPageToken": None,
}
}
folder_id = "root" folder_id = "root"
if "in parents" in q: if "in parents" in q:
# q looks like: '<folder_id>' in parents and trashed = false ... # q looks like: '<folder_id>' in parents and trashed = false ...

View file

@ -1,13 +1,11 @@
"""Composio route integration fixtures. """Composio route integration fixtures.
The sys.modules hijack happens at module import time, before importing The `composio` sys.modules hijack lives in the parent integration conftest
app.app, so production `from composio import Composio` bindings resolve to so it runs before any sibling suite imports `app.routes`.
the strict E2E fake in this pytest process too.
""" """
from __future__ import annotations from __future__ import annotations
import sys
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
import httpx import httpx
@ -16,19 +14,15 @@ import pytest_asyncio
from httpx import ASGITransport from httpx import ASGITransport
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from tests.e2e.fakes import composio_module as _fake_composio from app.app import app, limiter
from app.config import config
sys.modules["composio"] = _fake_composio from app.db import (
from app.app import app, limiter # noqa: E402
from app.config import config # noqa: E402
from app.db import ( # noqa: E402
SearchSourceConnector, SearchSourceConnector,
SearchSourceConnectorType, SearchSourceConnectorType,
User, User,
get_async_session, get_async_session,
) )
from app.users import current_active_user # noqa: E402 from app.users import current_active_user
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration

View file

@ -1,3 +1,5 @@
import importlib
import sys
import uuid import uuid
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@ -7,17 +9,27 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
from app.config import config as app_config # Hijack `composio` before any `from app.*` import; the `from composio import
from app.db import ( # Composio` in app.services.composio_service binds once at first import.
Base, from tests.e2e.fakes import composio_module as _fake_composio
DocumentType,
SearchSourceConnector, sys.modules["composio"] = _fake_composio
SearchSourceConnectorType,
SearchSpace, app_config = importlib.import_module("app.config").config
User, app_db = importlib.import_module("app.db")
) Base = app_db.Base
from app.indexing_pipeline.connector_document import ConnectorDocument DocumentType = app_db.DocumentType
from tests.conftest import TEST_DATABASE_URL SearchSourceConnector = app_db.SearchSourceConnector
SearchSourceConnectorType = app_db.SearchSourceConnectorType
SearchSpace = app_db.SearchSpace
User = app_db.User
ConnectorDocument = importlib.import_module(
"app.indexing_pipeline.connector_document"
).ConnectorDocument
create_default_roles_and_membership = importlib.import_module(
"app.routes.search_spaces_routes"
).create_default_roles_and_membership
TEST_DATABASE_URL = importlib.import_module("tests.conftest").TEST_DATABASE_URL
_EMBEDDING_DIM = app_config.embedding_model_instance.dimension _EMBEDDING_DIM = app_config.embedding_model_instance.dimension
@ -105,6 +117,9 @@ async def db_search_space(db_session: AsyncSession, db_user: User) -> SearchSpac
) )
db_session.add(space) db_session.add(space)
await db_session.flush() await db_session.flush()
# Mirror POST /searchspaces so routes guarded by check_permission find a membership.
await create_default_roles_and_membership(db_session, space.id, db_user.id)
await db_session.flush()
return space return space
@ -145,6 +160,10 @@ def patched_chunk_text(monkeypatch) -> MagicMock:
"app.indexing_pipeline.indexing_pipeline_service.chunk_text", "app.indexing_pipeline.indexing_pipeline_service.chunk_text",
mock, mock,
) )
monkeypatch.setattr(
"app.indexing_pipeline.indexing_pipeline_service.chunk_text_hybrid",
mock,
)
return mock return mock

View file

@ -204,6 +204,7 @@ class TestStripeCheckoutSessionCreation:
assert ( assert (
fake_client.last_params["success_url"] fake_client.last_params["success_url"]
== f"http://localhost:3000/dashboard/{search_space_id}/purchase-success" == f"http://localhost:3000/dashboard/{search_space_id}/purchase-success"
"?session_id={CHECKOUT_SESSION_ID}"
) )
assert ( assert (
fake_client.last_params["cancel_url"] fake_client.last_params["cancel_url"]

View file

@ -7,7 +7,7 @@ mocked at their system boundaries.
from __future__ import annotations from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@ -25,6 +25,7 @@ pytestmark = pytest.mark.integration
_COMPOSIO_ACCOUNT_ID = "composio-calendar-test-789" _COMPOSIO_ACCOUNT_ID = "composio-calendar-test-789"
_INDEXER_MODULE = "app.tasks.connector_indexers.google_calendar_indexer" _INDEXER_MODULE = "app.tasks.connector_indexers.google_calendar_indexer"
_GET_ACCESS_TOKEN = "app.services.composio_service.ComposioService.get_access_token"
@pytest_asyncio.fixture @pytest_asyncio.fixture
@ -69,32 +70,29 @@ async def native_calendar(async_engine):
await cleanup_space(async_engine, data["search_space_id"]) await cleanup_space(async_engine, data["search_space_id"])
@patch(_GET_ACCESS_TOKEN)
@patch(f"{_INDEXER_MODULE}.TaskLoggingService") @patch(f"{_INDEXER_MODULE}.TaskLoggingService")
@patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector") @patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") @patch(f"{_INDEXER_MODULE}.ComposioService")
async def test_composio_calendar_uses_composio_credentials( async def test_composio_calendar_uses_composio_service(
mock_build_creds, mock_composio_service_cls,
mock_cal_cls, mock_cal_cls,
mock_tl_cls, mock_tl_cls,
mock_get_access_token,
async_engine, async_engine,
composio_calendar, composio_calendar,
): ):
"""Calendar indexer calls build_composio_credentials for a Composio connector.""" """Calendar indexer uses Composio tools directly for a Composio connector."""
from app.tasks.connector_indexers.google_calendar_indexer import ( from app.tasks.connector_indexers.google_calendar_indexer import (
index_google_calendar_events, index_google_calendar_events,
) )
data = composio_calendar data = composio_calendar
mock_creds = MagicMock(name="composio-creds") mock_composio_service = MagicMock()
mock_build_creds.return_value = mock_creds mock_composio_service.get_calendar_events = AsyncMock(return_value=([], None))
mock_composio_service_cls.return_value = mock_composio_service
mock_tl_cls.return_value = mock_task_logger() mock_tl_cls.return_value = mock_task_logger()
mock_cal_instance = MagicMock()
mock_cal_instance.get_all_primary_calendar_events = AsyncMock(
return_value=([], None)
)
mock_cal_cls.return_value = mock_cal_instance
maker = make_session_factory(async_engine) maker = make_session_factory(async_engine)
async with maker() as session: async with maker() as session:
await index_google_calendar_events( await index_google_calendar_events(
@ -104,17 +102,25 @@ async def test_composio_calendar_uses_composio_credentials(
user_id=data["user_id"], user_id=data["user_id"],
) )
mock_build_creds.assert_called_once_with(_COMPOSIO_ACCOUNT_ID) mock_composio_service_cls.assert_called_once()
mock_cal_cls.assert_called_once() mock_composio_service.get_calendar_events.assert_called_once_with(
_, kwargs = mock_cal_cls.call_args connected_account_id=_COMPOSIO_ACCOUNT_ID,
assert kwargs.get("credentials") is mock_creds entity_id=f"surfsense_{data['user_id']}",
time_min=ANY,
time_max=ANY,
max_results=250,
)
mock_cal_cls.assert_not_called()
mock_get_access_token.assert_not_called()
@patch(_GET_ACCESS_TOKEN)
@patch(f"{_INDEXER_MODULE}.TaskLoggingService") @patch(f"{_INDEXER_MODULE}.TaskLoggingService")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") @patch(f"{_INDEXER_MODULE}.ComposioService")
async def test_composio_calendar_without_account_id_returns_error( async def test_composio_calendar_without_account_id_returns_error(
mock_build_creds, mock_composio_service_cls,
mock_tl_cls, mock_tl_cls,
mock_get_access_token,
async_engine, async_engine,
composio_calendar_no_id, composio_calendar_no_id,
): ):
@ -138,20 +144,23 @@ async def test_composio_calendar_without_account_id_returns_error(
assert count == 0 assert count == 0
assert error is not None assert error is not None
assert "composio" in error.lower() assert "composio" in error.lower()
mock_build_creds.assert_not_called() mock_composio_service_cls.assert_not_called()
mock_get_access_token.assert_not_called()
@patch(_GET_ACCESS_TOKEN)
@patch(f"{_INDEXER_MODULE}.TaskLoggingService") @patch(f"{_INDEXER_MODULE}.TaskLoggingService")
@patch(f"{_INDEXER_MODULE}.ComposioService")
@patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector") @patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") async def test_native_calendar_uses_google_calendar_connector(
async def test_native_calendar_does_not_use_composio_credentials(
mock_build_creds,
mock_cal_cls, mock_cal_cls,
mock_composio_service_cls,
mock_tl_cls, mock_tl_cls,
mock_get_access_token,
async_engine, async_engine,
native_calendar, native_calendar,
): ):
"""Calendar indexer does NOT call build_composio_credentials for a native connector.""" """Native Calendar connector uses GoogleCalendarConnector with no Composio path."""
from app.tasks.connector_indexers.google_calendar_indexer import ( from app.tasks.connector_indexers.google_calendar_indexer import (
index_google_calendar_events, index_google_calendar_events,
) )
@ -174,4 +183,6 @@ async def test_native_calendar_does_not_use_composio_credentials(
user_id=data["user_id"], user_id=data["user_id"],
) )
mock_build_creds.assert_not_called() mock_cal_cls.assert_called_once()
mock_composio_service_cls.assert_not_called()
mock_get_access_token.assert_not_called()

View file

@ -7,7 +7,7 @@ mocked at their system boundaries.
from __future__ import annotations from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@ -25,6 +25,7 @@ pytestmark = pytest.mark.integration
_COMPOSIO_ACCOUNT_ID = "composio-gmail-test-456" _COMPOSIO_ACCOUNT_ID = "composio-gmail-test-456"
_INDEXER_MODULE = "app.tasks.connector_indexers.google_gmail_indexer" _INDEXER_MODULE = "app.tasks.connector_indexers.google_gmail_indexer"
_GET_ACCESS_TOKEN = "app.services.composio_service.ComposioService.get_access_token"
@pytest_asyncio.fixture @pytest_asyncio.fixture
@ -69,30 +70,32 @@ async def native_gmail(async_engine):
await cleanup_space(async_engine, data["search_space_id"]) await cleanup_space(async_engine, data["search_space_id"])
@patch(_GET_ACCESS_TOKEN)
@patch(f"{_INDEXER_MODULE}.TaskLoggingService") @patch(f"{_INDEXER_MODULE}.TaskLoggingService")
@patch(f"{_INDEXER_MODULE}.GoogleGmailConnector") @patch(f"{_INDEXER_MODULE}.GoogleGmailConnector")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") @patch(f"{_INDEXER_MODULE}.ComposioService")
async def test_composio_gmail_uses_composio_credentials( async def test_composio_gmail_uses_composio_service(
mock_build_creds, mock_composio_service_cls,
mock_gmail_cls, mock_gmail_cls,
mock_tl_cls, mock_tl_cls,
mock_get_access_token,
async_engine, async_engine,
composio_gmail, composio_gmail,
): ):
"""Gmail indexer calls build_composio_credentials for a Composio connector.""" """Gmail indexer uses Composio tools directly for a Composio connector."""
from app.tasks.connector_indexers.google_gmail_indexer import ( from app.tasks.connector_indexers.google_gmail_indexer import (
index_google_gmail_messages, index_google_gmail_messages,
) )
data = composio_gmail data = composio_gmail
mock_creds = MagicMock(name="composio-creds") mock_composio_service = MagicMock()
mock_build_creds.return_value = mock_creds mock_composio_service.get_gmail_messages = AsyncMock(
return_value=([], None, None, None)
)
mock_composio_service.get_gmail_message_detail = AsyncMock(return_value=({}, None))
mock_composio_service_cls.return_value = mock_composio_service
mock_tl_cls.return_value = mock_task_logger() mock_tl_cls.return_value = mock_task_logger()
mock_gmail_instance = MagicMock()
mock_gmail_instance.get_recent_messages = AsyncMock(return_value=([], None))
mock_gmail_cls.return_value = mock_gmail_instance
maker = make_session_factory(async_engine) maker = make_session_factory(async_engine)
async with maker() as session: async with maker() as session:
await index_google_gmail_messages( await index_google_gmail_messages(
@ -102,17 +105,25 @@ async def test_composio_gmail_uses_composio_credentials(
user_id=data["user_id"], user_id=data["user_id"],
) )
mock_build_creds.assert_called_once_with(_COMPOSIO_ACCOUNT_ID) mock_composio_service_cls.assert_called_once()
mock_gmail_cls.assert_called_once() mock_composio_service.get_gmail_messages.assert_called_once_with(
args, _ = mock_gmail_cls.call_args connected_account_id=_COMPOSIO_ACCOUNT_ID,
assert args[0] is mock_creds entity_id=f"surfsense_{data['user_id']}",
query=ANY,
max_results=ANY,
page_token=None,
)
mock_gmail_cls.assert_not_called()
mock_get_access_token.assert_not_called()
@patch(_GET_ACCESS_TOKEN)
@patch(f"{_INDEXER_MODULE}.TaskLoggingService") @patch(f"{_INDEXER_MODULE}.TaskLoggingService")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") @patch(f"{_INDEXER_MODULE}.ComposioService")
async def test_composio_gmail_without_account_id_returns_error( async def test_composio_gmail_without_account_id_returns_error(
mock_build_creds, mock_composio_service_cls,
mock_tl_cls, mock_tl_cls,
mock_get_access_token,
async_engine, async_engine,
composio_gmail_no_id, composio_gmail_no_id,
): ):
@ -136,20 +147,23 @@ async def test_composio_gmail_without_account_id_returns_error(
assert count == 0 assert count == 0
assert error is not None assert error is not None
assert "composio" in error.lower() assert "composio" in error.lower()
mock_build_creds.assert_not_called() mock_composio_service_cls.assert_not_called()
mock_get_access_token.assert_not_called()
@patch(_GET_ACCESS_TOKEN)
@patch(f"{_INDEXER_MODULE}.TaskLoggingService") @patch(f"{_INDEXER_MODULE}.TaskLoggingService")
@patch(f"{_INDEXER_MODULE}.ComposioService")
@patch(f"{_INDEXER_MODULE}.GoogleGmailConnector") @patch(f"{_INDEXER_MODULE}.GoogleGmailConnector")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") async def test_native_gmail_uses_google_gmail_connector(
async def test_native_gmail_does_not_use_composio_credentials(
mock_build_creds,
mock_gmail_cls, mock_gmail_cls,
mock_composio_service_cls,
mock_tl_cls, mock_tl_cls,
mock_get_access_token,
async_engine, async_engine,
native_gmail, native_gmail,
): ):
"""Gmail indexer does NOT call build_composio_credentials for a native connector.""" """Native Gmail connector uses GoogleGmailConnector with no Composio path."""
from app.tasks.connector_indexers.google_gmail_indexer import ( from app.tasks.connector_indexers.google_gmail_indexer import (
index_google_gmail_messages, index_google_gmail_messages,
) )
@ -170,4 +184,6 @@ async def test_native_gmail_does_not_use_composio_credentials(
user_id=data["user_id"], user_id=data["user_id"],
) )
mock_build_creds.assert_not_called() mock_gmail_cls.assert_called_once()
mock_composio_service_cls.assert_not_called()
mock_get_access_token.assert_not_called()

View file

@ -200,7 +200,7 @@ async def test_reindex_sets_status_ready(db_session, db_search_space, db_user, m
async def test_reindex_replaces_chunks(db_session, db_search_space, db_user, mocker): async def test_reindex_replaces_chunks(db_session, db_search_space, db_user, mocker):
"""Reindexing replaces old chunks with new content rather than appending.""" """Reindexing replaces old chunks with new content rather than appending."""
mocker.patch( mocker.patch(
"app.indexing_pipeline.indexing_pipeline_service.chunk_text", "app.indexing_pipeline.indexing_pipeline_service.chunk_text_hybrid",
side_effect=[["Original chunk."], ["Updated chunk."]], side_effect=[["Original chunk."], ["Updated chunk."]],
) )

View file

@ -37,7 +37,12 @@ def _make_orm_doc(connector_doc, doc_id):
async def test_index_calls_embed_and_chunk_via_to_thread( async def test_index_calls_embed_and_chunk_via_to_thread(
pipeline, make_connector_document, monkeypatch pipeline, make_connector_document, monkeypatch
): ):
"""index() runs embed_texts and the chunker via asyncio.to_thread, not blocking the loop.""" """index() runs the chunker and embed_texts via asyncio.to_thread, not blocking the loop.
Routing between ``chunk_text`` (code path) and ``chunk_text_hybrid`` (default
path, see issue #1334) is verified separately in
``test_non_code_documents_use_hybrid_chunker``.
"""
to_thread_calls = [] to_thread_calls = []
original_to_thread = asyncio.to_thread original_to_thread = asyncio.to_thread
@ -51,12 +56,6 @@ async def test_index_calls_embed_and_chunk_via_to_thread(
"app.indexing_pipeline.indexing_pipeline_service.summarize_document", "app.indexing_pipeline.indexing_pipeline_service.summarize_document",
AsyncMock(return_value="Summary."), AsyncMock(return_value="Summary."),
) )
mock_chunk = MagicMock(return_value=["chunk1"])
mock_chunk.__name__ = "chunk_text"
monkeypatch.setattr(
"app.indexing_pipeline.indexing_pipeline_service.chunk_text",
mock_chunk,
)
mock_chunk_hybrid = MagicMock(return_value=["chunk1"]) mock_chunk_hybrid = MagicMock(return_value=["chunk1"])
mock_chunk_hybrid.__name__ = "chunk_text_hybrid" mock_chunk_hybrid.__name__ = "chunk_text_hybrid"
monkeypatch.setattr( monkeypatch.setattr(
@ -71,6 +70,11 @@ async def test_index_calls_embed_and_chunk_via_to_thread(
"app.indexing_pipeline.indexing_pipeline_service.embed_texts", "app.indexing_pipeline.indexing_pipeline_service.embed_texts",
mock_embed, mock_embed,
) )
# Bypass set_committed_value, which requires a real ORM instance (not MagicMock).
monkeypatch.setattr(
"app.indexing_pipeline.indexing_pipeline_service.attach_chunks_to_document",
MagicMock(),
)
connector_doc = make_connector_document( connector_doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
@ -83,11 +87,62 @@ async def test_index_calls_embed_and_chunk_via_to_thread(
await pipeline.index(document, connector_doc, llm=MagicMock()) await pipeline.index(document, connector_doc, llm=MagicMock())
# Non-code documents now route through the table-aware hybrid chunker # Either chunker entry point satisfies the "chunking runs off the event
# (see commit 2f3a33c9). Either chunker entry point satisfies the # loop" contract this test guards. Routing between the two is verified
# "chunking runs off the event loop" contract this test guards. # in test_non_code_documents_use_hybrid_chunker.
assert {"chunk_text", "chunk_text_hybrid"} & set(to_thread_calls) assert {"chunk_text", "chunk_text_hybrid"} & set(to_thread_calls)
assert "embed_texts" in to_thread_calls assert "embed_texts" in to_thread_calls
assert document.status == DocumentStatus.ready()
async def test_non_code_documents_use_hybrid_chunker(
pipeline, make_connector_document, monkeypatch
):
"""Non-code documents route through ``chunk_text_hybrid`` (issue #1334).
The hybrid chunker preserves Markdown table integrity by avoiding splits
mid-row. Only documents flagged with ``should_use_code_chunker=True``
should take the ``chunk_text`` path.
"""
monkeypatch.setattr(
"app.indexing_pipeline.indexing_pipeline_service.summarize_document",
AsyncMock(return_value="Summary."),
)
mock_chunk_hybrid = MagicMock(return_value=["chunk1"])
mock_chunk_hybrid.__name__ = "chunk_text_hybrid"
monkeypatch.setattr(
"app.indexing_pipeline.indexing_pipeline_service.chunk_text_hybrid",
mock_chunk_hybrid,
)
mock_chunk_code = MagicMock(return_value=["chunk1"])
mock_chunk_code.__name__ = "chunk_text"
monkeypatch.setattr(
"app.indexing_pipeline.indexing_pipeline_service.chunk_text",
mock_chunk_code,
)
monkeypatch.setattr(
"app.indexing_pipeline.indexing_pipeline_service.embed_texts",
MagicMock(side_effect=lambda texts: [[0.1] * _EMBEDDING_DIM for _ in texts]),
)
monkeypatch.setattr(
"app.indexing_pipeline.indexing_pipeline_service.attach_chunks_to_document",
MagicMock(),
)
connector_doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-1",
search_space_id=1,
should_use_code_chunker=False,
)
document = MagicMock(spec=Document)
document.id = 1
document.status = DocumentStatus.pending()
await pipeline.index(document, connector_doc, llm=MagicMock())
mock_chunk_hybrid.assert_called_once()
mock_chunk_code.assert_not_called()
def _mock_session_factory(orm_docs_by_id): def _mock_session_factory(orm_docs_by_id):

View file

@ -24,6 +24,8 @@ import dynamic from "next/dynamic";
import type { FC } from "react"; import type { FC } from "react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { tryGetHostname } from "@/lib/url";
import { import {
globalNewLLMConfigsAtom, globalNewLLMConfigsAtom,
newLLMConfigsAtom, newLLMConfigsAtom,
@ -99,20 +101,12 @@ const GenerateImageToolUI = dynamic(
import("@/components/tool-ui/generate-image").then((m) => ({ default: m.GenerateImageToolUI })), import("@/components/tool-ui/generate-image").then((m) => ({ default: m.GenerateImageToolUI })),
{ ssr: false } { ssr: false }
); );
function extractDomain(url: string): string | undefined {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return undefined;
}
}
function useCitationsFromMetadata(): SerializableCitation[] { function useCitationsFromMetadata(): SerializableCitation[] {
const allCitations = useAllCitationMetadata(); const allCitations = useAllCitationMetadata();
return useMemo(() => { return useMemo(() => {
const result: SerializableCitation[] = []; const result: SerializableCitation[] = [];
for (const [url, meta] of allCitations) { for (const [url, meta] of allCitations) {
const domain = extractDomain(url); const domain = tryGetHostname(url);
result.push({ result.push({
id: `url-cite-${url}`, id: `url-cite-${url}`,
href: url, href: url,

View file

@ -242,14 +242,7 @@ const SurfsenseDocPreviewContent: FC<{
); );
}; };
function extractDomain(url: string): string { import { tryGetHostname } from "@/lib/url";
try {
const hostname = new URL(url).hostname;
return hostname.replace(/^www\./, "");
} catch {
return url;
}
}
interface UrlCitationProps { interface UrlCitationProps {
url: string; url: string;
@ -261,7 +254,7 @@ interface UrlCitationProps {
* page title and snippet (extracted deterministically from web_search tool results). * page title and snippet (extracted deterministically from web_search tool results).
*/ */
export const UrlCitation: FC<UrlCitationProps> = ({ url }) => { export const UrlCitation: FC<UrlCitationProps> = ({ url }) => {
const domain = extractDomain(url); const domain = tryGetHostname(url) ?? url;
const meta = useCitationMetadata(url); const meta = useCitationMetadata(url);
return ( return (

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import { Folder as FolderIcon } from "lucide-react"; import { Folder as FolderIcon, X as XIcon } from "lucide-react";
import type { NodeEntry, TElement } from "platejs";
import type { PlateElementProps } from "platejs/react"; import type { PlateElementProps } from "platejs/react";
import { import {
createPlatePlugin, createPlatePlugin,
@ -9,7 +10,16 @@ import {
PlateContent, PlateContent,
usePlateEditor, usePlateEditor,
} from "platejs/react"; } from "platejs/react";
import { type FC, forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from "react"; import {
createContext,
type FC,
forwardRef,
useCallback,
useContext,
useImperativeHandle,
useMemo,
useRef,
} from "react";
import { FOLDER_MENTION_DOCUMENT_TYPE } from "@/atoms/chat/mentioned-documents.atom"; import { FOLDER_MENTION_DOCUMENT_TYPE } from "@/atoms/chat/mentioned-documents.atom";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document } from "@/contracts/types/document.types"; import type { Document } from "@/contracts/types/document.types";
@ -26,13 +36,9 @@ export interface MentionedDocument {
} }
/** /**
* Input shape for inserting a chip. ``kind`` defaults to ``"doc"`` * Input shape for inserting a chip. ``kind`` defaults to ``"doc"``.
* when omitted so legacy callers don't have to thread the * Folder chips default ``document_type`` to ``FOLDER_MENTION_DOCUMENT_TYPE``
* discriminator. Folder callers pass ``kind: "folder"`` and the * so the dedup key never collides with a doc chip sharing the same id.
* folder ``id`` and ``title``; ``document_type`` defaults to
* ``FOLDER_MENTION_DOCUMENT_TYPE`` inside ``insertMentionChip`` so the
* dedup key (`kind:document_type:id`) never collides with a doc chip
* that happens to share an id.
*/ */
export type MentionChipInput = { export type MentionChipInput = {
id: number; id: number;
@ -87,12 +93,7 @@ type MentionElementNode = {
id: number; id: number;
title: string; title: string;
document_type?: string; document_type?: string;
/** /** Discriminator; defaults to ``"doc"`` for legacy nodes. */
* Discriminator added so a folder chip and a doc chip with the
* same id round-trip cleanly through ``getMentionedDocuments``
* and the persisted ``mentioned-documents`` content part.
* Defaults to ``"doc"`` for nodes that predate this field.
*/
kind?: MentionKind; kind?: MentionKind;
statusLabel?: string | null; statusLabel?: string | null;
statusKind?: MentionStatusKind; statusKind?: MentionStatusKind;
@ -104,13 +105,22 @@ type ComposerValue = ComposerParagraph[];
const MENTION_TYPE = "mention"; const MENTION_TYPE = "mention";
const MENTION_CHIP_CLASSNAME = const MENTION_CHIP_CLASSNAME =
"inline-flex h-5 items-center gap-1 mx-0.5 rounded bg-primary/10 px-1 text-xs font-bold text-primary/60 select-none align-middle leading-none"; "group inline-flex h-5 items-center gap-1 mx-0.5 rounded bg-primary/10 px-1 text-xs font-bold text-primary/60 select-none align-middle leading-none";
const MENTION_CHIP_ICON_CLASSNAME = "flex items-center text-muted-foreground leading-none"; const MENTION_CHIP_ICON_CLASSNAME = "flex items-center text-muted-foreground leading-none";
const MENTION_CHIP_TITLE_CLASSNAME = "max-w-[120px] truncate leading-none"; const MENTION_CHIP_TITLE_CLASSNAME = "max-w-[120px] truncate leading-none";
const COMPOSER_TEXT_METRICS_CLASSNAME = "text-sm leading-6"; const COMPOSER_TEXT_METRICS_CLASSNAME = "text-sm leading-6";
const EMPTY_VALUE: ComposerValue = [{ type: "p", children: [{ text: "" }] }]; const EMPTY_VALUE: ComposerValue = [{ type: "p", children: [{ text: "" }] }];
/**
* Lets ``MentionElement`` reach the editor's chip-removal helper so
* the X button and Backspace go through the same call site.
*/
type MentionEditorContextValue = {
removeChip: (docId: number, docType: string | undefined) => void;
};
const MentionEditorContext = createContext<MentionEditorContextValue | null>(null);
const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
attributes, attributes,
children, children,
@ -124,16 +134,36 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
: "text-amber-700"; : "text-amber-700";
const isFolder = element.kind === "folder"; const isFolder = element.kind === "folder";
const ctx = useContext(MentionEditorContext);
return ( return (
<span {...attributes} className="inline-flex align-middle"> <span {...attributes} className="inline-flex align-middle">
<span contentEditable={false} className={`${MENTION_CHIP_CLASSNAME} cursor-default`}> <span contentEditable={false} className={`${MENTION_CHIP_CLASSNAME} cursor-default`}>
<span className={MENTION_CHIP_ICON_CLASSNAME}> <span className={MENTION_CHIP_ICON_CLASSNAME}>
{isFolder ? ( <span className="relative flex h-3 w-3 items-center justify-center">
<FolderIcon className="h-3 w-3" /> <span className="flex items-center justify-center transition-opacity group-hover:opacity-0">
) : ( {isFolder ? (
getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3") <FolderIcon className="h-3 w-3" />
)} ) : (
getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3")
)}
</span>
{ctx ? (
<button
type="button"
aria-label={`Remove mention ${element.title}`}
title={`Remove ${element.title}`}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
ctx.removeChip(element.id, element.document_type);
}}
className="absolute inset-0 flex items-center justify-center rounded-sm opacity-0 transition-opacity hover:text-primary focus-visible:opacity-100 focus-visible:outline-none group-hover:opacity-100"
>
<XIcon className="h-3 w-3" />
</button>
) : null}
</span>
</span> </span>
<span className={MENTION_CHIP_TITLE_CLASSNAME} title={element.title}> <span className={MENTION_CHIP_TITLE_CLASSNAME} title={element.title}>
{element.title} {element.title}
@ -294,17 +324,16 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
value: initialText ? toValueFromText(initialText) : EMPTY_VALUE, value: initialText ? toValueFromText(initialText) : EMPTY_VALUE,
}); });
// Move the caret to end-of-doc and focus the editor. Falls back
// to DOM focus if Plate's API throws (transient unmount race).
const focusAtEnd = useCallback(() => { const focusAtEnd = useCallback(() => {
const el = editableRef.current; try {
if (!el) return; editor.tf.select(editor.api.end([]));
el.focus(); editor.tf.focus();
const selection = window.getSelection(); } catch {
const range = document.createRange(); editableRef.current?.focus();
range.selectNodeContents(el); }
range.collapse(false); }, [editor]);
selection?.removeAllRanges();
selection?.addRange(range);
}, []);
const getCurrentValue = useCallback( const getCurrentValue = useCallback(
() => (editor.children as ComposerValue) ?? EMPTY_VALUE, () => (editor.children as ComposerValue) ?? EMPTY_VALUE,
@ -352,13 +381,18 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[editor, emitState] [editor, emitState]
); );
// Insert chip + trailing space as a single ``insertNodes`` call.
// The chip is a void inline; ``select: true`` on it alone would
// land the caret inside its empty children (an unrenderable
// point). With the space as the last inserted node, the caret
// resolves to that text node and stays visible. The
// ``withoutNormalizing`` wrapper batches the optional trigger
// delete + insert into a single undo step.
const insertMentionChip = useCallback( const insertMentionChip = useCallback(
(mention: MentionChipInput, options?: { removeTriggerText?: boolean }) => { (mention: MentionChipInput, options?: { removeTriggerText?: boolean }) => {
if (typeof mention.id !== "number" || typeof mention.title !== "string") return; if (typeof mention.id !== "number" || typeof mention.title !== "string") return;
const removeTriggerText = options?.removeTriggerText ?? true; const removeTriggerText = options?.removeTriggerText ?? true;
const current = getCurrentValue();
const selection = editor.selection;
const kind: MentionKind = mention.kind ?? "doc"; const kind: MentionKind = mention.kind ?? "doc";
const document_type = const document_type =
mention.document_type ?? (kind === "folder" ? FOLDER_MENTION_DOCUMENT_TYPE : undefined); mention.document_type ?? (kind === "folder" ? FOLDER_MENTION_DOCUMENT_TYPE : undefined);
@ -371,65 +405,48 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
children: [{ text: "" }], children: [{ text: "" }],
}; };
const cursorCtx = getCursorTextContext(current, selection); editor.tf.withoutNormalizing(() => {
if (!cursorCtx) { const selection = editor.selection;
const lastBlock = current[current.length - 1] ?? { type: "p", children: [{ text: "" }] };
const appended: ComposerValue = [
...current.slice(0, -1),
{
...lastBlock,
children: [...lastBlock.children, mentionNode, { text: " " }],
},
];
setValue(appended);
requestAnimationFrame(focusAtEnd);
return;
}
const block = current[cursorCtx.blockIndex]; // No active selection (focus moved to a picker) — snap
const currentChild = getTextNode(block.children[cursorCtx.childIndex]); // to end-of-doc so the chip appends cleanly.
if (!currentChild) { if (!selection) {
const children = [...block.children]; editor.tf.select(editor.api.end([]));
children.splice(cursorCtx.childIndex + 1, 0, mentionNode, { text: " " }); } else if (removeTriggerText) {
const next = [...current]; // Delete the in-progress "@query" so the chip stands in for it.
next[cursorCtx.blockIndex] = { ...block, children }; const cursorCtx = getCursorTextContext(getCurrentValue(), selection);
setValue(next as ComposerValue); if (cursorCtx) {
requestAnimationFrame(focusAtEnd); const text = cursorCtx.text;
return; let triggerIndex = -1;
} for (let i = cursorCtx.cursor - 1; i >= 0; i--) {
if (text[i] === "@") {
const text = currentChild.text; triggerIndex = i;
let removeStart = cursorCtx.cursor; break;
if (removeTriggerText) { }
for (let i = cursorCtx.cursor - 1; i >= 0; i--) { if (text[i] === " " || text[i] === "\n") break;
if (text[i] === "@") { }
removeStart = i; if (triggerIndex >= 0 && triggerIndex < cursorCtx.cursor) {
break; const path = [cursorCtx.blockIndex, cursorCtx.childIndex];
editor.tf.delete({
at: {
anchor: { path, offset: triggerIndex },
focus: { path, offset: cursorCtx.cursor },
},
});
}
} }
if (text[i] === " " || text[i] === "\n") break;
} }
}
const before = text.slice(0, removeStart); editor.tf.insertNodes([mentionNode, { text: " " }] as unknown as TElement[], {
const after = text.slice(cursorCtx.cursor); select: true,
const replacement: ComposerNode[] = []; });
if (before.length > 0) replacement.push({ text: before }); });
replacement.push(mentionNode); editor.tf.focus();
replacement.push({ text: ` ${after}` });
const children = [...block.children];
children.splice(cursorCtx.childIndex, 1, ...replacement);
const next = [...current];
next[cursorCtx.blockIndex] = { ...block, children };
setValue(next as ComposerValue);
requestAnimationFrame(focusAtEnd);
}, },
[editor.selection, focusAtEnd, getCurrentValue, setValue] [editor, getCurrentValue]
); );
// Backwards-compatible shim — pre-folder callers pass a doc-only // Doc-only shim that routes through ``insertMentionChip``.
// payload; we route them through ``insertMentionChip`` with
// ``kind: "doc"``.
const insertDocumentChip = useCallback( const insertDocumentChip = useCallback(
( (
doc: Pick<Document, "id" | "title" | "document_type">, doc: Pick<Document, "id" | "title" | "document_type">,
@ -440,26 +457,43 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[insertMentionChip] [insertMentionChip]
); );
// Remove chip(s) matching (id, document_type). Iterates in
// descending path order so removing one entry can't invalidate
// later paths. Chips are deduped today, so this typically runs
// at most once.
const removeDocumentChip = useCallback( const removeDocumentChip = useCallback(
(docId: number, docType?: string) => { (docId: number, docType?: string) => {
const current = getCurrentValue(); const match = (n: unknown) => {
let changed = false; if (!n || typeof n !== "object" || !("type" in n)) return false;
const next = current.map((block) => { const node = n as MentionElementNode;
const children = block.children.filter((node) => { if (node.type !== MENTION_TYPE) return false;
if (!isMentionNode(node)) return true; if (node.id !== docId) return false;
const match = return (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN");
node.id === docId && (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN"); };
if (match) changed = true;
return !match; const entries = Array.from(editor.api.nodes({ at: [], match })) as NodeEntry[];
}); if (entries.length === 0) return;
return { ...block, children: children.length ? children : [{ text: "" }] }; editor.tf.withoutNormalizing(() => {
for (const [, path] of entries.reverse()) {
editor.tf.removeNodes({ at: path });
}
}); });
if (!changed) return;
setValue(next as ComposerValue);
}, },
[getCurrentValue, setValue] [editor]
); );
// Single removal call site for Backspace and the X button so the
// two can never diverge (e.g. one forgetting to notify the parent).
const removeChip = useCallback(
(docId: number, docType: string | undefined) => {
removeDocumentChip(docId, docType);
onDocumentRemove?.(docId, docType);
},
[onDocumentRemove, removeDocumentChip]
);
// Update chip status in place via ``tf.setNodes`` so the user's
// selection survives backend status events arriving mid-typing.
const setDocumentChipStatus = useCallback( const setDocumentChipStatus = useCallback(
( (
docId: number, docId: number,
@ -467,31 +501,31 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
statusLabel: string | null, statusLabel: string | null,
statusKind: MentionStatusKind = "pending" statusKind: MentionStatusKind = "pending"
) => { ) => {
const current = getCurrentValue(); const match = (n: unknown) => {
let changed = false; if (!n || typeof n !== "object" || !("type" in n)) return false;
const next = current.map((block) => ({ const node = n as MentionElementNode;
...block, if (node.type !== MENTION_TYPE) return false;
children: block.children.map((node) => { if (node.id !== docId) return false;
if (!isMentionNode(node)) return node; return (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN");
const sameType = (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN"); };
if (node.id !== docId || !sameType) return node;
changed = true; editor.tf.setNodes(
return { {
...node, statusLabel,
statusLabel, statusKind: statusLabel ? statusKind : undefined,
statusKind: statusLabel ? statusKind : undefined, } as Partial<TElement>,
}; { at: [], match }
}), );
}));
if (!changed) return;
setValue(next as ComposerValue);
}, },
[getCurrentValue, setValue] [editor]
); );
const clear = useCallback(() => { const clear = useCallback(() => {
setValue(EMPTY_VALUE); setValue(EMPTY_VALUE);
}, [setValue]); // ``tf.setValue`` wipes the selection — refocus so the caret
// returns after Enter-to-submit.
requestAnimationFrame(focusAtEnd);
}, [focusAtEnd, setValue]);
const setText = useCallback( const setText = useCallback(
(text: string) => { (text: string) => {
@ -510,7 +544,18 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
useImperativeHandle( useImperativeHandle(
ref, ref,
() => ({ () => ({
focus: () => editableRef.current?.focus(), // Preserve existing selection if any; otherwise seed one
// at end-of-doc so the contentEditable shows a caret.
focus: () => {
try {
if (!editor.selection) {
editor.tf.select(editor.api.end([]));
}
editor.tf.focus();
} catch {
editableRef.current?.focus();
}
},
clear, clear,
setText, setText,
getText, getText,
@ -522,6 +567,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
}), }),
[ [
clear, clear,
editor,
getMentionedDocs, getMentionedDocs,
getText, getText,
insertMentionChip, insertMentionChip,
@ -564,10 +610,9 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
if (!isMentionNode(prev)) return; if (!isMentionNode(prev)) return;
e.preventDefault(); e.preventDefault();
removeDocumentChip(prev.id, prev.document_type); removeChip(prev.id, prev.document_type);
onDocumentRemove?.(prev.id, prev.document_type);
}, },
[editor.selection, getCurrentValue, onDocumentRemove, onKeyDown, onSubmit, removeDocumentChip] [editor.selection, getCurrentValue, onKeyDown, onSubmit, removeChip]
); );
const editableProps = useMemo( const editableProps = useMemo(
@ -584,26 +629,33 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[editor, handleKeyDown, placeholder] [editor, handleKeyDown, placeholder]
); );
const mentionEditorContextValue = useMemo<MentionEditorContextValue>(
() => ({ removeChip }),
[removeChip]
);
return ( return (
<div className="relative w-full"> <div className="relative w-full">
<Plate <MentionEditorContext.Provider value={mentionEditorContextValue}>
editor={editor} <Plate
onChange={({ value }) => { editor={editor}
emitState(value as ComposerValue); onChange={({ value }) => {
}} emitState(value as ComposerValue);
> }}
<PlateContent >
ref={editableRef} <PlateContent
readOnly={disabled} ref={editableRef}
{...editableProps} readOnly={disabled}
className={cn( {...editableProps}
"min-h-[24px] max-h-32 overflow-y-auto outline-none whitespace-pre-wrap wrap-break-word", className={cn(
COMPOSER_TEXT_METRICS_CLASSNAME, "min-h-[24px] max-h-32 overflow-y-auto outline-none whitespace-pre-wrap wrap-break-word",
disabled && "opacity-50 cursor-not-allowed", COMPOSER_TEXT_METRICS_CLASSNAME,
className disabled && "opacity-50 cursor-not-allowed",
)} className
/> )}
</Plate> />
</Plate>
</MentionEditorContext.Provider>
</div> </div>
); );
} }

View file

@ -23,6 +23,8 @@ import "katex/dist/katex.min.css";
import { toast } from "sonner"; import { toast } from "sonner";
import { processChildrenWithCitations } from "@/components/citations/citation-renderer"; import { processChildrenWithCitations } from "@/components/citations/citation-renderer";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { tryGetHostname } from "@/lib/url";
import { import {
Table, Table,
TableBody, TableBody,
@ -139,15 +141,6 @@ const MarkdownTextImpl = () => {
export const MarkdownText = memo(MarkdownTextImpl); export const MarkdownText = memo(MarkdownTextImpl);
function extractDomain(url: string): string {
try {
const parsed = new URL(url);
return parsed.hostname.replace(/^www\./, "");
} catch {
return "";
}
}
// Canonical local-file virtual paths are mount-prefixed: /<mount>/<relative/path> // Canonical local-file virtual paths are mount-prefixed: /<mount>/<relative/path>
const LOCAL_FILE_PATH_REGEX = /^\/[a-z0-9_-]+\/[^\s`]+(?:\/[^\s`]+)*$/; const LOCAL_FILE_PATH_REGEX = /^\/[a-z0-9_-]+\/[^\s`]+(?:\/[^\s`]+)*$/;
@ -288,7 +281,7 @@ function FilePathLink({ path, className }: { path: string; className?: string })
function MarkdownImage({ src, alt }: { src?: string; alt?: string }) { function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {
if (!src) return null; if (!src) return null;
const domain = extractDomain(src); const domain = tryGetHostname(src) ?? "";
return ( return (
<div className="my-4 w-fit max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none"> <div className="my-4 w-fit max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">

View file

@ -1,7 +1,6 @@
"use client"; "use client";
import type { MouseEventHandler, ReactNode } from "react"; import type { MouseEventHandler, ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -61,30 +60,27 @@ export function MentionChip({
const isInteractive = Boolean(onClick) && !disabled; const isInteractive = Boolean(onClick) && !disabled;
const chip = ( const chip = (
<Button <button
variant="ghost"
type="button" type="button"
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
aria-label={ariaLabel ?? label} aria-label={ariaLabel ?? label}
className={cn( className={cn(
"h-auto max-w-[220px] justify-start gap-1.5 rounded-md border bg-background px-2 py-0.5 align-middle text-xs leading-5 text-foreground shadow-none transition-colors focus-visible:ring-1 focus-visible:ring-ring", "inline-flex h-5 items-center gap-1 rounded bg-primary/10 px-1 align-middle text-xs font-bold text-primary/60 leading-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isInteractive isInteractive ? "cursor-pointer" : "cursor-default",
? "cursor-pointer hover:bg-accent hover:text-accent-foreground"
: "cursor-default",
disabled && "opacity-60", disabled && "opacity-60",
className className
)} )}
> >
<span className="inline-flex shrink-0 text-muted-foreground">{icon}</span> <span className="inline-flex shrink-0 text-muted-foreground">{icon}</span>
<span className="truncate">{label}</span> <span className="max-w-[120px] truncate leading-none">{label}</span>
</Button> </button>
); );
if (!tooltip) return chip; if (!tooltip) return chip;
return ( return (
<Tooltip> <Tooltip delayDuration={600}>
<TooltipTrigger asChild>{chip}</TooltipTrigger> <TooltipTrigger asChild>{chip}</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs break-all"> <TooltipContent side="top" className="max-w-xs break-all">
{tooltip} {tooltip}

View file

@ -61,6 +61,7 @@ import { useDocumentUploadDialog } from "@/components/assistant-ui/document-uplo
import { import {
InlineMentionEditor, InlineMentionEditor,
type InlineMentionEditorRef, type InlineMentionEditorRef,
type MentionedDocument,
} from "@/components/assistant-ui/inline-mention-editor"; } from "@/components/assistant-ui/inline-mention-editor";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { UserMessage } from "@/components/assistant-ui/user-message"; import { UserMessage } from "@/components/assistant-ui/user-message";
@ -180,36 +181,24 @@ const PremiumQuotaPinnedAlert: FC = () => {
const getTimeBasedGreeting = (user?: { display_name?: string | null; email?: string }): string => { const getTimeBasedGreeting = (user?: { display_name?: string | null; email?: string }): string => {
const hour = new Date().getHours(); const hour = new Date().getHours();
// Extract first name: prefer display_name, fall back to email extraction
let firstName: string | null = null; let firstName: string | null = null;
if (user?.display_name?.trim()) { if (user?.display_name?.trim()) {
// Use display_name if available and not empty
// Extract first name from display_name (take first word)
const nameParts = user.display_name.trim().split(/\s+/); const nameParts = user.display_name.trim().split(/\s+/);
firstName = nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1).toLowerCase(); firstName = nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1).toLowerCase();
} else if (user?.email) { } else if (user?.email) {
// Fall back to email extraction if display_name is not available
firstName = firstName =
user.email.split("@")[0].split(".")[0].charAt(0).toUpperCase() + user.email.split("@")[0].split(".")[0].charAt(0).toUpperCase() +
user.email.split("@")[0].split(".")[0].slice(1); user.email.split("@")[0].split(".")[0].slice(1);
} }
// Array of greeting variations for each time period
const morningGreetings = ["Good morning", "Fresh start today", "Morning", "Hey there"]; const morningGreetings = ["Good morning", "Fresh start today", "Morning", "Hey there"];
const afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there", "Hi there"]; const afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there", "Hi there"];
const eveningGreetings = ["Good evening", "Evening", "Hey there", "Hi there"]; const eveningGreetings = ["Good evening", "Evening", "Hey there", "Hi there"];
const nightGreetings = ["Good night", "Evening", "Hey there", "Winding down"]; const nightGreetings = ["Good night", "Evening", "Hey there", "Winding down"];
const lateNightGreetings = ["Still up", "Night owl mode", "Up past bedtime", "Hi there"]; const lateNightGreetings = ["Still up", "Night owl mode", "Up past bedtime", "Hi there"];
// Select a random greeting based on time
let greeting: string; let greeting: string;
if (hour < 5) { if (hour < 5) {
// Late night: midnight to 5 AM
greeting = lateNightGreetings[Math.floor(Math.random() * lateNightGreetings.length)]; greeting = lateNightGreetings[Math.floor(Math.random() * lateNightGreetings.length)];
} else if (hour < 12) { } else if (hour < 12) {
greeting = morningGreetings[Math.floor(Math.random() * morningGreetings.length)]; greeting = morningGreetings[Math.floor(Math.random() * morningGreetings.length)];
@ -218,33 +207,23 @@ const getTimeBasedGreeting = (user?: { display_name?: string | null; email?: str
} else if (hour < 22) { } else if (hour < 22) {
greeting = eveningGreetings[Math.floor(Math.random() * eveningGreetings.length)]; greeting = eveningGreetings[Math.floor(Math.random() * eveningGreetings.length)];
} else { } else {
// Night: 10 PM to midnight
greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)]; greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)];
} }
// Add personalization with first name if available return firstName ? `${greeting}, ${firstName}!` : `${greeting}!`;
if (firstName) {
return `${greeting}, ${firstName}!`;
}
return `${greeting}!`;
}; };
const ThreadWelcome: FC = () => { const ThreadWelcome: FC = () => {
const { data: user } = useAtomValue(currentUserAtom); const { data: user } = useAtomValue(currentUserAtom);
// Memoize greeting so it doesn't change on re-renders (only on user change)
const greeting = useMemo(() => getTimeBasedGreeting(user), [user]); const greeting = useMemo(() => getTimeBasedGreeting(user), [user]);
return ( return (
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative"> <div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
{/* Greeting positioned above the composer */}
<div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center"> <div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center">
<h1 className="aui-thread-welcome-message-inner text-3xl md:text-[2.625rem] select-none"> <h1 className="aui-thread-welcome-message-inner text-3xl md:text-[2.625rem] select-none">
{greeting} {greeting}
</h1> </h1>
</div> </div>
{/* Composer - top edge fixed, expands downward only */}
<div className="w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0"> <div className="w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
<Composer /> <Composer />
</div> </div>
@ -422,7 +401,6 @@ const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDi
}; };
const Composer: FC = () => { const Composer: FC = () => {
// Document mention state (atoms persist across component remounts)
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false); const [showDocumentPopover, setShowDocumentPopover] = useState(false);
const [showPromptPicker, setShowPromptPicker] = useState(false); const [showPromptPicker, setShowPromptPicker] = useState(false);
@ -434,7 +412,9 @@ const Composer: FC = () => {
const promptPickerRef = useRef<PromptPickerRef>(null); const promptPickerRef = useRef<PromptPickerRef>(null);
const { search_space_id, chat_id } = useParams(); const { search_space_id, chat_id } = useParams();
const aui = useAui(); const aui = useAui();
const hasAutoFocusedRef = useRef(false); // Desktop-only auto-focus; on mobile, programmatic focus would
// summon the soft keyboard on every picker close / thread switch.
const isDesktop = useMediaQuery("(min-width: 640px)");
const electronAPI = useElectronAPI(); const electronAPI = useElectronAPI();
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>(); const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
@ -455,7 +435,6 @@ const Composer: FC = () => {
const currentPlaceholder = COMPOSER_PLACEHOLDER; const currentPlaceholder = COMPOSER_PLACEHOLDER;
// Live collaboration state
const { data: currentUser } = useAtomValue(currentUserAtom); const { data: currentUser } = useAtomValue(currentUserAtom);
const { data: members } = useAtomValue(membersAtom); const { data: members } = useAtomValue(membersAtom);
const threadId = useMemo(() => { const threadId = useMemo(() => {
@ -469,13 +448,11 @@ const Composer: FC = () => {
const respondingToUserId = sessionState?.respondingToUserId ?? null; const respondingToUserId = sessionState?.respondingToUserId ?? null;
const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id; const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id;
// Sync comments for the entire thread via Zero (one subscription per thread) // One Zero subscription per thread for comment sync.
useCommentsSync(threadId); useCommentsSync(threadId);
// Batch-prefetch comments for all assistant messages so individual useComments // Batch-prefetch assistant message comments to avoid N+1 fetches.
// hooks never fire their own network requests (eliminates N+1 API calls). // Returns a primitive string so useSyncExternalStore can compare by value.
// Return a primitive string from the selector so useSyncExternalStore can
// compare snapshots by value and avoid infinite re-render loops.
const assistantIdsKey = useAuiState(({ thread }) => const assistantIdsKey = useAuiState(({ thread }) =>
thread.messages thread.messages
.filter((m) => m.role === "assistant" && m.id?.startsWith("msg-")) .filter((m) => m.role === "assistant" && m.id?.startsWith("msg-"))
@ -488,18 +465,17 @@ const Composer: FC = () => {
); );
useBatchCommentsPreload(assistantDbMessageIds); useBatchCommentsPreload(assistantDbMessageIds);
// Auto-focus editor on new chat page after mount // Always-focused composer: refocus whenever no picker has taken
// over input. ``threadId`` is in the deps so the effect re-fires
// on thread switch (Composer instance is reused).
useEffect(() => { useEffect(() => {
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) { if (!isDesktop) return;
const timeoutId = setTimeout(() => { if (showDocumentPopover || showPromptPicker) return;
editorRef.current?.focus(); void threadId;
hasAutoFocusedRef.current = true; editorRef.current?.focus();
}, 100); }, [isDesktop, showDocumentPopover, showPromptPicker, threadId]);
return () => clearTimeout(timeoutId);
}
}, [isThreadEmpty]);
// Close document picker when a slide-out panel (inbox, shared/private chats) opens // Close document picker when a slide-out panel (inbox, etc.) opens.
useEffect(() => { useEffect(() => {
const handler = () => { const handler = () => {
setShowDocumentPopover(false); setShowDocumentPopover(false);
@ -509,21 +485,41 @@ const Composer: FC = () => {
return () => window.removeEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler); return () => window.removeEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler);
}, []); }, []);
// Sync editor text with assistant-ui composer runtime // Sync editor text into assistant-ui's composer and mirror the chip
// atom from the editor's reported ``docs``. The editor is the
// single source of truth, so this catches every Plate deletion path
// (Backspace, X button, Cmd+Backspace, range-delete, cut,
// paste-over) without per-keybinding plumbing. The ``prev``
// short-circuit keeps pure-text keystrokes from churning the atom.
const handleEditorChange = useCallback( const handleEditorChange = useCallback(
(text: string) => { (text: string, docs: MentionedDocument[]) => {
aui.composer().setText(text); aui.composer().setText(text);
setMentionedDocuments((prev) => {
if (prev.length === docs.length) {
const editorKeys = new Set(docs.map((d) => getMentionDocKey(d)));
if (prev.every((d) => editorKeys.has(getMentionDocKey(d)))) {
return prev;
}
}
return docs.map<MentionedDocumentInfo>((d) => ({
id: d.id,
title: d.title,
// Atom requires a string; ``"UNKNOWN"`` matches the
// sentinel ``getMentionDocKey`` and the editor's
// match predicates use.
document_type: d.document_type ?? "UNKNOWN",
kind: d.kind,
}));
});
}, },
[aui] [aui, setMentionedDocuments]
); );
// Open document picker when @ mention is triggered
const handleMentionTrigger = useCallback((query: string) => { const handleMentionTrigger = useCallback((query: string) => {
setShowDocumentPopover(true); setShowDocumentPopover(true);
setMentionQuery(query); setMentionQuery(query);
}, []); }, []);
// Close document picker and reset query
const handleMentionClose = useCallback(() => { const handleMentionClose = useCallback(() => {
if (showDocumentPopover) { if (showDocumentPopover) {
setShowDocumentPopover(false); setShowDocumentPopover(false);
@ -531,13 +527,11 @@ const Composer: FC = () => {
} }
}, [showDocumentPopover]); }, [showDocumentPopover]);
// Open action picker when / is triggered
const handleActionTrigger = useCallback((query: string) => { const handleActionTrigger = useCallback((query: string) => {
setShowPromptPicker(true); setShowPromptPicker(true);
setActionQuery(query); setActionQuery(query);
}, []); }, []);
// Close action picker and reset query
const handleActionClose = useCallback(() => { const handleActionClose = useCallback(() => {
if (showPromptPicker) { if (showPromptPicker) {
setShowPromptPicker(false); setShowPromptPicker(false);
@ -581,7 +575,7 @@ const Composer: FC = () => {
[clipboardInitialText, electronAPI, aui] [clipboardInitialText, electronAPI, aui]
); );
// Keyboard navigation for document/action picker (arrow keys, Enter, Escape) // Arrow / Enter / Escape navigation for the active picker.
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
if (showPromptPicker) { if (showPromptPicker) {
@ -662,7 +656,7 @@ const Composer: FC = () => {
(docId: number, docType?: string) => { (docId: number, docType?: string) => {
setMentionedDocuments((prev) => { setMentionedDocuments((prev) => {
if (!docType) { if (!docType) {
// Defensive fallback: keep UI in sync even when chip type is unavailable. // Fallback when chip type is unavailable.
return prev.filter((doc) => doc.id !== docId); return prev.filter((doc) => doc.id !== docId);
} }
const removedKey = getMentionDocKey({ id: docId, document_type: docType }); const removedKey = getMentionDocKey({ id: docId, document_type: docType });
@ -672,27 +666,22 @@ const Composer: FC = () => {
[setMentionedDocuments] [setMentionedDocuments]
); );
const handleDocumentsMention = useCallback( const handleDocumentsMention = useCallback((mentions: MentionedDocumentInfo[]) => {
(mentions: MentionedDocumentInfo[]) => { const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? []; const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
for (const mention of mentions) { for (const mention of mentions) {
const key = getMentionDocKey(mention); const key = getMentionDocKey(mention);
if (editorDocKeys.has(key)) continue; if (editorDocKeys.has(key)) continue;
editorRef.current?.insertMentionChip(mention); editorRef.current?.insertMentionChip(mention);
} // Track within the loop so a duplicate-in-batch can't double-insert.
editorDocKeys.add(key);
}
setMentionedDocuments((prev) => { // Atom is reconciled by ``handleEditorChange`` via the editor's
const existingKeySet = new Set(prev.map((d) => getMentionDocKey(d))); // onChange — no second write path here.
const uniqueNew = mentions.filter((m) => !existingKeySet.has(getMentionDocKey(m))); setMentionQuery("");
return [...prev, ...uniqueNew]; }, []);
});
setMentionQuery("");
},
[setMentionedDocuments]
);
useEffect(() => { useEffect(() => {
const editor = editorRef.current; const editor = editorRef.current;
@ -1292,12 +1281,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
); );
}; };
/** /** Friendly tool name (delegates to ``getToolDisplayName``). */
* Friendly tool name for display in the chat UI. Delegates to the
* shared map in ``contracts/enums/toolIcons`` so unix-style identifiers
* (``rm``, ``ls``, ``grep`` ) and snake_cased function names render as
* plain English (e.g. "Delete file", "List files", "Search in files").
*/
function formatToolName(name: string): string { function formatToolName(name: string): string {
return getToolDisplayName(name); return getToolDisplayName(name);
} }

View file

@ -7,19 +7,11 @@ import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
import { cn } from "./_adapter"; import { cn } from "./_adapter";
import { CitationHoverPopover } from "./citation-hover-popover"; import { CitationHoverPopover } from "./citation-hover-popover";
import type { CitationVariant, SerializableCitation } from "./schema"; import type { CitationVariant, SerializableCitation } from "./schema";
import { tryGetHostname } from "@/lib/url";
import { TYPE_ICONS } from "./type-icons"; import { TYPE_ICONS } from "./type-icons";
const FALLBACK_LOCALE = "en-US"; const FALLBACK_LOCALE = "en-US";
function extractDomain(url: string): string | undefined {
try {
const urlObj = new URL(url);
return urlObj.hostname.replace(/^www\./, "");
} catch {
return undefined;
}
}
function formatDate(isoString: string, locale: string): string { function formatDate(isoString: string, locale: string): string {
try { try {
const date = new Date(isoString); const date = new Date(isoString);
@ -56,7 +48,7 @@ export function Citation(props: CitationProps) {
const locale = providedLocale ?? FALLBACK_LOCALE; const locale = providedLocale ?? FALLBACK_LOCALE;
const sanitizedHref = sanitizeHref(rawHref); const sanitizedHref = sanitizeHref(rawHref);
const domain = providedDomain ?? extractDomain(rawHref); const domain = providedDomain ?? tryGetHostname(rawHref);
const citationData: SerializableCitation = { const citationData: SerializableCitation = {
...serializable, ...serializable,

View file

@ -3,6 +3,19 @@ import type { GetDocumentsRequest } from "@/contracts/types/document.types";
import type { GetLogsRequest } from "@/contracts/types/log.types"; import type { GetLogsRequest } from "@/contracts/types/log.types";
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types"; import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
/**
* Convert an object to a stable array of [key, value] pairs sorted by key.
* This ensures cache keys are order-independent (avoiding Object.values order-dependency).
* Filters out undefined values.
*/
function stableEntries(obj: Record<string, unknown> | null | undefined): unknown[] {
if (!obj) return [];
return Object.entries(obj)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => a.localeCompare(b))
.flat();
}
export const cacheKeys = { export const cacheKeys = {
// New chat threads (assistant-ui) // New chat threads (assistant-ui)
threads: { threads: {
@ -13,9 +26,9 @@ export const cacheKeys = {
}, },
documents: { documents: {
globalQueryParams: (queries: GetDocumentsRequest["queryParams"]) => globalQueryParams: (queries: GetDocumentsRequest["queryParams"]) =>
["documents", ...(queries ? Object.values(queries) : [])] as const, ["documents", ...stableEntries(queries)] as const,
withQueryParams: (queries: GetDocumentsRequest["queryParams"]) => withQueryParams: (queries: GetDocumentsRequest["queryParams"]) =>
["documents-with-queries", ...(queries ? Object.values(queries) : [])] as const, ["documents-with-queries", ...stableEntries(queries)] as const,
document: (documentId: string) => ["document", documentId] as const, document: (documentId: string) => ["document", documentId] as const,
byChunk: (chunkId: string) => ["documents", "by-chunk", chunkId] as const, byChunk: (chunkId: string) => ["documents", "by-chunk", chunkId] as const,
}, },
@ -24,7 +37,7 @@ export const cacheKeys = {
detail: (logId: number | string) => ["logs", "detail", logId] as const, detail: (logId: number | string) => ["logs", "detail", logId] as const,
summary: (searchSpaceId?: number | string) => ["logs", "summary", searchSpaceId] as const, summary: (searchSpaceId?: number | string) => ["logs", "summary", searchSpaceId] as const,
withQueryParams: (queries: GetLogsRequest["queryParams"]) => withQueryParams: (queries: GetLogsRequest["queryParams"]) =>
["logs", "with-query-params", ...(queries ? Object.values(queries) : [])] as const, ["logs", "with-query-params", ...stableEntries(queries)] as const,
}, },
newLLMConfigs: { newLLMConfigs: {
all: (searchSpaceId: number) => ["new-llm-configs", searchSpaceId] as const, all: (searchSpaceId: number) => ["new-llm-configs", searchSpaceId] as const,
@ -51,7 +64,7 @@ export const cacheKeys = {
searchSpaces: { searchSpaces: {
all: ["search-spaces"] as const, all: ["search-spaces"] as const,
withQueryParams: (queries: GetSearchSpacesRequest["queryParams"]) => withQueryParams: (queries: GetSearchSpacesRequest["queryParams"]) =>
["search-spaces", ...(queries ? Object.values(queries) : [])] as const, ["search-spaces", ...stableEntries(queries)] as const,
detail: (searchSpaceId: string) => ["search-spaces", searchSpaceId] as const, detail: (searchSpaceId: string) => ["search-spaces", searchSpaceId] as const,
}, },
user: { user: {
@ -78,7 +91,7 @@ export const cacheKeys = {
connectors: { connectors: {
all: (searchSpaceId: string) => ["connectors", searchSpaceId] as const, all: (searchSpaceId: string) => ["connectors", searchSpaceId] as const,
withQueryParams: (queries: GetConnectorsRequest["queryParams"]) => withQueryParams: (queries: GetConnectorsRequest["queryParams"]) =>
["connectors", ...(queries ? Object.values(queries) : [])] as const, ["connectors", ...stableEntries(queries)] as const,
byId: (connectorId: string) => ["connector", connectorId] as const, byId: (connectorId: string) => ["connector", connectorId] as const,
index: () => ["connector", "index"] as const, index: () => ["connector", "index"] as const,
googleDrive: { googleDrive: {

14
surfsense_web/lib/url.ts Normal file
View file

@ -0,0 +1,14 @@
/**
* Extract a normalized hostname from a URL. Strips a leading `www.`.
* Returns `undefined` if the input is not a parseable URL.
*
* This is the canonical replacement for the four previously-duplicated
* `extractDomain` helpers that had subtly different error fallbacks.
*/
export function tryGetHostname(url: string): string | undefined {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return undefined;
}
}

View file

@ -81,7 +81,7 @@
"@remotion/media": "^4.0.438", "@remotion/media": "^4.0.438",
"@remotion/player": "^4.0.438", "@remotion/player": "^4.0.438",
"@remotion/web-renderer": "^4.0.438", "@remotion/web-renderer": "^4.0.438",
"@rocicorp/zero": "^0.26.2", "@rocicorp/zero": "1.4.0",
"@slate-serializers/html": "^2.2.3", "@slate-serializers/html": "^2.2.3",
"@streamdown/code": "^1.0.2", "@streamdown/code": "^1.0.2",
"@streamdown/math": "^1.0.2", "@streamdown/math": "^1.0.2",

File diff suppressed because it is too large Load diff