Compare commits

..

15 Commits

Author SHA1 Message Date
Dane Urban
80cf389774 . 2026-02-23 16:30:30 -08:00
Danelegend
e775aaacb7 chore: preview modal (#8665) 2026-02-23 16:29:13 -08:00
Justin Tahara
e5b08b3d92 fix(search): Improve Speed (#8430) 2026-02-23 16:29:13 -08:00
Jamison Lahman
7c91304ba2 chore(playwright): warn user if setup takes longer than usual (#8690) 2026-02-23 16:29:13 -08:00
roshan
68a292b500 fix(ui): Clean up NRF settings button styling (#8678)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-23 16:29:13 -08:00
Justin Tahara
e553b80030 fix(db): Multitenant Schema migration update (#8679) 2026-02-23 16:29:13 -08:00
Justin Tahara
f3949f8e09 chore(ods): Automated Cherry-pick backport (#8642) 2026-02-23 16:29:13 -08:00
Nikolas Garza
c7c064e296 feat(scim): Okta compatibility + provider abstraction (#8568) 2026-02-23 16:29:13 -08:00
Wenxi
68b91a8862 fix: domain rules for signup on cloud (#8671) 2026-02-23 16:29:13 -08:00
roshan
c23e5a196d fix: Handle unauthenticated state gracefully on NRF page (#8491)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-23 16:29:13 -08:00
Raunak Bhagat
093223c6c4 refactor: migrate Web Search page to SettingsLayouts + Content (#8662) 2026-02-23 16:29:13 -08:00
Danelegend
89517111d4 feat: Add code interpreter server db model (#8669) 2026-02-23 16:29:13 -08:00
Wenxi
883d4b4ceb chore: set trial api usage to 0 and show ui (#8664) 2026-02-23 16:29:13 -08:00
Dane Urban
f3672b6819 CSV rendering 2026-02-22 18:33:39 -08:00
Dane Urban
921f5d9e96 preview modal 2026-02-22 17:42:30 -08:00
38 changed files with 209 additions and 1131 deletions

View File

@@ -1,29 +0,0 @@
"""code interpreter seed
Revision ID: 07b98176f1de
Revises: 7cb492013621
Create Date: 2026-02-23 15:55:07.606784
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "07b98176f1de"
down_revision = "7cb492013621"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Seed the single instance of code_interpreter_server
# NOTE: There should only exist at most and at minimum 1 code_interpreter_server row
op.execute(
sa.text("INSERT INTO code_interpreter_server (server_enabled) VALUES (true)")
)
def downgrade() -> None:
op.execute(sa.text("DELETE FROM code_interpreter_server"))

View File

@@ -5,10 +5,8 @@ from uuid import UUID
import httpx
import sqlalchemy as sa
from celery import Celery
from celery import shared_task
from celery import Task
from redis import Redis
from redis.lock import Lock as RedisLock
from retry import retry
from sqlalchemy import select
@@ -26,14 +24,12 @@ from onyx.configs.constants import CELERY_GENERIC_BEAT_LOCK_TIMEOUT
from onyx.configs.constants import CELERY_USER_FILE_PROCESSING_LOCK_TIMEOUT
from onyx.configs.constants import CELERY_USER_FILE_PROCESSING_TASK_EXPIRES
from onyx.configs.constants import CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT
from onyx.configs.constants import CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES
from onyx.configs.constants import DocumentSource
from onyx.configs.constants import OnyxCeleryPriority
from onyx.configs.constants import OnyxCeleryQueues
from onyx.configs.constants import OnyxCeleryTask
from onyx.configs.constants import OnyxRedisLocks
from onyx.configs.constants import USER_FILE_PROCESSING_MAX_QUEUE_DEPTH
from onyx.configs.constants import USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH
from onyx.connectors.file.connector import LocalFileConnector
from onyx.connectors.models import Document
from onyx.connectors.models import HierarchyNode
@@ -79,58 +75,10 @@ def _user_file_project_sync_lock_key(user_file_id: str | UUID) -> str:
return f"{OnyxRedisLocks.USER_FILE_PROJECT_SYNC_LOCK_PREFIX}:{user_file_id}"
def _user_file_project_sync_queued_key(user_file_id: str | UUID) -> str:
return f"{OnyxRedisLocks.USER_FILE_PROJECT_SYNC_QUEUED_PREFIX}:{user_file_id}"
def _user_file_delete_lock_key(user_file_id: str | UUID) -> str:
return f"{OnyxRedisLocks.USER_FILE_DELETE_LOCK_PREFIX}:{user_file_id}"
def get_user_file_project_sync_queue_depth(celery_app: Celery) -> int:
redis_celery: Redis = celery_app.broker_connection().channel().client # type: ignore
return celery_get_queue_length(
OnyxCeleryQueues.USER_FILE_PROJECT_SYNC, redis_celery
)
def enqueue_user_file_project_sync_task(
*,
celery_app: Celery,
redis_client: Redis,
user_file_id: str | UUID,
tenant_id: str,
priority: OnyxCeleryPriority = OnyxCeleryPriority.HIGH,
) -> bool:
"""Enqueue a project-sync task if no matching queued task already exists."""
queued_key = _user_file_project_sync_queued_key(user_file_id)
# NX+EX gives us atomic dedupe and a self-healing TTL.
queued_guard_set = redis_client.set(
queued_key,
1,
nx=True,
ex=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
)
if not queued_guard_set:
return False
try:
celery_app.send_task(
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
kwargs={"user_file_id": str(user_file_id), "tenant_id": tenant_id},
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
priority=priority,
expires=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
)
except Exception:
# Roll back the queued guard if task publish fails.
redis_client.delete(queued_key)
raise
return True
@retry(tries=3, delay=1, backoff=2, jitter=(0.0, 1.0))
def _visit_chunks(
*,
@@ -684,8 +632,8 @@ def process_single_user_file_delete(
ignore_result=True,
)
def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None:
"""Scan for user files needing project sync and enqueue per-file tasks."""
task_logger.info("Starting")
"""Scan for user files with PROJECT_SYNC status and enqueue per-file tasks."""
task_logger.info("check_for_user_file_project_sync - Starting")
redis_client = get_redis_client(tenant_id=tenant_id)
lock: RedisLock = redis_client.lock(
@@ -697,16 +645,7 @@ def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None:
return None
enqueued = 0
skipped_guard = 0
try:
queue_depth = get_user_file_project_sync_queue_depth(self.app)
if queue_depth > USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH:
task_logger.warning(
f"Queue depth {queue_depth} exceeds "
f"{USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH}, skipping enqueue for tenant={tenant_id}"
)
return None
with get_session_with_current_tenant() as db_session:
user_file_ids = (
db_session.execute(
@@ -722,23 +661,19 @@ def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None:
)
for user_file_id in user_file_ids:
if not enqueue_user_file_project_sync_task(
celery_app=self.app,
redis_client=redis_client,
user_file_id=user_file_id,
tenant_id=tenant_id,
self.app.send_task(
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
kwargs={"user_file_id": str(user_file_id), "tenant_id": tenant_id},
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
priority=OnyxCeleryPriority.HIGH,
):
skipped_guard += 1
continue
)
enqueued += 1
finally:
if lock.owned():
lock.release()
task_logger.info(
f"Enqueued {enqueued} "
f"Skipped guard {skipped_guard} tasks for tenant={tenant_id}"
f"check_for_user_file_project_sync - Enqueued {enqueued} tasks for tenant={tenant_id}"
)
return None
@@ -757,8 +692,6 @@ def process_single_user_file_project_sync(
)
redis_client = get_redis_client(tenant_id=tenant_id)
redis_client.delete(_user_file_project_sync_queued_key(user_file_id))
file_lock: RedisLock = redis_client.lock(
_user_file_project_sync_lock_key(user_file_id),
timeout=CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT,

View File

@@ -30,7 +30,6 @@ from onyx.configs.constants import DocumentSource
from onyx.configs.constants import MessageType
from onyx.context.search.models import SearchDoc
from onyx.context.search.models import SearchDocsResponse
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.memory import add_memory
from onyx.db.memory import update_memory_at_index
from onyx.db.memory import UserMemoryContext
@@ -657,12 +656,7 @@ def run_llm_loop(
fallback_extraction_attempted: bool = False
citation_mapping: dict[int, str] = {} # Maps citation_num -> document_id/URL
# Fetch this in a short-lived session so the long-running stream loop does
# not pin a connection just to keep read state alive.
with get_session_with_current_tenant() as prompt_db_session:
default_base_system_prompt: str = get_default_base_system_prompt(
prompt_db_session
)
default_base_system_prompt: str = get_default_base_system_prompt(db_session)
system_prompt = None
custom_agent_prompt_msg = None

View File

@@ -856,11 +856,6 @@ def handle_stream_message_objects(
reserved_tokens=reserved_token_count,
)
# Release any read transaction before entering the long-running LLM stream.
# Without this, the request-scoped session can keep a connection checked out
# for the full stream duration.
db_session.commit()
# The stream generator can resume on a different worker thread after early yields.
# Set this right before launching the LLM loop so run_in_background copies the right context.
if new_msg_req.mock_llm_response is not None:

View File

@@ -167,14 +167,6 @@ CELERY_USER_FILE_PROCESSING_TASK_EXPIRES = 60 # 1 minute (in seconds)
# beat generator stops adding more. Prevents unbounded queue growth when workers
# fall behind.
USER_FILE_PROCESSING_MAX_QUEUE_DEPTH = 500
# How long a queued user-file-project-sync task remains valid.
# Should be short enough to discard stale queue entries under load while still
# allowing workers enough time to pick up new tasks.
CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES = 60 # 1 minute (in seconds)
# Max queue depth before user-file-project-sync producers stop enqueuing.
# This applies backpressure when workers are falling behind.
USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH = 500
CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT = 5 * 60 # 5 minutes (in seconds)
@@ -467,7 +459,6 @@ class OnyxRedisLocks:
USER_FILE_QUEUED_PREFIX = "da_lock:user_file_queued"
USER_FILE_PROJECT_SYNC_BEAT_LOCK = "da_lock:check_user_file_project_sync_beat"
USER_FILE_PROJECT_SYNC_LOCK_PREFIX = "da_lock:user_file_project_sync"
USER_FILE_PROJECT_SYNC_QUEUED_PREFIX = "da_lock:user_file_project_sync_queued"
USER_FILE_DELETE_BEAT_LOCK = "da_lock:check_user_file_delete_beat"
USER_FILE_DELETE_LOCK_PREFIX = "da_lock:user_file_delete"

View File

@@ -6,7 +6,6 @@ from typing import cast
from pydantic import BaseModel
from pydantic import Field
from pydantic import field_validator
from pydantic import model_validator
from onyx.access.models import ExternalAccess
@@ -168,14 +167,6 @@ class DocumentBase(BaseModel):
# list of strings.
metadata: dict[str, str | list[str]]
@field_validator("metadata", mode="before")
@classmethod
def _coerce_metadata_values(cls, v: dict[str, Any]) -> dict[str, str | list[str]]:
return {
key: [str(item) for item in val] if isinstance(val, list) else str(val)
for key, val in v.items()
}
# UTC time
doc_updated_at: datetime | None = None
chunk_count: int | None = None

View File

@@ -762,43 +762,6 @@ def download_webapp(
)
@router.get("/{session_id}/download-directory/{path:path}")
def download_directory(
session_id: UUID,
path: str,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> Response:
"""
Download a directory as a zip file.
Returns the specified directory as a zip archive.
"""
user_id: UUID = user.id
session_manager = SessionManager(db_session)
try:
result = session_manager.download_directory(session_id, user_id, path)
except ValueError as e:
error_message = str(e)
if "path traversal" in error_message.lower():
raise HTTPException(status_code=403, detail="Access denied")
raise HTTPException(status_code=400, detail=error_message)
if result is None:
raise HTTPException(status_code=404, detail="Directory not found")
zip_bytes, filename = result
return Response(
content=zip_bytes,
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)
@router.post("/{session_id}/upload", response_model=UploadResponse)
def upload_file_endpoint(
session_id: UUID,

View File

@@ -107,23 +107,27 @@ def get_or_create_craft_connector(db_session: Session, user: User) -> tuple[int,
)
for cc_pair in cc_pairs:
if (
cc_pair.connector.source == DocumentSource.CRAFT_FILE
and cc_pair.creator_id == user.id
):
if cc_pair.connector.source == DocumentSource.CRAFT_FILE:
return cc_pair.connector.id, cc_pair.credential.id
# No cc_pair for this user — find or create the shared CRAFT_FILE connector
# Check for orphaned connector (created but cc_pair creation failed previously)
existing_connectors = fetch_connectors(
db_session, sources=[DocumentSource.CRAFT_FILE]
)
connector_id: int | None = None
orphaned_connector = None
for conn in existing_connectors:
if conn.name == USER_LIBRARY_CONNECTOR_NAME:
connector_id = conn.id
if conn.name != USER_LIBRARY_CONNECTOR_NAME:
continue
if not conn.credentials:
orphaned_connector = conn
break
if connector_id is None:
if orphaned_connector:
connector_id = orphaned_connector.id
logger.info(
f"Found orphaned User Library connector {connector_id}, completing setup"
)
else:
connector_data = ConnectorBase(
name=USER_LIBRARY_CONNECTOR_NAME,
source=DocumentSource.CRAFT_FILE,

View File

@@ -646,30 +646,16 @@ class SessionManager:
if sandbox and sandbox.status.is_active():
# Quick health check to verify sandbox is actually responsive
# AND verify the session workspace still exists on disk
# (it may have been wiped if the sandbox was re-provisioned)
is_healthy = self._sandbox_manager.health_check(sandbox.id, timeout=5.0)
workspace_exists = (
is_healthy
and self._sandbox_manager.session_workspace_exists(
sandbox.id, existing.id
)
)
if is_healthy and workspace_exists:
if self._sandbox_manager.health_check(sandbox.id, timeout=5.0):
logger.info(
f"Returning existing empty session {existing.id} for user {user_id}"
)
return existing
elif not is_healthy:
else:
logger.warning(
f"Empty session {existing.id} has unhealthy sandbox {sandbox.id}. "
f"Deleting and creating fresh session."
)
else:
logger.warning(
f"Empty session {existing.id} workspace missing in sandbox "
f"{sandbox.id}. Deleting and creating fresh session."
)
else:
logger.warning(
f"Empty session {existing.id} has no active sandbox "
@@ -1917,94 +1903,6 @@ class SessionManager:
return zip_buffer.getvalue(), filename
def download_directory(
self,
session_id: UUID,
user_id: UUID,
path: str,
) -> tuple[bytes, str] | None:
"""
Create a zip file of an arbitrary directory in the session workspace.
Args:
session_id: The session UUID
user_id: The user ID to verify ownership
path: Relative path to the directory (within session workspace)
Returns:
Tuple of (zip_bytes, filename) or None if session not found
Raises:
ValueError: If path traversal attempted or path is not a directory
"""
# Verify session ownership
session = get_build_session(session_id, user_id, self._db_session)
if session is None:
return None
sandbox = get_sandbox_by_user_id(self._db_session, user_id)
if sandbox is None:
return None
# Check if directory exists
try:
self._sandbox_manager.list_directory(
sandbox_id=sandbox.id,
session_id=session_id,
path=path,
)
except ValueError:
return None
# Recursively collect all files
def collect_files(dir_path: str) -> list[tuple[str, str]]:
"""Collect all files recursively, returning (full_path, arcname) tuples."""
files: list[tuple[str, str]] = []
try:
entries = self._sandbox_manager.list_directory(
sandbox_id=sandbox.id,
session_id=session_id,
path=dir_path,
)
for entry in entries:
if entry.is_directory:
files.extend(collect_files(entry.path))
else:
# arcname is relative to the target directory
prefix_len = len(path) + 1 # +1 for trailing slash
arcname = entry.path[prefix_len:]
files.append((entry.path, arcname))
except ValueError:
pass
return files
file_list = collect_files(path)
# Create zip file in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for full_path, arcname in file_list:
try:
content = self._sandbox_manager.read_file(
sandbox_id=sandbox.id,
session_id=session_id,
path=full_path,
)
zip_file.writestr(arcname, content)
except ValueError:
pass
zip_buffer.seek(0)
# Use the directory name for the zip filename
dir_name = Path(path).name
safe_name = "".join(
c if c.isalnum() or c in ("-", "_", ".") else "_" for c in dir_name
)
filename = f"{safe_name}.zip"
return zip_buffer.getvalue(), filename
# =========================================================================
# File System Operations
# =========================================================================
@@ -2039,18 +1937,11 @@ class SessionManager:
return None
# Use sandbox manager to list directory (works for both local and K8s)
# If the directory doesn't exist (e.g., session workspace not yet loaded),
# return an empty listing rather than erroring out.
try:
raw_entries = self._sandbox_manager.list_directory(
sandbox_id=sandbox.id,
session_id=session_id,
path=path,
)
except ValueError as e:
if "path traversal" in str(e).lower():
raise
return DirectoryListing(path=path, entries=[])
raw_entries = self._sandbox_manager.list_directory(
sandbox_id=sandbox.id,
session_id=session_id,
path=path,
)
# Filter hidden files and directories
entries: list[FileSystemEntry] = [

View File

@@ -12,18 +12,11 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
from onyx.background.celery.tasks.user_file_processing.tasks import (
enqueue_user_file_project_sync_task,
)
from onyx.background.celery.tasks.user_file_processing.tasks import (
get_user_file_project_sync_queue_depth,
)
from onyx.background.celery.versioned_apps.client import app as client_app
from onyx.configs.constants import OnyxCeleryPriority
from onyx.configs.constants import OnyxCeleryQueues
from onyx.configs.constants import OnyxCeleryTask
from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.configs.constants import USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import UserFileStatus
from onyx.db.models import ChatSession
@@ -34,7 +27,6 @@ from onyx.db.models import UserProject
from onyx.db.persona import get_personas_by_ids
from onyx.db.projects import get_project_token_count
from onyx.db.projects import upload_files_to_user_files_with_indexing
from onyx.redis.redis_pool import get_redis_client
from onyx.server.features.projects.models import CategorizedFilesSnapshot
from onyx.server.features.projects.models import ChatSessionRequest
from onyx.server.features.projects.models import TokenCountResponse
@@ -55,33 +47,6 @@ class UserFileDeleteResult(BaseModel):
assistant_names: list[str] = []
def _trigger_user_file_project_sync(user_file_id: UUID, tenant_id: str) -> None:
queue_depth = get_user_file_project_sync_queue_depth(client_app)
if queue_depth > USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH:
logger.warning(
f"Skipping immediate project sync for user_file_id={user_file_id} due to "
f"queue depth {queue_depth}>{USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH}. "
"It will be picked up by beat later."
)
return
redis_client = get_redis_client(tenant_id=tenant_id)
enqueued = enqueue_user_file_project_sync_task(
celery_app=client_app,
redis_client=redis_client,
user_file_id=user_file_id,
tenant_id=tenant_id,
priority=OnyxCeleryPriority.HIGHEST,
)
if not enqueued:
logger.info(
f"Skipped duplicate project sync enqueue for user_file_id={user_file_id}"
)
return
logger.info(f"Triggered project sync for user_file_id={user_file_id}")
@router.get("", tags=PUBLIC_API_TAGS)
def get_projects(
user: User = Depends(current_user),
@@ -224,7 +189,15 @@ def unlink_user_file_from_project(
db_session.commit()
tenant_id = get_current_tenant_id()
_trigger_user_file_project_sync(user_file.id, tenant_id)
task = client_app.send_task(
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
kwargs={"user_file_id": user_file.id, "tenant_id": tenant_id},
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
priority=OnyxCeleryPriority.HIGHEST,
)
logger.info(
f"Triggered project sync for user_file_id={user_file.id} with task_id={task.id}"
)
return Response(status_code=204)
@@ -268,7 +241,15 @@ def link_user_file_to_project(
db_session.commit()
tenant_id = get_current_tenant_id()
_trigger_user_file_project_sync(user_file.id, tenant_id)
task = client_app.send_task(
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
kwargs={"user_file_id": user_file.id, "tenant_id": tenant_id},
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
priority=OnyxCeleryPriority.HIGHEST,
)
logger.info(
f"Triggered project sync for user_file_id={user_file.id} with task_id={task.id}"
)
return UserFileSnapshot.from_model(user_file)

View File

@@ -587,7 +587,6 @@ def handle_send_chat_message(
request.headers
),
mcp_headers=chat_message_req.mcp_headers,
additional_context=chat_message_req.additional_context,
external_state_container=state_container,
)
result = gather_stream_full(packets, state_container)
@@ -610,7 +609,6 @@ def handle_send_chat_message(
request.headers
),
mcp_headers=chat_message_req.mcp_headers,
additional_context=chat_message_req.additional_context,
external_state_container=state_container,
):
yield get_json_line(obj.model_dump())

View File

@@ -125,11 +125,6 @@ class SendMessageRequest(BaseModel):
# - No CitationInfo packets are emitted during streaming
include_citations: bool = True
# Additional context injected into the LLM call but NOT stored in the DB
# (not shown in chat history). Used e.g. by the Chrome extension to pass
# the current tab URL when "Read this tab" is enabled.
additional_context: str | None = None
@model_validator(mode="after")
def check_chat_session_id_or_info(self) -> "SendMessageRequest":
# If neither is provided, default to creating a new chat session using the

View File

@@ -9,7 +9,6 @@ from collections.abc import AsyncGenerator
from collections.abc import Generator
from contextlib import asynccontextmanager
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from dotenv import load_dotenv
@@ -47,15 +46,11 @@ def mock_current_admin_user() -> MagicMock:
@pytest.fixture(scope="function")
def client() -> Generator[TestClient, None, None]:
# Initialize TestClient with the FastAPI app using a no-op test lifespan.
# Patch out prometheus metrics setup to avoid "Duplicated timeseries in
# CollectorRegistry" errors when multiple tests each create a new app
# (prometheus registers metrics globally and rejects duplicate names).
# Initialize TestClient with the FastAPI app using a no-op test lifespan
get_app = fetch_versioned_implementation(
module="onyx.main", attribute="get_application"
)
with patch("onyx.main.setup_prometheus_metrics"):
app: FastAPI = get_app(lifespan_override=test_lifespan)
app: FastAPI = get_app(lifespan_override=test_lifespan)
# Override the database session dependency with a mock
# (these tests don't actually need DB access)

View File

@@ -1,168 +0,0 @@
from unittest.mock import MagicMock
from unittest.mock import patch
from uuid import uuid4
import pytest
from onyx.background.celery.tasks.user_file_processing.tasks import (
_user_file_project_sync_queued_key,
)
from onyx.background.celery.tasks.user_file_processing.tasks import (
check_for_user_file_project_sync,
)
from onyx.background.celery.tasks.user_file_processing.tasks import (
enqueue_user_file_project_sync_task,
)
from onyx.background.celery.tasks.user_file_processing.tasks import (
process_single_user_file_project_sync,
)
from onyx.configs.constants import CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES
from onyx.configs.constants import OnyxCeleryPriority
from onyx.configs.constants import OnyxCeleryQueues
from onyx.configs.constants import OnyxCeleryTask
from onyx.configs.constants import USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH
def _build_redis_mock_with_lock() -> tuple[MagicMock, MagicMock]:
redis_client = MagicMock()
lock = MagicMock()
lock.acquire.return_value = True
lock.owned.return_value = True
redis_client.lock.return_value = lock
return redis_client, lock
@patch(
"onyx.background.celery.tasks.user_file_processing.tasks."
"get_user_file_project_sync_queue_depth"
)
@patch("onyx.background.celery.tasks.user_file_processing.tasks.get_redis_client")
def test_check_for_user_file_project_sync_applies_queue_backpressure(
mock_get_redis_client: MagicMock,
mock_get_queue_depth: MagicMock,
) -> None:
redis_client, lock = _build_redis_mock_with_lock()
mock_get_redis_client.return_value = redis_client
mock_get_queue_depth.return_value = USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH + 1
task_app = MagicMock()
with patch.object(check_for_user_file_project_sync, "app", task_app):
check_for_user_file_project_sync.run(tenant_id="test-tenant")
task_app.send_task.assert_not_called()
lock.release.assert_called_once()
@patch(
"onyx.background.celery.tasks.user_file_processing.tasks."
"enqueue_user_file_project_sync_task"
)
@patch(
"onyx.background.celery.tasks.user_file_processing.tasks."
"get_user_file_project_sync_queue_depth"
)
@patch(
"onyx.background.celery.tasks.user_file_processing.tasks."
"get_session_with_current_tenant"
)
@patch("onyx.background.celery.tasks.user_file_processing.tasks.get_redis_client")
def test_check_for_user_file_project_sync_skips_duplicates(
mock_get_redis_client: MagicMock,
mock_get_session: MagicMock,
mock_get_queue_depth: MagicMock,
mock_enqueue: MagicMock,
) -> None:
redis_client, lock = _build_redis_mock_with_lock()
mock_get_redis_client.return_value = redis_client
mock_get_queue_depth.return_value = 0
user_file_id_one = uuid4()
user_file_id_two = uuid4()
session = MagicMock()
session.execute.return_value.scalars.return_value.all.return_value = [
user_file_id_one,
user_file_id_two,
]
mock_get_session.return_value.__enter__.return_value = session
mock_enqueue.side_effect = [True, False]
task_app = MagicMock()
with patch.object(check_for_user_file_project_sync, "app", task_app):
check_for_user_file_project_sync.run(tenant_id="test-tenant")
assert mock_enqueue.call_count == 2
lock.release.assert_called_once()
def test_enqueue_user_file_project_sync_task_sets_guard_and_expiry() -> None:
redis_client = MagicMock()
redis_client.set.return_value = True
celery_app = MagicMock()
user_file_id = str(uuid4())
enqueued = enqueue_user_file_project_sync_task(
celery_app=celery_app,
redis_client=redis_client,
user_file_id=user_file_id,
tenant_id="test-tenant",
priority=OnyxCeleryPriority.HIGHEST,
)
assert enqueued is True
redis_client.set.assert_called_once_with(
_user_file_project_sync_queued_key(user_file_id),
1,
nx=True,
ex=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
)
celery_app.send_task.assert_called_once_with(
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
kwargs={"user_file_id": user_file_id, "tenant_id": "test-tenant"},
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
priority=OnyxCeleryPriority.HIGHEST,
expires=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
)
def test_enqueue_user_file_project_sync_task_rolls_back_guard_on_publish_failure() -> (
None
):
redis_client = MagicMock()
redis_client.set.return_value = True
celery_app = MagicMock()
celery_app.send_task.side_effect = RuntimeError("publish failed")
user_file_id = str(uuid4())
with pytest.raises(RuntimeError):
enqueue_user_file_project_sync_task(
celery_app=celery_app,
redis_client=redis_client,
user_file_id=user_file_id,
tenant_id="test-tenant",
)
redis_client.delete.assert_called_once_with(
_user_file_project_sync_queued_key(user_file_id)
)
@patch("onyx.background.celery.tasks.user_file_processing.tasks.get_redis_client")
def test_process_single_user_file_project_sync_clears_queued_guard_on_pickup(
mock_get_redis_client: MagicMock,
) -> None:
redis_client = MagicMock()
lock = MagicMock()
lock.acquire.return_value = False
redis_client.lock.return_value = lock
mock_get_redis_client.return_value = redis_client
user_file_id = str(uuid4())
process_single_user_file_project_sync.run(
user_file_id=user_file_id,
tenant_id="test-tenant",
)
redis_client.delete.assert_called_once_with(
_user_file_project_sync_queued_key(user_file_id)
)

View File

@@ -1,95 +0,0 @@
from onyx.configs.constants import DocumentSource
from onyx.connectors.models import Document
from onyx.connectors.models import DocumentBase
from onyx.connectors.models import TextSection
def _minimal_doc_kwargs(metadata: dict) -> dict:
return {
"id": "test-doc",
"sections": [TextSection(text="hello", link="http://example.com")],
"source": DocumentSource.NOT_APPLICABLE,
"semantic_identifier": "Test Doc",
"metadata": metadata,
}
def test_int_values_coerced_to_str() -> None:
doc = Document(**_minimal_doc_kwargs({"count": 42}))
assert doc.metadata == {"count": "42"}
def test_float_values_coerced_to_str() -> None:
doc = Document(**_minimal_doc_kwargs({"score": 3.14}))
assert doc.metadata == {"score": "3.14"}
def test_bool_values_coerced_to_str() -> None:
doc = Document(**_minimal_doc_kwargs({"active": True}))
assert doc.metadata == {"active": "True"}
def test_list_of_ints_coerced_to_list_of_str() -> None:
doc = Document(**_minimal_doc_kwargs({"ids": [1, 2, 3]}))
assert doc.metadata == {"ids": ["1", "2", "3"]}
def test_list_of_mixed_types_coerced_to_list_of_str() -> None:
doc = Document(**_minimal_doc_kwargs({"tags": ["a", 1, True, 2.5]}))
assert doc.metadata == {"tags": ["a", "1", "True", "2.5"]}
def test_list_of_dicts_coerced_to_list_of_str() -> None:
raw = {"nested": [{"key": "val"}, {"key2": "val2"}]}
doc = Document(**_minimal_doc_kwargs(raw))
assert doc.metadata == {"nested": ["{'key': 'val'}", "{'key2': 'val2'}"]}
def test_dict_value_coerced_to_str() -> None:
raw = {"info": {"inner_key": "inner_val"}}
doc = Document(**_minimal_doc_kwargs(raw))
assert doc.metadata == {"info": "{'inner_key': 'inner_val'}"}
def test_none_value_coerced_to_str() -> None:
doc = Document(**_minimal_doc_kwargs({"empty": None}))
assert doc.metadata == {"empty": "None"}
def test_already_valid_str_values_unchanged() -> None:
doc = Document(**_minimal_doc_kwargs({"key": "value"}))
assert doc.metadata == {"key": "value"}
def test_already_valid_list_of_str_unchanged() -> None:
doc = Document(**_minimal_doc_kwargs({"tags": ["a", "b", "c"]}))
assert doc.metadata == {"tags": ["a", "b", "c"]}
def test_empty_metadata_unchanged() -> None:
doc = Document(**_minimal_doc_kwargs({}))
assert doc.metadata == {}
def test_mixed_metadata_values() -> None:
raw = {
"str_val": "hello",
"int_val": 99,
"list_val": [1, "two", 3.0],
"dict_val": {"nested": True},
}
doc = Document(**_minimal_doc_kwargs(raw))
assert doc.metadata == {
"str_val": "hello",
"int_val": "99",
"list_val": ["1", "two", "3.0"],
"dict_val": "{'nested': True}",
}
def test_coercion_works_on_base_class() -> None:
kwargs = _minimal_doc_kwargs({"count": 42})
kwargs.pop("source")
kwargs.pop("id")
doc = DocumentBase(**kwargs)
assert doc.metadata == {"count": "42"}

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Onyx",
"version": "1.1",
"version": "1.0",
"description": "Onyx lets you research, create, and automate with LLMs powered by your team's unique knowledge",
"permissions": [
"sidePanel",

View File

@@ -43,12 +43,8 @@ async function openSidePanel(tabId) {
}
}
function encodeUserPrompt(text) {
return encodeURIComponent(text).replace(/\(/g, "%28").replace(/\)/g, "%29");
}
async function sendToOnyx(info, tab) {
const selectedText = encodeUserPrompt(info.selectionText);
const selectedText = encodeURIComponent(info.selectionText);
const currentUrl = encodeURIComponent(tab.url);
try {
@@ -157,23 +153,6 @@ chrome.commands.onCommand.addListener(async (command) => {
}
});
async function sendActiveTabUrlToPanel() {
try {
const [tab] = await chrome.tabs.query({
active: true,
lastFocusedWindow: true,
});
if (tab?.url) {
chrome.runtime.sendMessage({
action: ACTIONS.TAB_URL_UPDATED,
url: tab.url,
});
}
} catch (error) {
console.error("[Onyx SW] Error sending tab URL:", error);
}
}
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === ACTIONS.GET_CURRENT_ONYX_DOMAIN) {
chrome.storage.local.get(
@@ -209,7 +188,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
chrome.storage.local.get(
{ [CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]: DEFAULT_ONYX_DOMAIN },
(result) => {
const encodedText = encodeUserPrompt(selectedText);
const encodedText = encodeURIComponent(selectedText);
const onyxDomain = result[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN];
const url = `${onyxDomain}${SIDE_PANEL_PATH}?user-prompt=${encodedText}`;
@@ -243,15 +222,6 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
}
return true;
}
if (request.action === ACTIONS.TAB_READING_ENABLED) {
chrome.storage.session.set({ tabReadingEnabled: true });
sendActiveTabUrlToPanel();
return false;
}
if (request.action === ACTIONS.TAB_READING_DISABLED) {
chrome.storage.session.set({ tabReadingEnabled: false });
return false;
}
});
chrome.storage.onChanged.addListener((changes, namespace) => {
@@ -303,40 +273,4 @@ chrome.omnibox.onInputChanged.addListener((text, suggest) => {
}
});
chrome.tabs.onActivated.addListener(async (activeInfo) => {
const result = await chrome.storage.session.get({ tabReadingEnabled: false });
if (!result.tabReadingEnabled) return;
try {
const tab = await chrome.tabs.get(activeInfo.tabId);
if (tab.url) {
chrome.runtime.sendMessage({
action: ACTIONS.TAB_URL_UPDATED,
url: tab.url,
});
}
} catch (error) {
console.error("[Onyx SW] Error on tab activated:", error);
}
});
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (!changeInfo.url) return;
const result = await chrome.storage.session.get({ tabReadingEnabled: false });
if (!result.tabReadingEnabled) return;
try {
const [activeTab] = await chrome.tabs.query({
active: true,
lastFocusedWindow: true,
});
if (activeTab?.id === tabId) {
chrome.runtime.sendMessage({
action: ACTIONS.TAB_URL_UPDATED,
url: changeInfo.url,
});
}
} catch (error) {
console.error("[Onyx SW] Error on tab updated:", error);
}
});
setupSidePanel();

View File

@@ -132,7 +132,9 @@ import { getOnyxDomain } from "../utils/storage.js";
return;
}
setIframeSrc(items[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN] + "/nrf");
setIframeSrc(
items[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN] + "/chat/nrf",
);
},
);
}

View File

@@ -15,15 +15,6 @@ import {
let iframeLoadTimeout;
let authRequired = false;
// Returns the origin of the Onyx app loaded in the iframe.
// We derive the origin from iframe.src so postMessage payloads
// (including tab URLs) are only delivered to the expected page.
// Throws if iframe.src is not a valid URL — this is intentional:
// postMessage must never fall back to the unsafe wildcard "*".
function getIframeOrigin() {
return new URL(iframe.src).origin;
}
async function checkPendingInput() {
try {
const result = await chrome.storage.session.get("pendingInput");
@@ -66,7 +57,7 @@ import {
type: WEB_MESSAGE.PAGE_CHANGE,
url: pageUrl,
},
getIframeOrigin(),
"*",
);
currentUrl = pageUrl;
}
@@ -85,34 +76,15 @@ import {
}
function handleMessage(event) {
// Only trust messages from the Onyx app iframe.
// Check both source identity and origin so that a cross-origin page
// navigated to inside the iframe cannot send privileged extension
// messages (e.g. TAB_READING_ENABLED) after iframe.src changes.
// getIframeOrigin() throws if iframe.src is not yet a valid URL —
// catching it here fails closed (message is rejected, not processed).
if (event.source !== iframe.contentWindow) return;
try {
if (event.origin !== getIframeOrigin()) return;
} catch {
return;
}
if (event.data.type === CHROME_MESSAGE.ONYX_APP_LOADED) {
clearTimeout(iframeLoadTimeout);
iframeLoaded = true;
showIframe();
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(
{ type: "PANEL_READY" },
getIframeOrigin(),
);
iframe.contentWindow.postMessage({ type: "PANEL_READY" }, "*");
}
} else if (event.data.type === CHROME_MESSAGE.AUTH_REQUIRED) {
authRequired = true;
} else if (event.data.type === CHROME_MESSAGE.TAB_READING_ENABLED) {
chrome.runtime.sendMessage({ action: ACTIONS.TAB_READING_ENABLED });
} else if (event.data.type === CHROME_MESSAGE.TAB_READING_DISABLED) {
chrome.runtime.sendMessage({ action: ACTIONS.TAB_READING_DISABLED });
}
}
@@ -145,13 +117,6 @@ import {
setIframeSrc(request.url, request.pageUrl);
} else if (request.action === ACTIONS.UPDATE_PAGE_URL) {
sendWebsiteToIframe(request.pageUrl);
} else if (request.action === ACTIONS.TAB_URL_UPDATED) {
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(
{ type: CHROME_MESSAGE.TAB_URL_UPDATED, url: request.url },
getIframeOrigin(),
);
}
}
});

View File

@@ -5,7 +5,7 @@ export const THEMES = {
export const DEFAULT_ONYX_DOMAIN = "http://localhost:3000";
export const SIDE_PANEL_PATH = "/nrf/side-panel";
export const SIDE_PANEL_PATH = "/chat/nrf/side-panel";
export const ACTIONS = {
GET_SELECTED_TEXT: "getSelectedText",
@@ -17,9 +17,6 @@ export const ACTIONS = {
OPEN_SIDE_PANEL_WITH_INPUT: "openSidePanelWithInput",
OPEN_ONYX_WITH_INPUT: "openOnyxWithInput",
CLOSE_SIDE_PANEL: "closeSidePanel",
TAB_URL_UPDATED: "tabUrlUpdated",
TAB_READING_ENABLED: "tabReadingEnabled",
TAB_READING_DISABLED: "tabReadingDisabled",
};
export const CHROME_SPECIFIC_STORAGE_KEYS = {
@@ -39,9 +36,6 @@ export const CHROME_MESSAGE = {
LOAD_NEW_CHAT_PAGE: "LOAD_NEW_CHAT_PAGE",
LOAD_NEW_PAGE: "LOAD_NEW_PAGE",
AUTH_REQUIRED: "AUTH_REQUIRED",
TAB_READING_ENABLED: "TAB_READING_ENABLED",
TAB_READING_DISABLED: "TAB_READING_DISABLED",
TAB_URL_UPDATED: "TAB_URL_UPDATED",
};
export const WEB_MESSAGE = {

View File

@@ -11,10 +11,8 @@ import IconButton from "@/refresh-components/buttons/IconButton";
import Modal from "@/refresh-components/Modal";
import { useFilters, useLlmManager } from "@/lib/hooks";
import Dropzone from "react-dropzone";
import { useSendMessageToParent, getPanelOrigin } from "@/lib/extension/utils";
import { useSendMessageToParent } from "@/lib/extension/utils";
import { useNRFPreferences } from "@/components/context/NRFPreferencesContext";
import SidePanelHeader from "@/app/nrf/side-panel/SidePanelHeader";
import { CHROME_MESSAGE } from "@/lib/extension/constants";
import { SettingsPanel } from "@/app/components/nrf/SettingsPanel";
import LoginPage from "@/app/auth/login/LoginPage";
import { sendSetDefaultNewTabMessage } from "@/lib/extension/utils";
@@ -35,9 +33,16 @@ import ChatScrollContainer from "@/sections/chat/ChatScrollContainer";
import WelcomeMessage from "@/app/app/components/WelcomeMessage";
import useChatSessions from "@/hooks/useChatSessions";
import { cn } from "@/lib/utils";
import Logo from "@/refresh-components/Logo";
import Spacer from "@/refresh-components/Spacer";
import { useAppSidebarContext } from "@/providers/AppSidebarProvider";
import { DEFAULT_CONTEXT_TOKENS } from "@/lib/constants";
import { SvgUser, SvgMenu, SvgAlertTriangle } from "@opal/icons";
import {
SvgUser,
SvgMenu,
SvgExternalLink,
SvgAlertTriangle,
} from "@opal/icons";
import { useAppBackground } from "@/providers/AppBackgroundProvider";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import DocumentsSidebar from "@/sections/document-sidebar/DocumentsSidebar";
@@ -62,6 +67,14 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
const searchParams = useSearchParams();
const filterManager = useFilters();
const { user, authTypeMetadata } = useUser();
const { setFolded } = useAppSidebarContext();
// Hide sidebar when in side panel mode
useEffect(() => {
if (isSidePanel) {
setFolded(true);
}
}, [isSidePanel, setFolded]);
// Chat sessions
const { refreshChatSessions } = useChatSessions();
@@ -116,8 +129,6 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
// State
const [message, setMessage] = useState("");
const [settingsOpen, setSettingsOpen] = useState<boolean>(false);
const [tabReadingEnabled, setTabReadingEnabled] = useState<boolean>(false);
const [currentTabUrl, setCurrentTabUrl] = useState<string | null>(null);
const [presentingDocument, setPresentingDocument] =
useState<MinimalOnyxDocument | null>(null);
@@ -126,12 +137,6 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
const updateCurrentDocumentSidebarVisible = useChatSessionStore(
(state) => state.updateCurrentDocumentSidebarVisible
);
const setCurrentSession = useChatSessionStore(
(state) => state.setCurrentSession
);
const currentSessionId = useChatSessionStore(
(state) => state.currentSessionId
);
// Memoized callback for closing document sidebar
const handleDocumentSidebarClose = useCallback(() => {
@@ -196,26 +201,6 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
useSendMessageToParent();
// Listen for tab URL updates from the Chrome extension
useEffect(() => {
if (!isSidePanel) return;
function handleExtensionMessage(event: MessageEvent) {
// Only trust messages from the Chrome extension parent.
// Checking the origin (chrome-extension://) prevents a non-extension
// page that embeds NRFPage as an iframe from injecting arbitrary URLs
// into the prompt context via TAB_URL_UPDATED.
if (!event.origin.startsWith("chrome-extension://")) return;
if (event.source !== window.parent) return;
if (event.data?.type === CHROME_MESSAGE.TAB_URL_UPDATED) {
setCurrentTabUrl(event.data.url as string);
}
}
window.addEventListener("message", handleExtensionMessage);
return () => window.removeEventListener("message", handleExtensionMessage);
}, [isSidePanel]);
const toggleSettings = () => {
setSettingsOpen((prev) => !prev);
};
@@ -283,16 +268,23 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
[handleMessageSpecificFileUpload]
);
// Handler for chat submission (used by query controller)
const onChat = useCallback(
(chatMessage: string) => {
resetInputBar();
onSubmit({
message: chatMessage,
currentMessageFiles: currentMessageFiles,
deepResearch: deepResearchEnabled,
});
},
[onSubmit, currentMessageFiles, deepResearchEnabled, resetInputBar]
);
// Handle submit from AppInputBar - routes through query controller for search/chat classification
const handleChatInputSubmit = useCallback(
async (submittedMessage: string) => {
if (!submittedMessage.trim()) return;
const additionalContext =
tabReadingEnabled && currentTabUrl
? `The user is currently viewing: ${currentTabUrl}. Use the open_url tool to read this page and use its content as additional context for your response.`
: undefined;
// If we already have messages (chat session started), always use chat mode
// (matches AppPage behavior where existing sessions bypass classification)
if (hasMessages) {
@@ -301,22 +293,9 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
message: submittedMessage,
currentMessageFiles: currentMessageFiles,
deepResearch: deepResearchEnabled,
additionalContext,
});
return;
}
// Build an onChat closure that captures additionalContext for this submission
const onChat = (chatMessage: string) => {
resetInputBar();
onSubmit({
message: chatMessage,
currentMessageFiles: currentMessageFiles,
deepResearch: deepResearchEnabled,
additionalContext,
});
};
// Use submitQuery which will classify the query and either:
// - Route to search (sets classification to "search" and shows SearchUI)
// - Route to chat (calls onChat callback)
@@ -329,8 +308,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
deepResearchEnabled,
resetInputBar,
submitQuery,
tabReadingEnabled,
currentTabUrl,
onChat,
]
);
@@ -353,34 +331,9 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
});
}, [messageHistory, onSubmit, currentMessageFiles, deepResearchEnabled]);
// Start a new chat session in the side panel
const handleNewChat = useCallback(() => {
setCurrentSession(null);
setTabReadingEnabled(false);
setCurrentTabUrl(null);
resetInputBar();
// Notify the service worker so it stops sending tab URL updates
window.parent.postMessage(
{ type: CHROME_MESSAGE.TAB_READING_DISABLED },
getPanelOrigin()
);
}, [setCurrentSession, resetInputBar]);
const handleToggleTabReading = useCallback(() => {
const next = !tabReadingEnabled;
setTabReadingEnabled(next);
if (!next) {
setCurrentTabUrl(null);
}
window.parent.postMessage(
{
type: next
? CHROME_MESSAGE.TAB_READING_ENABLED
: CHROME_MESSAGE.TAB_READING_DISABLED,
},
getPanelOrigin()
);
}, [tabReadingEnabled]);
const handleOpenInOnyx = () => {
window.open(`${window.location.origin}/app`, "_blank");
};
// Handle search result document click
const handleSearchDocumentClick = useCallback(
@@ -409,10 +362,18 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
{/* Side panel header */}
{isSidePanel && (
<SidePanelHeader
onNewChat={handleNewChat}
chatSessionId={currentSessionId}
/>
<header className="flex items-center justify-between px-4 py-3 border-b border-border-01 bg-background">
<div className="flex items-center gap-2">
<Logo />
</div>
<Button
tertiary
rightIcon={SvgExternalLink}
onClick={handleOpenInOnyx}
>
Open in Onyx
</Button>
</header>
)}
{/* Settings button */}
@@ -431,22 +392,18 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
{({ getRootProps }) => (
<div
{...getRootProps()}
className={cn(
"flex-1 min-h-0 w-full flex flex-col items-center outline-none",
isSidePanel && "px-3"
)}
className="h-full w-full flex flex-col items-center outline-none"
>
{/* Chat area with messages */}
{hasMessages && resolvedAssistant && (
<>
{/* Fake header - pushes content below absolute settings button (non-side-panel only) */}
{!isSidePanel && <Spacer rem={2} />}
{/* Fake header */}
<Spacer rem={2} />
<ChatScrollContainer
sessionId="nrf-session"
anchorSelector={anchorSelector}
autoScroll={autoScrollEnabled}
isStreaming={isStreaming}
hideScrollbar={isSidePanel}
>
<ChatUI
liveAssistant={resolvedAssistant}
@@ -475,11 +432,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
{/* AppInputBar container - in normal flex flow like AppPage */}
<div
ref={inputRef}
className={cn(
"w-full flex flex-col",
!isSidePanel &&
"max-w-[var(--app-page-main-content-width)] px-4"
)}
className="w-full max-w-[var(--app-page-main-content-width)] flex flex-col px-4"
>
<AppInputBar
ref={chatInputBarRef}
@@ -502,13 +455,8 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
disabled={
!llmManager.isLoadingProviders && !llmManager.hasAnyProvider
}
{...(isSidePanel && {
tabReadingEnabled,
currentTabUrl,
onToggleTabReading: handleToggleTabReading,
})}
/>
<Spacer rem={isSidePanel ? 1 : 0.5} />
<Spacer rem={0.5} />
</div>
{/* Search results - shown when query is classified as search */}

View File

@@ -0,0 +1,20 @@
import { unstable_noStore as noStore } from "next/cache";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import { cookies } from "next/headers";
import NRFPage from "./NRFPage";
import { NRFPreferencesProvider } from "@/components/context/NRFPreferencesContext";
import * as AppLayouts from "@/layouts/app-layouts";
export default async function Page() {
noStore();
const requestCookies = await cookies();
return (
<AppLayouts.Root>
<InstantSSRAutoRefresh />
<NRFPreferencesProvider>
<NRFPage />
</NRFPreferencesProvider>
</AppLayouts.Root>
);
}

View File

@@ -0,0 +1,18 @@
import { unstable_noStore as noStore } from "next/cache";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import NRFPage from "@/app/app/nrf/NRFPage";
import { NRFPreferencesProvider } from "@/components/context/NRFPreferencesContext";
import * as AppLayouts from "@/layouts/app-layouts";
export default async function Page() {
noStore();
return (
<AppLayouts.Root>
<InstantSSRAutoRefresh />
<NRFPreferencesProvider>
<NRFPage isSidePanel />
</NRFPreferencesProvider>
</AppLayouts.Root>
);
}

View File

@@ -126,9 +126,6 @@ export interface SendMessageParams {
temperature?: number;
// Origin of the message for telemetry tracking
origin?: MessageOrigin;
// Additional context injected into the LLM call but not stored/shown in chat.
// Used e.g. by Chrome extension "Read this tab" feature.
additionalContext?: string;
}
export async function* sendMessage({
@@ -145,7 +142,6 @@ export async function* sendMessage({
modelVersion,
temperature,
origin,
additionalContext,
}: SendMessageParams): AsyncGenerator<PacketType, void, unknown> {
// Build payload for new send-chat-message API
const payload = {
@@ -167,7 +163,6 @@ export async function* sendMessage({
: null,
// Default to "unknown" for consistency with backend; callers should set explicitly
origin: origin ?? "unknown",
additional_context: additionalContext ?? null,
};
const body = JSON.stringify(payload);

View File

@@ -1,18 +1,10 @@
"use client";
import useSWR from "swr";
import Text from "@/refresh-components/texts/Text";
import Button from "@/refresh-components/buttons/Button";
import { SvgGlobe, SvgDownloadCloud, SvgFolder, SvgFiles } from "@opal/icons";
import { SvgGlobe, SvgDownloadCloud } from "@opal/icons";
import { Section } from "@/layouts/general-layouts";
import { Artifact } from "@/app/craft/hooks/useBuildSessionStore";
import { useFilesNeedsRefresh } from "@/app/craft/hooks/useBuildSessionStore";
import {
fetchDirectoryListing,
downloadArtifactFile,
downloadDirectory,
} from "@/app/craft/services/apiServices";
import { getFileIcon } from "@/lib/utils";
interface ArtifactsTabProps {
artifacts: Artifact[];
@@ -28,50 +20,20 @@ export default function ArtifactsTab({
(a) => a.type === "nextjs_app" || a.type === "web_app"
);
// Fetch top-level items in outputs/ directory
const filesNeedsRefresh = useFilesNeedsRefresh();
const { data: outputsListing } = useSWR(
sessionId
? [
`/api/build/sessions/${sessionId}/files?path=outputs`,
filesNeedsRefresh,
]
: null,
() => (sessionId ? fetchDirectoryListing(sessionId, "outputs") : null),
{
revalidateOnFocus: false,
dedupingInterval: 2000,
}
);
// Filter out the "web" directory since it's already shown as a webapp artifact
const outputEntries = (outputsListing?.entries ?? []).filter(
(entry) => entry.name !== "web"
);
const handleWebappDownload = () => {
const handleDownload = () => {
if (!sessionId) return;
// Trigger download by creating a link and clicking it
const downloadUrl = `/api/build/sessions/${sessionId}/webapp/download`;
const link = document.createElement("a");
link.href = `/api/build/sessions/${sessionId}/webapp/download`;
link.download = "";
link.href = downloadUrl;
link.download = ""; // Let the server set the filename
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleOutputDownload = (path: string, isDirectory: boolean) => {
if (!sessionId) return;
if (isDirectory) {
downloadDirectory(sessionId, path);
} else {
downloadArtifactFile(sessionId, path);
}
};
const hasWebapps = webappArtifacts.length > 0;
const hasOutputFiles = outputEntries.length > 0;
if (!sessionId || (!hasWebapps && !hasOutputFiles)) {
if (!sessionId || webappArtifacts.length === 0) {
return (
<Section
height="full"
@@ -79,12 +41,12 @@ export default function ArtifactsTab({
justifyContent="center"
padding={2}
>
<SvgFiles size={48} className="stroke-text-02" />
<SvgGlobe size={48} className="stroke-text-02" />
<Text headingH3 text03>
No artifacts yet
No web apps yet
</Text>
<Text secondaryBody text02>
Output files and web apps will appear here
Web apps created during the build will appear here
</Text>
</Section>
);
@@ -92,63 +54,24 @@ export default function ArtifactsTab({
return (
<div className="flex flex-col h-full">
{/* Webapp Artifact List */}
<div className="flex-1 overflow-auto overlay-scrollbar">
<div className="divide-y divide-border-01">
{/* Webapp Artifacts */}
{webappArtifacts.map((artifact) => (
<div
key={artifact.id}
className="flex items-center gap-3 p-3 hover:bg-background-tint-01 transition-colors"
>
<SvgGlobe size={24} className="stroke-text-02 flex-shrink-0" />
<div className="flex-1 min-w-0 flex items-center gap-2">
<Text secondaryBody text04 className="truncate">
{artifact.name}
</Text>
<Text secondaryBody text02>
Next.js Application
</Text>
</div>
<div className="flex items-center gap-2">
<Button
tertiary
action
leftIcon={SvgDownloadCloud}
onClick={handleWebappDownload}
>
Download
</Button>
</div>
</div>
))}
{/* Output Files & Folders */}
{outputEntries.map((entry) => {
const FileIcon = entry.is_directory
? SvgFolder
: getFileIcon(entry.name);
{webappArtifacts.map((artifact) => {
return (
<div
key={entry.path}
key={artifact.id}
className="flex items-center gap-3 p-3 hover:bg-background-tint-01 transition-colors"
>
<FileIcon size={24} className="stroke-text-02 flex-shrink-0" />
<SvgGlobe size={24} className="stroke-text-02 flex-shrink-0" />
<div className="flex-1 min-w-0 flex items-center gap-2">
<Text secondaryBody text04 className="truncate">
{entry.name}
{artifact.name}
</Text>
<Text secondaryBody text02>
Next.js Application
</Text>
{entry.is_directory ? (
<Text secondaryBody text02>
Folder
</Text>
) : entry.size !== null ? (
<Text secondaryBody text02>
{formatFileSize(entry.size)}
</Text>
) : null}
</div>
<div className="flex items-center gap-2">
@@ -156,9 +79,7 @@ export default function ArtifactsTab({
tertiary
action
leftIcon={SvgDownloadCloud}
onClick={() =>
handleOutputDownload(entry.path, entry.is_directory)
}
onClick={handleDownload}
>
Download
</Button>
@@ -171,9 +92,3 @@ export default function ArtifactsTab({
</div>
);
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -75,10 +75,11 @@ export function useOnboardingModal(): OnboardingModalController {
level: existingPersona?.level,
};
// Check if user has completed initial onboarding (only role required, not name)
// Check if user has completed initial onboarding
const hasUserInfo = useMemo(() => {
return !!getBuildUserPersona()?.workArea;
}, [user]);
const existingPersona = getBuildUserPersona();
return !!(user?.personalization?.name && existingPersona?.workArea);
}, [user?.personalization?.name]);
// Check if all providers are configured (skip LLM step entirely if so)
const allProvidersConfigured = useMemo(
@@ -93,7 +94,7 @@ export function useOnboardingModal(): OnboardingModalController {
);
// Auto-open initial onboarding modal on first load
// Shows if: user info (role) missing OR (admin AND no providers configured)
// Shows if: user info is missing OR (admin AND no providers configured)
useEffect(() => {
if (hasInitialized || isLoadingLlm || !user) return;

View File

@@ -424,38 +424,6 @@ export async function fetchDirectoryListing(
return res.json();
}
/**
* Trigger a browser download for a single file from the sandbox.
*/
export function downloadArtifactFile(sessionId: string, path: string): void {
const encodedPath = path
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
const link = document.createElement("a");
link.href = `${API_BASE}/sessions/${sessionId}/artifacts/${encodedPath}`;
link.download = path.split("/").pop() || path;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* Trigger a browser download for a directory as a zip file.
*/
export function downloadDirectory(sessionId: string, path: string): void {
const encodedPath = path
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
const link = document.createElement("a");
link.href = `${API_BASE}/sessions/${sessionId}/download-directory/${encodedPath}`;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
export interface FileContentResponse {
content: string; // For text files: text content. For images: data URL (base64-encoded)
mimeType: string;

View File

@@ -1,6 +1,6 @@
import { unstable_noStore as noStore } from "next/cache";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import NRFPage from "@/app/nrf/NRFPage";
import NRFPage from "@/app/app/nrf/NRFPage";
import { NRFPreferencesProvider } from "@/components/context/NRFPreferencesContext";
import NRFChrome from "../NRFChrome";

View File

@@ -1,40 +0,0 @@
"use client";
import Logo from "@/refresh-components/Logo";
import IconButton from "@/refresh-components/buttons/IconButton";
import { SvgEditBig, SvgExternalLink } from "@opal/icons";
interface SidePanelHeaderProps {
onNewChat: () => void;
chatSessionId?: string | null;
}
export default function SidePanelHeader({
onNewChat,
chatSessionId,
}: SidePanelHeaderProps) {
const handleOpenInOnyx = () => {
const path = chatSessionId ? `/app?chatId=${chatSessionId}` : "/app";
window.open(`${window.location.origin}${path}`, "_blank");
};
return (
<header className="flex items-center justify-between px-4 py-3 border-b border-border-01 bg-background">
<Logo />
<div className="flex items-center gap-1">
<IconButton
icon={SvgEditBig}
onClick={onNewChat}
tertiary
tooltip="New chat"
/>
<IconButton
icon={SvgExternalLink}
onClick={handleOpenInOnyx}
tertiary
tooltip="Open in Onyx"
/>
</div>
</header>
);
}

View File

@@ -1,6 +1,6 @@
import { unstable_noStore as noStore } from "next/cache";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import NRFPage from "@/app/nrf/NRFPage";
import NRFPage from "@/app/app/nrf/NRFPage";
import { NRFPreferencesProvider } from "@/components/context/NRFPreferencesContext";
/**

View File

@@ -89,8 +89,6 @@ export interface OnSubmitProps {
isSeededChat?: boolean;
modelOverride?: LlmDescriptor;
regenerationRequest?: RegenerationRequest | null;
// Additional context injected into the LLM call but not stored/shown in chat.
additionalContext?: string;
}
interface RegenerationRequest {
@@ -370,7 +368,6 @@ export default function useChatController({
isSeededChat,
modelOverride,
regenerationRequest,
additionalContext,
}: OnSubmitProps) => {
const projectId = params(SEARCH_PARAM_NAMES.PROJECT_ID);
{
@@ -728,7 +725,6 @@ export default function useChatController({
: undefined,
forcedToolId: effectiveForcedToolId,
origin: messageOrigin,
additionalContext,
});
const delay = (ms: number) => {

View File

@@ -17,9 +17,6 @@ export const CHROME_MESSAGE = {
LOAD_NEW_CHAT_PAGE: "LOAD_NEW_CHAT_PAGE",
LOAD_NEW_PAGE: "LOAD_NEW_PAGE",
AUTH_REQUIRED: "AUTH_REQUIRED",
TAB_READING_ENABLED: "TAB_READING_ENABLED",
TAB_READING_DISABLED: "TAB_READING_DISABLED",
TAB_URL_UPDATED: "TAB_URL_UPDATED",
};
export const SUBMIT_MESSAGE_TYPES = {

View File

@@ -3,14 +3,6 @@ import { CHROME_MESSAGE } from "./constants";
export type ExtensionContext = "new_tab" | "side_panel" | null;
// Returns the origin of the Chrome extension panel (our parent frame).
// window.location.ancestorOrigins is Chrome-specific and only populated
// when the page is loaded inside an iframe (e.g. the Chrome extension panel).
// Falls back to "*" in regular browser contexts (no parent frame).
export function getPanelOrigin(): string {
return window.location.ancestorOrigins?.[0] ?? "*";
}
export function getExtensionContext(): {
isExtension: boolean;
context: ExtensionContext;
@@ -28,20 +20,17 @@ export function getExtensionContext(): {
return { isExtension: false, context: null };
}
export function sendSetDefaultNewTabMessage(value: boolean) {
if (typeof window !== "undefined" && window.parent !== window) {
if (typeof window !== "undefined" && window.parent) {
window.parent.postMessage(
{ type: CHROME_MESSAGE.SET_DEFAULT_NEW_TAB, value },
getPanelOrigin()
"*"
);
}
}
export const sendAuthRequiredMessage = () => {
if (typeof window !== "undefined" && window.parent !== window) {
window.parent.postMessage(
{ type: CHROME_MESSAGE.AUTH_REQUIRED },
getPanelOrigin()
);
if (typeof window !== "undefined" && window.parent) {
window.parent.postMessage({ type: CHROME_MESSAGE.AUTH_REQUIRED }, "*");
}
};
@@ -52,11 +41,8 @@ export const useSendAuthRequiredMessage = () => {
};
export const sendMessageToParent = () => {
if (typeof window !== "undefined" && window.parent !== window) {
window.parent.postMessage(
{ type: CHROME_MESSAGE.ONYX_APP_LOADED },
getPanelOrigin()
);
if (typeof window !== "undefined" && window.parent) {
window.parent.postMessage({ type: CHROME_MESSAGE.ONYX_APP_LOADED }, "*");
}
};
export const useSendMessageToParent = () => {

View File

@@ -7,26 +7,15 @@ interface LinguistLanguage {
filenames?: string[];
}
const allLanguages = Object.values(languages) as LinguistLanguage[];
// Collect extensions that linguist-languages assigns to "Markdown" so we can
// exclude them from the code-language map
const markdownExtensions = new Set(
allLanguages
.find((lang) => lang.name === "Markdown")
?.extensions?.map((ext) => ext.toLowerCase()) ?? []
);
// Build extension → language name and filename → language name maps at module load
const extensionMap = new Map<string, string>();
const filenameMap = new Map<string, string>();
for (const lang of allLanguages) {
for (const lang of Object.values(languages) as LinguistLanguage[]) {
if (lang.type !== "programming") continue;
const name = lang.name.toLowerCase();
for (const ext of lang.extensions ?? []) {
if (markdownExtensions.has(ext.toLowerCase())) continue;
// First language to claim an extension wins
if (!extensionMap.has(ext)) {
extensionMap.set(ext, name);
@@ -49,12 +38,3 @@ export function getCodeLanguage(name: string): string | null {
const ext = lower.match(/\.[^.]+$/)?.[0];
return (ext && extensionMap.get(ext)) ?? filenameMap.get(lower) ?? null;
}
/**
* Returns true if the file name has a Markdown extension (as defined by
* linguist-languages) and should be rendered as rich text rather than code.
*/
export function isMarkdownFile(name: string): boolean {
const ext = name.toLowerCase().match(/\.[^.]+$/)?.[0];
return !!ext && markdownExtensions.has(ext);
}

View File

@@ -9,7 +9,6 @@ import React, {
useState,
} from "react";
import { ScrollContainerProvider } from "@/components/chat/ScrollContainerContext";
import { cn } from "@/lib/utils";
// Size constants
const DEFAULT_ANCHOR_OFFSET_PX = 16; // 1rem
@@ -50,9 +49,6 @@ export interface ChatScrollContainerProps {
/** Session ID - resets scroll state when changed */
sessionId?: string;
/** Hide the scrollbar (scroll still works, just invisible) */
hideScrollbar?: boolean;
}
// Build a CSS mask that fades content opacity at top/bottom edges
@@ -73,7 +69,6 @@ const ChatScrollContainer = React.memo(
isStreaming = false,
onScrollButtonVisibilityChange,
sessionId,
hideScrollbar = false,
}: ChatScrollContainerProps,
ref: ForwardedRef<ChatScrollContainerHandle>
) => {
@@ -352,10 +347,7 @@ const ChatScrollContainer = React.memo(
key={sessionId}
ref={scrollContainerRef}
data-testid="chat-scroll-container"
className={cn(
"flex flex-col flex-1 min-h-0 overflow-y-auto overflow-x-hidden",
hideScrollbar ? "no-scrollbar" : "default-scrollbar"
)}
className="flex flex-col flex-1 min-h-0 overflow-y-auto overflow-x-hidden default-scrollbar"
onScroll={handleScroll}
style={{
scrollbarGutter: "stable both-edges",

View File

@@ -43,7 +43,6 @@ import {
SvgCalendar,
SvgFiles,
SvgFileText,
SvgGlobe,
SvgHourglass,
SvgPlus,
SvgPlusCircle,
@@ -129,10 +128,6 @@ export interface AppInputBarProps {
toggleDeepResearch: () => void;
disabled: boolean;
ref?: React.Ref<AppInputBarHandle>;
// Side panel tab reading
tabReadingEnabled?: boolean;
currentTabUrl?: string | null;
onToggleTabReading?: () => void;
}
const AppInputBar = React.memo(
@@ -158,9 +153,6 @@ const AppInputBar = React.memo(
setPresentingDocument,
disabled,
ref,
tabReadingEnabled,
currentTabUrl,
onToggleTabReading,
}: AppInputBarProps) => {
// Internal message state - kept local to avoid parent re-renders on every keystroke
const [message, setMessage] = useState(initialMessage);
@@ -716,40 +708,17 @@ const AppInputBar = React.memo(
disabled={disabled}
/>
)}
{onToggleTabReading ? (
{showDeepResearch && (
<Button
icon={SvgGlobe}
onClick={onToggleTabReading}
icon={SvgHourglass}
onClick={toggleDeepResearch}
variant="select"
selected={tabReadingEnabled}
foldable={!tabReadingEnabled}
selected={deepResearchEnabled}
foldable={!deepResearchEnabled}
disabled={disabled}
>
{tabReadingEnabled
? currentTabUrl
? (() => {
try {
return new URL(currentTabUrl).hostname;
} catch {
return currentTabUrl;
}
})()
: "Reading tab..."
: "Read this tab"}
Deep Research
</Button>
) : (
showDeepResearch && (
<Button
icon={SvgHourglass}
onClick={toggleDeepResearch}
variant="select"
selected={deepResearchEnabled}
foldable={!deepResearchEnabled}
disabled={disabled}
>
Deep Research
</Button>
)
)}
{selectedAssistant &&

View File

@@ -14,7 +14,6 @@ import {
CopyButton,
DownloadButton,
} from "@/sections/modals/PreviewModal/variants/shared";
import TextSeparator from "@/refresh-components/TextSeparator";
interface CsvData {
headers: string[];
@@ -37,7 +36,7 @@ export const csvVariant: PreviewVariant = {
headerDescription: (ctx) => {
if (!ctx.fileContent) return "";
const { rows } = parseCsv(ctx.fileContent);
return `CSV - ${rows.length} rows ${ctx.fileSize}`;
return `CSV - ${rows.length} rows · ${ctx.fileSize}`;
},
renderContent: (ctx) => {
@@ -47,10 +46,15 @@ export const csvVariant: PreviewVariant = {
<Section justifyContent="start" alignItems="start" padding={1}>
<Table>
<TableHeader className="sticky top-0 z-sticky">
<TableRow noHover>
<TableRow className="bg-background-tint-02">
{headers.map((h: string, i: number) => (
<TableHead key={i}>
<Text as="p" className="line-clamp-2" text04 secondaryAction>
<Text
as="p"
className="line-clamp-2 font-medium"
text03
mainUiBody
>
{h}
</Text>
</TableHead>
@@ -59,33 +63,22 @@ export const csvVariant: PreviewVariant = {
</TableHeader>
<TableBody>
{rows.map((row: string[], rIdx: number) => (
<TableRow key={rIdx} noHover>
<TableRow key={rIdx}>
{headers.map((_: string, cIdx: number) => (
<TableCell
key={cIdx}
className={cn(
cIdx === 0 && "sticky left-0",
"py-4 px-4 whitespace-normal break-words"
cIdx === 0 && "sticky left-0 bg-background-tint-01",
"py-0 px-4 whitespace-normal break-words"
)}
>
<Text
as="p"
{...(cIdx === 0
? { text04: true, secondaryAction: true }
: { text03: true, secondaryBody: true })}
>
{row?.[cIdx] ?? ""}
</Text>
{row?.[cIdx] ?? ""}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
<TextSeparator
count={rows.length}
text={rows.length === 1 ? "row" : "rows"}
/>
</Section>
);
},
@@ -95,7 +88,7 @@ export const csvVariant: PreviewVariant = {
const { headers, rows } = parseCsv(ctx.fileContent);
return (
<Text text03 mainUiBody className="select-none">
{headers.length} {headers.length === 1 ? "column" : "columns"} {" "}
{headers.length} {headers.length === 1 ? "column" : "columns"} ·{" "}
{rows.length} {rows.length === 1 ? "row" : "rows"}
</Text>
);

View File

@@ -1,7 +1,6 @@
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import ScrollIndicatorDiv from "@/refresh-components/ScrollIndicatorDiv";
import { Section } from "@/layouts/general-layouts";
import { isMarkdownFile } from "@/lib/languages";
import { PreviewVariant } from "@/sections/modals/PreviewModal/interfaces";
import {
CopyButton,
@@ -19,7 +18,14 @@ const MARKDOWN_MIMES = [
export const markdownVariant: PreviewVariant = {
matches: (name, mime) => {
if (MARKDOWN_MIMES.some((m) => mime.startsWith(m))) return true;
return isMarkdownFile(name || "");
const lower = (name || "").toLowerCase();
return (
lower.endsWith(".md") ||
lower.endsWith(".markdown") ||
lower.endsWith(".txt") ||
lower.endsWith(".rst") ||
lower.endsWith(".org")
);
},
width: "lg",
height: "full",