Compare commits

..

2 Commits

Author SHA1 Message Date
Jamison Lahman
396f7a78f9 fix(mobile): sidebar overlaps content on medium-sized screens 2026-04-02 14:11:16 -07:00
Jamison Lahman
e07b3ca88e fix(mobile): update sidebar responsiveness 2026-04-02 12:33:34 -07:00
26 changed files with 1135 additions and 1618 deletions

View File

@@ -1,14 +1,20 @@
from datetime import datetime
from datetime import timezone
from uuid import UUID
from celery import shared_task
from celery import Task
from ee.onyx.background.celery_utils import should_perform_chat_ttl_check
from ee.onyx.background.task_name_builders import name_chat_ttl_task
from onyx.configs.app_configs import JOB_TIMEOUT
from onyx.configs.constants import OnyxCeleryTask
from onyx.db.chat import delete_chat_session
from onyx.db.chat import get_chat_sessions_older_than
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.enums import TaskStatus
from onyx.db.tasks import mark_task_as_finished_with_id
from onyx.db.tasks import register_task
from onyx.server.settings.store import load_settings
from onyx.utils.logger import setup_logger
@@ -23,16 +29,26 @@ logger = setup_logger()
trail=False,
)
def perform_ttl_management_task(
self: Task, retention_limit_days: int, *, tenant_id: str # noqa: ARG001
self: Task, retention_limit_days: int, *, tenant_id: str
) -> None:
task_id = self.request.id
if not task_id:
raise RuntimeError("No task id defined for this task; cannot identify it")
start_time = datetime.now(tz=timezone.utc)
user_id: UUID | None = None
session_id: UUID | None = None
try:
with get_session_with_current_tenant() as db_session:
# we generally want to move off this, but keeping for now
register_task(
db_session=db_session,
task_name=name_chat_ttl_task(retention_limit_days, tenant_id),
task_id=task_id,
status=TaskStatus.STARTED,
start_time=start_time,
)
old_chat_sessions = get_chat_sessions_older_than(
retention_limit_days, db_session
@@ -49,10 +65,23 @@ def perform_ttl_management_task(
hard_delete=True,
)
with get_session_with_current_tenant() as db_session:
mark_task_as_finished_with_id(
db_session=db_session,
task_id=task_id,
success=True,
)
except Exception:
logger.exception(
f"delete_chat_session exceptioned. user_id={user_id} session_id={session_id}"
)
with get_session_with_current_tenant() as db_session:
mark_task_as_finished_with_id(
db_session=db_session,
task_id=task_id,
success=False,
)
raise

View File

@@ -36,7 +36,6 @@ from onyx.configs.constants import OnyxRedisLocks
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.opensearch_migration import build_sanitized_to_original_doc_id_mapping
from onyx.db.opensearch_migration import get_vespa_visit_state
from onyx.db.opensearch_migration import is_migration_completed
from onyx.db.opensearch_migration import (
mark_migration_completed_time_if_not_set_with_commit,
)
@@ -107,19 +106,14 @@ def migrate_chunks_from_vespa_to_opensearch_task(
acquired; effectively a no-op. True if the task completed
successfully. False if the task errored.
"""
# 1. Check if we should run the task.
# 1.a. If OpenSearch indexing is disabled, we don't run the task.
if not ENABLE_OPENSEARCH_INDEXING_FOR_ONYX:
task_logger.warning(
"OpenSearch migration is not enabled, skipping chunk migration task."
)
return None
task_logger.info("Starting chunk-level migration from Vespa to OpenSearch.")
task_start_time = time.monotonic()
# 1.b. Only one instance per tenant of this task may run concurrently at
# once. If we fail to acquire a lock, we assume it is because another task
# has one and we exit.
r = get_redis_client()
lock: RedisLock = r.lock(
name=OnyxRedisLocks.OPENSEARCH_MIGRATION_BEAT_LOCK,
@@ -142,11 +136,10 @@ def migrate_chunks_from_vespa_to_opensearch_task(
f"Token: {lock.local.token}"
)
# 2. Prepare to migrate.
total_chunks_migrated_this_task = 0
total_chunks_errored_this_task = 0
try:
# 2.a. Double-check that tenant info is correct.
# Double check that tenant info is correct.
if tenant_id != get_current_tenant_id():
err_str = (
f"Tenant ID mismatch in the OpenSearch migration task: "
@@ -155,62 +148,16 @@ def migrate_chunks_from_vespa_to_opensearch_task(
task_logger.error(err_str)
return False
# Do as much as we can with a DB session in one spot to not hold a
# session during a migration batch.
with get_session_with_current_tenant() as db_session:
# 2.b. Immediately check to see if this tenant is done, to save
# having to do any other work. This function does not require a
# migration record to necessarily exist.
if is_migration_completed(db_session):
return True
# 2.c. Try to insert the OpenSearchTenantMigrationRecord table if it
# does not exist.
with (
get_session_with_current_tenant() as db_session,
get_vespa_http_client(
timeout=VESPA_MIGRATION_REQUEST_TIMEOUT_S
) as vespa_client,
):
try_insert_opensearch_tenant_migration_record_with_commit(db_session)
# 2.d. Get search settings.
search_settings = get_current_search_settings(db_session)
indexing_setting = IndexingSetting.from_db_model(search_settings)
# 2.e. Build sanitized to original doc ID mapping to check for
# conflicts in the event we sanitize a doc ID to an
# already-existing doc ID.
# We reconstruct this mapping for every task invocation because
# a document may have been added in the time between two tasks.
sanitized_doc_start_time = time.monotonic()
sanitized_to_original_doc_id_mapping = (
build_sanitized_to_original_doc_id_mapping(db_session)
)
task_logger.debug(
f"Built sanitized_to_original_doc_id_mapping with {len(sanitized_to_original_doc_id_mapping)} entries "
f"in {time.monotonic() - sanitized_doc_start_time:.3f} seconds."
)
# 2.f. Get the current migration state.
continuation_token_map, total_chunks_migrated = get_vespa_visit_state(
db_session
)
# 2.f.1. Double-check that the migration state does not imply
# completion. Really we should never have to enter this block as we
# would expect is_migration_completed to return True, but in the
# strange event that the migration is complete but the migration
# completed time was never stamped, we do so here.
if is_continuation_token_done_for_all_slices(continuation_token_map):
task_logger.info(
f"OpenSearch migration COMPLETED for tenant {tenant_id}. Total chunks migrated: {total_chunks_migrated}."
)
mark_migration_completed_time_if_not_set_with_commit(db_session)
return True
task_logger.debug(
f"Read the tenant migration record. Total chunks migrated: {total_chunks_migrated}. "
f"Continuation token map: {continuation_token_map}"
)
with get_vespa_http_client(
timeout=VESPA_MIGRATION_REQUEST_TIMEOUT_S
) as vespa_client:
# 2.g. Create the OpenSearch and Vespa document indexes.
tenant_state = TenantState(tenant_id=tenant_id, multitenant=MULTI_TENANT)
indexing_setting = IndexingSetting.from_db_model(search_settings)
opensearch_document_index = OpenSearchDocumentIndex(
tenant_state=tenant_state,
index_name=search_settings.index_name,
@@ -224,14 +171,22 @@ def migrate_chunks_from_vespa_to_opensearch_task(
httpx_client=vespa_client,
)
# 2.h. Get the approximate chunk count in Vespa as of this time to
# update the migration record.
sanitized_doc_start_time = time.monotonic()
# We reconstruct this mapping for every task invocation because a
# document may have been added in the time between two tasks.
sanitized_to_original_doc_id_mapping = (
build_sanitized_to_original_doc_id_mapping(db_session)
)
task_logger.debug(
f"Built sanitized_to_original_doc_id_mapping with {len(sanitized_to_original_doc_id_mapping)} entries "
f"in {time.monotonic() - sanitized_doc_start_time:.3f} seconds."
)
approx_chunk_count_in_vespa: int | None = None
get_chunk_count_start_time = time.monotonic()
try:
approx_chunk_count_in_vespa = vespa_document_index.get_chunk_count()
except Exception:
# This failure should not be blocking.
task_logger.exception(
"Error getting approximate chunk count in Vespa. Moving on..."
)
@@ -240,12 +195,25 @@ def migrate_chunks_from_vespa_to_opensearch_task(
f"approximate chunk count in Vespa. Got {approx_chunk_count_in_vespa}."
)
# 3. Do the actual migration in batches until we run out of time.
while (
time.monotonic() - task_start_time < MIGRATION_TASK_SOFT_TIME_LIMIT_S
and lock.owned()
):
# 3.a. Get the next batch of raw chunks from Vespa.
(
continuation_token_map,
total_chunks_migrated,
) = get_vespa_visit_state(db_session)
if is_continuation_token_done_for_all_slices(continuation_token_map):
task_logger.info(
f"OpenSearch migration COMPLETED for tenant {tenant_id}. Total chunks migrated: {total_chunks_migrated}."
)
mark_migration_completed_time_if_not_set_with_commit(db_session)
break
task_logger.debug(
f"Read the tenant migration record. Total chunks migrated: {total_chunks_migrated}. "
f"Continuation token map: {continuation_token_map}"
)
get_vespa_chunks_start_time = time.monotonic()
raw_vespa_chunks, next_continuation_token_map = (
vespa_document_index.get_all_raw_document_chunks_paginated(
@@ -258,7 +226,6 @@ def migrate_chunks_from_vespa_to_opensearch_task(
f"seconds. Next continuation token map: {next_continuation_token_map}"
)
# 3.b. Transform the raw chunks to OpenSearch chunks in memory.
opensearch_document_chunks, errored_chunks = (
transform_vespa_chunks_to_opensearch_chunks(
raw_vespa_chunks,
@@ -273,7 +240,6 @@ def migrate_chunks_from_vespa_to_opensearch_task(
"errored."
)
# 3.c. Index the OpenSearch chunks into OpenSearch.
index_opensearch_chunks_start_time = time.monotonic()
opensearch_document_index.index_raw_chunks(
chunks=opensearch_document_chunks
@@ -285,38 +251,12 @@ def migrate_chunks_from_vespa_to_opensearch_task(
total_chunks_migrated_this_task += len(opensearch_document_chunks)
total_chunks_errored_this_task += len(errored_chunks)
# Do as much as we can with a DB session in one spot to not hold a
# session during a migration batch.
with get_session_with_current_tenant() as db_session:
# 3.d. Update the migration state.
update_vespa_visit_progress_with_commit(
db_session,
continuation_token_map=next_continuation_token_map,
chunks_processed=len(opensearch_document_chunks),
chunks_errored=len(errored_chunks),
approx_chunk_count_in_vespa=approx_chunk_count_in_vespa,
)
# 3.e. Get the current migration state. Even thought we
# technically have it in-memory since we just wrote it, we
# want to reference the DB as the source of truth at all
# times.
continuation_token_map, total_chunks_migrated = (
get_vespa_visit_state(db_session)
)
# 3.e.1. Check if the migration is done.
if is_continuation_token_done_for_all_slices(
continuation_token_map
):
task_logger.info(
f"OpenSearch migration COMPLETED for tenant {tenant_id}. Total chunks migrated: {total_chunks_migrated}."
)
mark_migration_completed_time_if_not_set_with_commit(db_session)
return True
task_logger.debug(
f"Read the tenant migration record. Total chunks migrated: {total_chunks_migrated}. "
f"Continuation token map: {continuation_token_map}"
update_vespa_visit_progress_with_commit(
db_session,
continuation_token_map=next_continuation_token_map,
chunks_processed=len(opensearch_document_chunks),
chunks_errored=len(errored_chunks),
approx_chunk_count_in_vespa=approx_chunk_count_in_vespa,
)
except Exception:
traceback.print_exc()

View File

@@ -324,15 +324,6 @@ def mark_migration_completed_time_if_not_set_with_commit(
db_session.commit()
def is_migration_completed(db_session: Session) -> bool:
"""Returns True if the migration is completed.
Can be run even if the migration record does not exist.
"""
record = db_session.query(OpenSearchTenantMigrationRecord).first()
return record is not None and record.migration_completed_at is not None
def build_sanitized_to_original_doc_id_mapping(
db_session: Session,
) -> dict[str, str]:

View File

@@ -1,4 +1,3 @@
import hashlib
from datetime import datetime
from datetime import timezone
from typing import Any
@@ -21,13 +20,9 @@ from onyx.document_index.opensearch.constants import DEFAULT_MAX_CHUNK_SIZE
from onyx.document_index.opensearch.constants import EF_CONSTRUCTION
from onyx.document_index.opensearch.constants import EF_SEARCH
from onyx.document_index.opensearch.constants import M
from onyx.document_index.opensearch.string_filtering import DocumentIDTooLongError
from onyx.document_index.opensearch.string_filtering import (
filter_and_validate_document_id,
)
from onyx.document_index.opensearch.string_filtering import (
MAX_DOCUMENT_ID_ENCODED_LENGTH,
)
from onyx.utils.tenant import get_tenant_id_short_string
from shared_configs.configs import MULTI_TENANT
from shared_configs.contextvars import get_current_tenant_id
@@ -80,50 +75,17 @@ def get_opensearch_doc_chunk_id(
This will be the string used to identify the chunk in OpenSearch. Any direct
chunk queries should use this function.
If the document ID is too long, a hash of the ID is used instead.
"""
opensearch_doc_chunk_id_suffix: str = f"__{max_chunk_size}__{chunk_index}"
encoded_suffix_length: int = len(opensearch_doc_chunk_id_suffix.encode("utf-8"))
max_encoded_permissible_doc_id_length: int = (
MAX_DOCUMENT_ID_ENCODED_LENGTH - encoded_suffix_length
sanitized_document_id = filter_and_validate_document_id(document_id)
opensearch_doc_chunk_id = (
f"{sanitized_document_id}__{max_chunk_size}__{chunk_index}"
)
opensearch_doc_chunk_id_tenant_prefix: str = ""
if tenant_state.multitenant:
short_tenant_id: str = get_tenant_id_short_string(tenant_state.tenant_id)
# Use tenant ID because in multitenant mode each tenant has its own
# Documents table, so there is a very small chance that doc IDs are not
# actually unique across all tenants.
opensearch_doc_chunk_id_tenant_prefix = f"{short_tenant_id}__"
encoded_prefix_length: int = len(
opensearch_doc_chunk_id_tenant_prefix.encode("utf-8")
)
max_encoded_permissible_doc_id_length -= encoded_prefix_length
try:
sanitized_document_id: str = filter_and_validate_document_id(
document_id, max_encoded_length=max_encoded_permissible_doc_id_length
)
except DocumentIDTooLongError:
# If the document ID is too long, use a hash instead.
# We use blake2b because it is faster and equally secure as SHA256, and
# accepts digest_size which controls the number of bytes returned in the
# hash.
# digest_size is the size of the returned hash in bytes. Since we're
# decoding the hash bytes as a hex string, the digest_size should be
# half the max target size of the hash string.
# Subtract 1 because filter_and_validate_document_id compares on >= on
# max_encoded_length.
# 64 is the max digest_size blake2b returns.
digest_size: int = min((max_encoded_permissible_doc_id_length - 1) // 2, 64)
sanitized_document_id = hashlib.blake2b(
document_id.encode("utf-8"), digest_size=digest_size
).hexdigest()
opensearch_doc_chunk_id: str = (
f"{opensearch_doc_chunk_id_tenant_prefix}{sanitized_document_id}{opensearch_doc_chunk_id_suffix}"
)
short_tenant_id = get_tenant_id_short_string(tenant_state.tenant_id)
opensearch_doc_chunk_id = f"{short_tenant_id}__{opensearch_doc_chunk_id}"
# Do one more validation to ensure we haven't exceeded the max length.
opensearch_doc_chunk_id = filter_and_validate_document_id(opensearch_doc_chunk_id)
return opensearch_doc_chunk_id

View File

@@ -1,15 +1,7 @@
import re
MAX_DOCUMENT_ID_ENCODED_LENGTH: int = 512
class DocumentIDTooLongError(ValueError):
"""Raised when a document ID is too long for OpenSearch after filtering."""
def filter_and_validate_document_id(
document_id: str, max_encoded_length: int = MAX_DOCUMENT_ID_ENCODED_LENGTH
) -> str:
def filter_and_validate_document_id(document_id: str) -> str:
"""
Filters and validates a document ID such that it can be used as an ID in
OpenSearch.
@@ -27,13 +19,9 @@ def filter_and_validate_document_id(
Args:
document_id: The document ID to filter and validate.
max_encoded_length: The maximum length of the document ID after
filtering in bytes. Compared with >= for extra resilience, so
encoded values of this length will fail.
Raises:
DocumentIDTooLongError: If the document ID is too long after filtering.
ValueError: If the document ID is empty after filtering.
ValueError: If the document ID is empty or too long after filtering.
Returns:
str: The filtered document ID.
@@ -41,8 +29,6 @@ def filter_and_validate_document_id(
filtered_document_id = re.sub(r"[^A-Za-z0-9_.\-~]", "", document_id)
if not filtered_document_id:
raise ValueError(f"Document ID {document_id} is empty after filtering.")
if len(filtered_document_id.encode("utf-8")) >= max_encoded_length:
raise DocumentIDTooLongError(
f"Document ID {document_id} is too long after filtering."
)
if len(filtered_document_id.encode("utf-8")) >= 512:
raise ValueError(f"Document ID {document_id} is too long after filtering.")
return filtered_document_id

View File

@@ -1,203 +0,0 @@
import pytest
from onyx.document_index.interfaces_new import TenantState
from onyx.document_index.opensearch.constants import DEFAULT_MAX_CHUNK_SIZE
from onyx.document_index.opensearch.schema import get_opensearch_doc_chunk_id
from onyx.document_index.opensearch.string_filtering import (
MAX_DOCUMENT_ID_ENCODED_LENGTH,
)
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA_STANDARD_VALUE
SINGLE_TENANT_STATE = TenantState(
tenant_id=POSTGRES_DEFAULT_SCHEMA_STANDARD_VALUE, multitenant=False
)
MULTI_TENANT_STATE = TenantState(
tenant_id="tenant_abcdef12-3456-7890-abcd-ef1234567890", multitenant=True
)
EXPECTED_SHORT_TENANT = "abcdef12"
class TestGetOpensearchDocChunkIdSingleTenant:
def test_basic(self) -> None:
result = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, "my-doc-id", chunk_index=0
)
assert result == f"my-doc-id__{DEFAULT_MAX_CHUNK_SIZE}__0"
def test_custom_chunk_size(self) -> None:
result = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, "doc1", chunk_index=3, max_chunk_size=1024
)
assert result == "doc1__1024__3"
def test_special_chars_are_stripped(self) -> None:
"""Tests characters not matching [A-Za-z0-9_.-~] are removed."""
result = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, "doc/with?special#chars&more%stuff", chunk_index=0
)
assert "/" not in result
assert "?" not in result
assert "#" not in result
assert result == f"docwithspecialcharsmorestuff__{DEFAULT_MAX_CHUNK_SIZE}__0"
def test_short_doc_id_not_hashed(self) -> None:
"""
Tests that a short doc ID should appear directly in the result, not as a
hash.
"""
doc_id = "short-id"
result = get_opensearch_doc_chunk_id(SINGLE_TENANT_STATE, doc_id, chunk_index=0)
assert "short-id" in result
def test_long_doc_id_is_hashed(self) -> None:
"""
Tests that a doc ID exceeding the max length should be replaced with a
blake2b hash.
"""
# Create a doc ID that will exceed max length after the suffix is
# appended.
doc_id = "a" * MAX_DOCUMENT_ID_ENCODED_LENGTH
result = get_opensearch_doc_chunk_id(SINGLE_TENANT_STATE, doc_id, chunk_index=0)
# The original doc ID should NOT appear in the result.
assert doc_id not in result
# The suffix should still be present.
assert f"__{DEFAULT_MAX_CHUNK_SIZE}__0" in result
def test_long_doc_id_hash_is_deterministic(self) -> None:
doc_id = "x" * MAX_DOCUMENT_ID_ENCODED_LENGTH
result1 = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, doc_id, chunk_index=5
)
result2 = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, doc_id, chunk_index=5
)
assert result1 == result2
def test_long_doc_id_different_inputs_produce_different_hashes(self) -> None:
doc_id_a = "a" * MAX_DOCUMENT_ID_ENCODED_LENGTH
doc_id_b = "b" * MAX_DOCUMENT_ID_ENCODED_LENGTH
result_a = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, doc_id_a, chunk_index=0
)
result_b = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, doc_id_b, chunk_index=0
)
assert result_a != result_b
def test_result_never_exceeds_max_length(self) -> None:
"""
Tests that the final result should always be under
MAX_DOCUMENT_ID_ENCODED_LENGTH bytes.
"""
doc_id = "z" * (MAX_DOCUMENT_ID_ENCODED_LENGTH * 2)
result = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, doc_id, chunk_index=999, max_chunk_size=99999
)
assert len(result.encode("utf-8")) < MAX_DOCUMENT_ID_ENCODED_LENGTH
def test_no_tenant_prefix_in_single_tenant(self) -> None:
result = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, "mydoc", chunk_index=0
)
assert not result.startswith(SINGLE_TENANT_STATE.tenant_id)
class TestGetOpensearchDocChunkIdMultiTenant:
def test_includes_tenant_prefix(self) -> None:
result = get_opensearch_doc_chunk_id(MULTI_TENANT_STATE, "mydoc", chunk_index=0)
assert result.startswith(f"{EXPECTED_SHORT_TENANT}__")
def test_format(self) -> None:
result = get_opensearch_doc_chunk_id(
MULTI_TENANT_STATE, "mydoc", chunk_index=2, max_chunk_size=256
)
assert result == f"{EXPECTED_SHORT_TENANT}__mydoc__256__2"
def test_long_doc_id_is_hashed_multitenant(self) -> None:
doc_id = "d" * MAX_DOCUMENT_ID_ENCODED_LENGTH
result = get_opensearch_doc_chunk_id(MULTI_TENANT_STATE, doc_id, chunk_index=0)
# Should still have tenant prefix.
assert result.startswith(f"{EXPECTED_SHORT_TENANT}__")
# The original doc ID should NOT appear in the result.
assert doc_id not in result
# The suffix should still be present.
assert f"__{DEFAULT_MAX_CHUNK_SIZE}__0" in result
def test_result_never_exceeds_max_length_multitenant(self) -> None:
doc_id = "q" * (MAX_DOCUMENT_ID_ENCODED_LENGTH * 2)
result = get_opensearch_doc_chunk_id(
MULTI_TENANT_STATE, doc_id, chunk_index=999, max_chunk_size=99999
)
assert len(result.encode("utf-8")) < MAX_DOCUMENT_ID_ENCODED_LENGTH
def test_different_tenants_produce_different_ids(self) -> None:
tenant_a = TenantState(
tenant_id="tenant_aaaaaaaa-0000-0000-0000-000000000000", multitenant=True
)
tenant_b = TenantState(
tenant_id="tenant_bbbbbbbb-0000-0000-0000-000000000000", multitenant=True
)
result_a = get_opensearch_doc_chunk_id(tenant_a, "same-doc", chunk_index=0)
result_b = get_opensearch_doc_chunk_id(tenant_b, "same-doc", chunk_index=0)
assert result_a != result_b
class TestGetOpensearchDocChunkIdEdgeCases:
def test_chunk_index_zero(self) -> None:
result = get_opensearch_doc_chunk_id(SINGLE_TENANT_STATE, "doc", chunk_index=0)
assert result.endswith("__0")
def test_large_chunk_index(self) -> None:
result = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, "doc", chunk_index=99999
)
assert result.endswith("__99999")
def test_doc_id_with_only_special_chars_raises(self) -> None:
"""
Tests that a doc ID that becomes empty after filtering should raise
ValueError.
"""
with pytest.raises(ValueError, match="empty after filtering"):
get_opensearch_doc_chunk_id(SINGLE_TENANT_STATE, "###???///", chunk_index=0)
def test_doc_id_at_boundary_length(self) -> None:
"""
Tests that a doc ID right at the boundary should not be hashed.
"""
suffix = f"__{DEFAULT_MAX_CHUNK_SIZE}__0"
suffix_len = len(suffix.encode("utf-8"))
# Max doc ID length that won't trigger hashing (must be <
# max_encoded_length).
max_doc_len = MAX_DOCUMENT_ID_ENCODED_LENGTH - suffix_len - 1
doc_id = "a" * max_doc_len
result = get_opensearch_doc_chunk_id(SINGLE_TENANT_STATE, doc_id, chunk_index=0)
assert doc_id in result
def test_doc_id_at_boundary_length_multitenant(self) -> None:
"""
Tests that a doc ID right at the boundary should not be hashed in
multitenant mode.
"""
suffix = f"__{DEFAULT_MAX_CHUNK_SIZE}__0"
suffix_len = len(suffix.encode("utf-8"))
prefix = f"{EXPECTED_SHORT_TENANT}__"
prefix_len = len(prefix.encode("utf-8"))
# Max doc ID length that won't trigger hashing (must be <
# max_encoded_length).
max_doc_len = MAX_DOCUMENT_ID_ENCODED_LENGTH - suffix_len - prefix_len - 1
doc_id = "a" * max_doc_len
result = get_opensearch_doc_chunk_id(MULTI_TENANT_STATE, doc_id, chunk_index=0)
assert doc_id in result
def test_doc_id_one_over_boundary_is_hashed(self) -> None:
"""
Tests that a doc ID one byte over the boundary should be hashed.
"""
suffix = f"__{DEFAULT_MAX_CHUNK_SIZE}__0"
suffix_len = len(suffix.encode("utf-8"))
# This length will trigger the >= check in filter_and_validate_document_id
doc_id = "a" * (MAX_DOCUMENT_ID_ENCODED_LENGTH - suffix_len)
result = get_opensearch_doc_chunk_id(SINGLE_TENANT_STATE, doc_id, chunk_index=0)
assert doc_id not in result

View File

@@ -4,7 +4,6 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/onboarding"
"github.com/onyx-dot-app/onyx/cli/internal/starprompt"
"github.com/onyx-dot-app/onyx/cli/internal/tui"
"github.com/spf13/cobra"
)
@@ -25,8 +24,6 @@ func newChatCmd() *cobra.Command {
cfg = *result
}
starprompt.MaybePrompt()
m := tui.NewModel(cfg)
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
_, err := p.Run()

View File

@@ -1,83 +0,0 @@
// Package starprompt implements a one-time GitHub star prompt shown before the TUI.
// Skipped when stdin/stdout is not a TTY, when gh CLI is not installed,
// or when the user has already been prompted. State is stored in the
// config directory so it shows at most once per user.
package starprompt
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"golang.org/x/term"
)
const repo = "onyx-dot-app/onyx"
func statePath() string {
return filepath.Join(config.ConfigDir(), ".star-prompted")
}
func hasBeenPrompted() bool {
_, err := os.Stat(statePath())
return err == nil
}
func markPrompted() {
_ = os.MkdirAll(config.ConfigDir(), 0o755)
f, err := os.Create(statePath())
if err == nil {
_ = f.Close()
}
}
func isGHInstalled() bool {
_, err := exec.LookPath("gh")
return err == nil
}
// MaybePrompt shows a one-time star prompt if conditions are met.
// It is safe to call unconditionally — it no-ops when not appropriate.
func MaybePrompt() {
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
return
}
if hasBeenPrompted() {
return
}
if !isGHInstalled() {
return
}
// Mark before asking so Ctrl+C won't cause a re-prompt.
markPrompted()
fmt.Print("Enjoying Onyx? Star the repo on GitHub? [Y/n] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer == "n" || answer == "no" {
return
}
cmd := exec.Command("gh", "api", "-X", "PUT", "/user/starred/"+repo)
cmd.Env = append(os.Environ(), "GH_PAGER=")
if devnull, err := os.Open(os.DevNull); err == nil {
defer func() { _ = devnull.Close() }()
cmd.Stdin = devnull
cmd.Stdout = devnull
cmd.Stderr = devnull
}
if err := cmd.Run(); err != nil {
fmt.Println("Star us at: https://github.com/" + repo)
} else {
fmt.Println("Thanks for the star!")
time.Sleep(500 * time.Millisecond)
}
}

View File

@@ -1302,18 +1302,4 @@ echo ""
print_info "Refer to the README in the ${INSTALL_ROOT} directory for more information."
echo ""
print_info "For help or issues, contact: founders@onyx.app"
echo ""
# --- GitHub star prompt (inspired by oh-my-codex) ---
# Only prompt in interactive mode and only if gh CLI is available.
# Uses the GitHub API directly (PUT /user/starred) like oh-my-codex.
if is_interactive && command -v gh &>/dev/null; then
prompt_yn_or_default "Enjoying Onyx? Star the repo on GitHub? [Y/n] " "Y"
if [[ ! "$REPLY" =~ ^[Nn] ]]; then
if GH_PAGER= gh api -X PUT /user/starred/onyx-dot-app/onyx < /dev/null >/dev/null 2>&1; then
print_success "Thanks for the star!"
else
print_info "Star us at: https://github.com/onyx-dot-app/onyx"
fi
fi
fi
echo ""

View File

@@ -586,10 +586,7 @@ export function Table<TData>(props: DataTableProps<TData>) {
// Data / Display cell
return (
<TableCell
key={cell.id}
data-column-id={cell.column.id}
>
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()

View File

@@ -127,13 +127,13 @@ function Main() {
/>
)}
</div>
<div className="flex flex-col gap-2 desktop:flex-row desktop:items-center desktop:gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2">
{isApiKeySet ? (
<>
<Button variant="danger" onClick={handleDelete}>
Delete API Key
</Button>
<Text as="p" mainContentBody text04 className="desktop:mt-0">
<Text as="p" mainContentBody text04 className="sm:mt-0">
Delete the current API key before updating.
</Text>
</>

View File

@@ -238,7 +238,9 @@ function BuildSessionButton({
<Text
as="p"
data-state={isActive ? "active" : "inactive"}
className="line-clamp-1 break-all text-left"
className={cn(
"sidebar-tab-text-defaulted line-clamp-1 break-all text-left"
)}
mainUiBody
>
<TypewriterText

View File

@@ -1,399 +0,0 @@
/* ============================================================================
Main Variant - Primary
============================================================================ */
.button-main-primary {
background-color: var(--theme-primary-05);
}
.button-main-primary:hover {
background-color: var(--theme-primary-04);
}
.button-main-primary[data-state="transient"] {
background-color: var(--theme-primary-06);
}
.button-main-primary:active {
background-color: var(--theme-primary-06);
}
.button-main-primary:disabled {
background-color: var(--background-neutral-04);
}
.button-main-primary-text {
color: var(--text-inverted-05) !important;
}
.button-main-primary:disabled .button-main-primary-text {
color: var(--text-inverted-04) !important;
}
.button-main-primary-icon {
stroke: var(--text-inverted-05);
}
.button-main-primary:disabled .button-main-primary-icon {
stroke: var(--text-inverted-04);
}
/* ============================================================================
Main Variant - Secondary
============================================================================ */
.button-main-secondary {
background-color: var(--background-tint-01);
border: 1px solid var(--border-01);
}
.button-main-secondary:hover {
background-color: var(--background-tint-02);
}
.button-main-secondary[data-state="transient"] {
background-color: var(--background-tint-00);
}
.button-main-secondary:active {
background-color: var(--background-tint-00);
}
.button-main-secondary:disabled {
background-color: var(--background-neutral-03);
border: 1px solid var(--border-01);
}
.button-main-secondary-text {
color: var(--text-03) !important;
}
.button-main-secondary:hover .button-main-secondary-text {
color: var(--text-04) !important;
}
.button-main-secondary[data-state="transient"] .button-main-secondary-text {
color: var(--text-05) !important;
}
.button-main-secondary:active .button-main-secondary-text {
color: var(--text-05) !important;
}
.button-main-secondary:disabled .button-main-secondary-text {
color: var(--text-01) !important;
}
.button-main-secondary-icon {
stroke: var(--text-03);
}
.button-main-secondary:hover .button-main-secondary-icon {
stroke: var(--text-04);
}
.button-main-secondary[data-state="transient"] .button-main-secondary-icon {
stroke: var(--text-05);
}
.button-main-secondary:active .button-main-secondary-icon {
stroke: var(--text-05);
}
.button-main-secondary:disabled .button-main-secondary-icon {
stroke: var(--text-01);
}
/* ============================================================================
Main Variant - Tertiary
============================================================================ */
.button-main-tertiary {
background-color: transparent;
}
.button-main-tertiary:hover {
background-color: var(--background-tint-02);
}
.button-main-tertiary[data-state="transient"] {
background-color: var(--background-tint-00);
}
.button-main-tertiary:active {
background-color: var(--background-tint-00);
}
.button-main-tertiary:disabled {
background-color: transparent;
}
.button-main-tertiary-text {
color: var(--text-03) !important;
}
.button-main-tertiary:hover .button-main-tertiary-text {
color: var(--text-04) !important;
}
.button-main-tertiary[data-state="transient"] .button-main-tertiary-text {
color: var(--text-05) !important;
}
.button-main-tertiary:active .button-main-tertiary-text {
color: var(--text-05) !important;
}
.button-main-tertiary:disabled .button-main-tertiary-text {
color: var(--text-01) !important;
}
.button-main-tertiary-icon {
stroke: var(--text-03);
}
.button-main-tertiary:hover .button-main-tertiary-icon {
stroke: var(--text-04);
}
.button-main-tertiary[data-state="transient"] .button-main-tertiary-icon {
stroke: var(--text-05);
}
.button-main-tertiary:active .button-main-tertiary-icon {
stroke: var(--text-05);
}
.button-main-tertiary:disabled .button-main-tertiary-icon {
stroke: var(--text-01);
}
/* ============================================================================
Main Variant - Internal
============================================================================ */
.button-main-internal {
background-color: transparent;
}
.button-main-internal:hover {
background-color: var(--background-tint-02);
}
.button-main-internal[data-state="transient"] {
background-color: var(--background-tint-00);
}
.button-main-internal:active {
background-color: var(--background-tint-00);
}
.button-main-internal:disabled {
background-color: transparent;
}
.button-main-internal-text {
color: var(--text-03) !important;
}
.button-main-internal:hover .button-main-internal-text {
color: var(--text-04) !important;
}
.button-main-internal[data-state="transient"] .button-main-internal-text {
color: var(--text-05) !important;
}
.button-main-internal:active .button-main-internal-text {
color: var(--text-05) !important;
}
.button-main-internal:disabled .button-main-internal-text {
color: var(--text-01) !important;
}
.button-main-internal-icon {
stroke: var(--text-03);
}
.button-main-internal:hover .button-main-internal-icon {
stroke: var(--text-04);
}
.button-main-internal[data-state="transient"] .button-main-internal-icon {
stroke: var(--text-05);
}
.button-main-internal:active .button-main-internal-icon {
stroke: var(--text-05);
}
.button-main-internal:disabled .button-main-internal-icon {
stroke: var(--text-01);
}
/* ============================================================================
Action Variant - Primary
============================================================================ */
.button-action-primary {
background-color: var(--action-link-05);
}
.button-action-primary:hover {
background-color: var(--action-link-04);
}
.button-action-primary[data-state="transient"] {
background-color: var(--action-link-06);
}
.button-action-primary:active {
background-color: var(--action-link-06);
}
.button-action-primary:disabled {
background-color: var(--action-link-02);
}
.button-action-primary-text {
color: var(--text-light-05) !important;
}
.button-action-primary:disabled .button-action-primary-text {
color: var(--text-01) !important;
}
.button-action-primary-icon {
stroke: var(--text-light-05);
}
.button-action-primary:disabled .button-action-primary-icon {
stroke: var(--text-01);
}
/* ============================================================================
Action Variant - Secondary
============================================================================ */
.button-action-secondary {
background-color: var(--background-tint-01);
border: 1px solid var(--border-01);
}
.button-action-secondary:hover {
background-color: var(--background-tint-02);
}
.button-action-secondary[data-state="transient"] {
background-color: var(--background-tint-00);
}
.button-action-secondary:active {
background-color: var(--background-tint-00);
}
.button-action-secondary:disabled {
background-color: var(--background-neutral-02);
border: 1px solid var(--border-01);
}
.button-action-secondary-text {
color: var(--action-text-link-05) !important;
}
.button-action-secondary:disabled .button-action-secondary-text {
color: var(--action-link-03) !important;
}
.button-action-secondary-icon {
stroke: var(--action-text-link-05);
}
.button-action-secondary:disabled .button-action-secondary-icon {
stroke: var(--action-link-03);
}
/* ============================================================================
Action Variant - Tertiary
============================================================================ */
.button-action-tertiary {
background-color: transparent;
}
.button-action-tertiary:hover {
background-color: var(--background-tint-02);
}
.button-action-tertiary[data-state="transient"] {
background-color: var(--background-tint-00);
}
.button-action-tertiary:active {
background-color: var(--background-tint-00);
}
.button-action-tertiary:disabled {
background-color: transparent;
}
.button-action-tertiary-text {
color: var(--action-text-link-05) !important;
}
.button-action-tertiary:disabled .button-action-tertiary-text {
color: var(--action-link-03) !important;
}
.button-action-tertiary-icon {
stroke: var(--action-text-link-05);
}
.button-action-tertiary:disabled .button-action-tertiary-icon {
stroke: var(--action-link-03);
}
/* ============================================================================
Danger Variant - Primary
============================================================================ */
.button-danger-primary {
background-color: var(--action-danger-05);
}
.button-danger-primary:hover {
background-color: var(--action-danger-04);
}
.button-danger-primary[data-state="transient"] {
background-color: var(--action-danger-06);
}
.button-danger-primary:active {
background-color: var(--action-danger-06);
}
.button-danger-primary:disabled {
background-color: var(--action-danger-02);
}
.button-danger-primary-text {
color: var(--text-light-05) !important;
}
.button-danger-primary:disabled .button-danger-primary-text {
color: var(--text-01) !important;
}
.button-danger-primary-icon {
stroke: var(--text-light-05);
}
.button-danger-primary:disabled .button-danger-primary-icon {
stroke: var(--text-01);
}
/* ============================================================================
Danger Variant - Secondary
============================================================================ */
.button-danger-secondary {
background-color: var(--background-tint-01);
border: 1px solid var(--border-01);
}
.button-danger-secondary:hover {
background-color: var(--background-tint-02);
}
.button-danger-secondary[data-state="transient"] {
background-color: var(--background-tint-00);
}
.button-danger-secondary:active {
background-color: var(--background-tint-00);
}
.button-danger-secondary:disabled {
background-color: var(--background-neutral-02);
border: 1px solid var(--border-01);
}
.button-danger-secondary-text {
color: var(--action-text-danger-05) !important;
}
.button-danger-secondary:disabled .button-danger-secondary-text {
color: var(--action-danger-03) !important;
}
.button-danger-secondary-icon {
stroke: var(--action-text-danger-05);
}
.button-danger-secondary:disabled .button-danger-secondary-icon {
stroke: var(--action-danger-03);
}
/* ============================================================================
Danger Variant - Tertiary
============================================================================ */
.button-danger-tertiary {
background-color: transparent;
}
.button-danger-tertiary:hover {
background-color: var(--background-tint-02);
}
.button-danger-tertiary[data-state="transient"] {
background-color: var(--background-tint-00);
}
.button-danger-tertiary:active {
background-color: var(--background-tint-00);
}
.button-danger-tertiary:disabled {
background-color: transparent;
}
.button-danger-tertiary-text {
color: var(--action-text-danger-05) !important;
}
.button-danger-tertiary:disabled .button-danger-tertiary-text {
color: var(--action-danger-03) !important;
}
.button-danger-tertiary-icon {
stroke: var(--action-text-danger-05);
}
.button-danger-tertiary:disabled .button-danger-tertiary-icon {
stroke: var(--action-danger-03);
}

View File

@@ -467,10 +467,6 @@
/* Frost Overlay (for FrostedDiv component) - lighter in light mode */
--frost-overlay: var(--alpha-grey-00-10);
/* Scrollbar */
--scrollbar-track: transparent;
--scrollbar-thumb: var(--alpha-grey-100-20);
}
/* Dark Colors */
@@ -675,8 +671,4 @@
/* Frost Overlay (for FrostedDiv component) - darker in dark mode */
--frost-overlay: var(--alpha-grey-100-10);
/* Scrollbar */
--scrollbar-track: transparent;
--scrollbar-thumb: var(--alpha-grey-00-20);
}

View File

@@ -0,0 +1,75 @@
/* Background classes */
.sidebar-tab-background-defaulted[data-state="active"] {
background-color: var(--background-tint-00);
}
.sidebar-tab-background-defaulted[data-state="inactive"] {
background-color: transparent;
}
.sidebar-tab-background-defaulted:hover {
background-color: var(--background-tint-03);
}
.sidebar-tab-background-lowlight[data-state="active"] {
background-color: var(--background-tint-00);
}
.sidebar-tab-background-lowlight[data-state="inactive"] {
background-color: transparent;
}
.sidebar-tab-background-lowlight:hover {
background-color: var(--background-tint-03);
}
.sidebar-tab-background-focused {
border: 2px solid var(--background-tint-04);
background-color: var(--background-neutral-00);
}
/* Text classes */
.sidebar-tab-text-defaulted[data-state="active"] {
color: var(--text-04);
}
.sidebar-tab-text-defaulted[data-state="inactive"] {
color: var(--text-03);
}
.group\/SidebarTab:hover .sidebar-tab-text-defaulted {
color: var(--text-04);
}
.sidebar-tab-text-lowlight[data-state="active"] {
color: var(--text-03);
}
.sidebar-tab-text-lowlight[data-state="inactive"] {
color: var(--text-02);
}
.group\/SidebarTab:hover .sidebar-tab-text-lowlight {
color: var(--text-03);
}
.sidebar-tab-text-focused {
color: var(--text-03);
}
/* Icon classes */
.sidebar-tab-icon-defaulted[data-state="active"] {
stroke: var(--text-04);
}
.sidebar-tab-icon-defaulted[data-state="inactive"] {
stroke: var(--text-03);
}
.group\/SidebarTab:hover .sidebar-tab-icon-defaulted {
stroke: var(--text-04);
}
.sidebar-tab-icon-lowlight[data-state="active"] {
stroke: var(--text-03);
}
.sidebar-tab-icon-lowlight[data-state="inactive"] {
stroke: var(--text-02);
}
.group\/SidebarTab:hover .sidebar-tab-icon-lowlight {
stroke: var(--text-03);
}
.sidebar-tab-icon-focused {
stroke: var(--text-02);
}

View File

@@ -1,38 +0,0 @@
.square-button {
/* Base styles */
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
aspect-ratio: 1 / 1;
border-radius: var(--radius-08);
padding: 0.5rem;
background-color: var(--background-tint-01);
}
.square-button:hover {
background-color: var(--background-tint-02);
}
.square-button:active {
background-color: var(--background-tint-03);
}
.square-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Transient state */
.square-button[data-state="transient"] {
border: 1px solid var(--action-link-05);
background-color: var(--action-link-00);
}
.square-button[data-state="transient"]:hover {
background-color: var(--action-link-01);
}
.square-button[data-state="transient"]:active {
background-color: var(--action-link-02);
}

View File

@@ -1,5 +1,4 @@
@import "css/attachment-button.css";
@import "css/button.css";
@import "css/card.css";
@import "css/code.css";
@import "css/color-swatch.css";
@@ -9,8 +8,8 @@
@import "css/inputs.css";
@import "css/knowledge-table.css";
@import "css/line-item.css";
@import "css/sidebar-tab.css";
@import "css/sizes.css";
@import "css/square-button.css";
@import "css/switch.css";
@import "css/z-index.css";
@@ -127,8 +126,17 @@
}
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
/* SHADOWS */
@@ -353,9 +361,27 @@
/* SCROLL BAR */
.default-scrollbar::-webkit-scrollbar {
width: 6px;
}
.default-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
}
.default-scrollbar::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.default-scrollbar::-webkit-scrollbar-thumb:hover {
background: #555;
}
.default-scrollbar {
scrollbar-width: thin;
scrollbar-color: #888 transparent;
overflow: overlay;
overflow-y: scroll;
overflow-x: hidden;
}
@@ -365,21 +391,78 @@
height: 100%;
}
.inputscroll {
.inputscroll::-webkit-scrollbar-track {
background: #e5e7eb;
scrollbar-width: none;
}
/* Ensure native scrollbars are visible */
@layer base {
* {
scrollbar-width: auto;
}
::-webkit-scrollbar {
width: 0px;
/* Vertical scrollbar width */
height: 8px;
/* Horizontal scrollbar height */
}
::-webkit-scrollbar-track {
background: transparent;
/* background: theme("colors.scrollbar.track"); */
/* Track background color */
}
/* Style the scrollbar handle */
::-webkit-scrollbar-thumb {
background: transparent;
/* background: theme("colors.scrollbar.thumb"); */
/* Handle color */
border-radius: 10px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: transparent;
/* background: theme("colors.scrollbar.thumb-hover"); */
/* Handle color on hover */
}
.dark-scrollbar::-webkit-scrollbar-thumb {
background: transparent;
/* background: theme("colors.scrollbar.dark.thumb"); */
/* Handle color */
border-radius: 10px;
}
.dark-scrollbar::-webkit-scrollbar-thumb:hover {
background: transparent;
/* background: theme("colors.scrollbar.dark.thumb-hover"); */
/* Handle color on hover */
}
/* TEXTAREA */
textarea::-webkit-scrollbar {
width: 8px;
}
textarea::-webkit-scrollbar-track {
background: var(--scrollbar-track);
border-radius: 4px;
}
textarea::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
textarea::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
textarea {
resize: vertical;
}
/* For Firefox */
textarea {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}

View File

@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import AdminSidebar from "@/sections/sidebar/AdminSidebar";
import { usePathname } from "next/navigation";
import { useSettingsContext } from "@/providers/SettingsProvider";
@@ -7,6 +8,8 @@ import { ApplicationStatus } from "@/interfaces/settings";
import { Button } from "@opal/components";
import { cn } from "@/lib/utils";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import useScreenSize from "@/hooks/useScreenSize";
import { SvgSidebar } from "@opal/icons";
export interface ClientLayoutProps {
children: React.ReactNode;
@@ -49,6 +52,8 @@ const SETTINGS_LAYOUT_PREFIXES = [
];
export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
const [sidebarFolded, setSidebarFolded] = useState(true);
const { isMobile } = useScreenSize();
const pathname = usePathname();
const settings = useSettingsContext();
@@ -82,7 +87,11 @@ export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
<div className="flex-1 min-w-0 min-h-0 overflow-y-auto">{children}</div>
) : (
<>
<AdminSidebar enableCloudSS={enableCloud} />
<AdminSidebar
enableCloudSS={enableCloud}
folded={sidebarFolded}
onFoldChange={setSidebarFolded}
/>
<div
data-main-container
className={cn(
@@ -90,6 +99,15 @@ export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
!hasOwnLayout && "py-10 px-4 md:px-12"
)}
>
{isMobile && (
<div className="flex items-center px-4 pt-2">
<Button
prominence="internal"
icon={SvgSidebar}
onClick={() => setSidebarFolded(false)}
/>
</div>
)}
{children}
</div>
</>

View File

@@ -0,0 +1,235 @@
"use client";
/**
* Sidebar Layout Components
*
* Provides composable layout primitives for app and admin sidebars with mobile
* overlay support and optional desktop folding.
*
* @example
* ```tsx
* import * as SidebarLayouts from "@/layouts/sidebar-layouts";
* import { useSidebarFolded } from "@/layouts/sidebar-layouts";
*
* function MySidebar() {
* const { folded, setFolded } = useSidebarState();
* const contentFolded = useSidebarFolded();
*
* return (
* <SidebarLayouts.Root folded={folded} onFoldChange={setFolded} foldable>
* <SidebarLayouts.Header>
* <NewSessionButton folded={contentFolded} />
* </SidebarLayouts.Header>
* <SidebarLayouts.Body scrollKey="my-sidebar">
* {contentFolded ? null : <SectionContent />}
* </SidebarLayouts.Body>
* <SidebarLayouts.Footer>
* <UserAvatar />
* </SidebarLayouts.Footer>
* </SidebarLayouts.Root>
* );
* }
* ```
*/
import {
createContext,
useContext,
useCallback,
type Dispatch,
type SetStateAction,
} from "react";
import { cn } from "@/lib/utils";
import SidebarWrapper from "@/sections/sidebar/SidebarWrapper";
import OverflowDiv from "@/refresh-components/OverflowDiv";
import useScreenSize from "@/hooks/useScreenSize";
// ---------------------------------------------------------------------------
// Fold context
// ---------------------------------------------------------------------------
const SidebarFoldedContext = createContext(false);
/**
* Returns whether the sidebar content should render in its folded (narrow)
* state. On mobile, this is always `false` because the overlay pattern handles
* visibility — the sidebar content itself is always fully expanded.
*/
export function useSidebarFolded(): boolean {
return useContext(SidebarFoldedContext);
}
// ---------------------------------------------------------------------------
// Root
// ---------------------------------------------------------------------------
interface SidebarRootProps {
/**
* Whether the sidebar is currently folded (desktop) or off-screen (mobile).
*/
folded: boolean;
/** Callback to update the fold state. Compatible with `useState` setters. */
onFoldChange: Dispatch<SetStateAction<boolean>>;
/**
* Whether the sidebar supports folding on desktop.
* When `false` (the default), the sidebar is always expanded on desktop and
* the fold button is hidden. Mobile overlay behavior is always enabled
* regardless of this prop.
*/
foldable?: boolean;
children: React.ReactNode;
}
function SidebarRoot({
folded,
onFoldChange,
foldable = false,
children,
}: SidebarRootProps) {
const { isMobile, isMediumScreen } = useScreenSize();
const close = useCallback(() => onFoldChange(true), [onFoldChange]);
const toggle = useCallback(
() => onFoldChange((prev) => !prev),
[onFoldChange]
);
// On mobile the sidebar content is always visually expanded — the overlay
// transform handles visibility. On desktop, only foldable sidebars honour
// the fold state.
const contentFolded = !isMobile && foldable ? folded : false;
const inner = (
<div className="flex flex-col min-h-0 h-full gap-3">{children}</div>
);
if (isMobile) {
return (
<SidebarFoldedContext.Provider value={false}>
<div
className={cn(
"fixed inset-y-0 left-0 z-50 transition-transform duration-200",
folded ? "-translate-x-full" : "translate-x-0"
)}
>
<SidebarWrapper folded={false} onFoldClick={close}>
{inner}
</SidebarWrapper>
</div>
{/* Backdrop — closes the sidebar when anything outside it is tapped */}
<div
className={cn(
"fixed inset-0 z-40 bg-mask-03 backdrop-blur-03 transition-opacity duration-200",
folded
? "opacity-0 pointer-events-none"
: "opacity-100 pointer-events-auto"
)}
onClick={close}
/>
</SidebarFoldedContext.Provider>
);
}
// Medium screens: the folded strip stays visible in the layout flow;
// expanding overlays content instead of pushing it.
if (isMediumScreen) {
return (
<SidebarFoldedContext.Provider value={folded}>
{/* Spacer reserves the folded sidebar width in the flex layout */}
<div className="shrink-0 w-[3.25rem]" />
{/* Sidebar — fixed so it overlays content when expanded */}
<div className="fixed inset-y-0 left-0 z-50">
<SidebarWrapper folded={folded} onFoldClick={toggle}>
{inner}
</SidebarWrapper>
</div>
{/* Backdrop when expanded — blur only, no tint */}
<div
className={cn(
"fixed inset-0 z-40 backdrop-blur-03 transition-opacity duration-200",
folded
? "opacity-0 pointer-events-none"
: "opacity-100 pointer-events-auto"
)}
onClick={close}
/>
</SidebarFoldedContext.Provider>
);
}
return (
<SidebarFoldedContext.Provider value={contentFolded}>
<SidebarWrapper
folded={foldable ? folded : undefined}
onFoldClick={foldable ? toggle : undefined}
>
{inner}
</SidebarWrapper>
</SidebarFoldedContext.Provider>
);
}
// ---------------------------------------------------------------------------
// Header — pinned content above the scroll area
// ---------------------------------------------------------------------------
interface SidebarHeaderProps {
children?: React.ReactNode;
}
function SidebarHeader({ children }: SidebarHeaderProps) {
if (!children) return null;
return <div className="px-2">{children}</div>;
}
// ---------------------------------------------------------------------------
// Body — scrollable content area
// ---------------------------------------------------------------------------
interface SidebarBodyProps {
/**
* Unique key to enable scroll position persistence across navigation.
* (e.g., "admin-sidebar", "app-sidebar").
*/
scrollKey: string;
children?: React.ReactNode;
}
function SidebarBody({ scrollKey, children }: SidebarBodyProps) {
const folded = useSidebarFolded();
return (
<OverflowDiv
className={cn("gap-3 px-2", folded && "hidden")}
scrollKey={scrollKey}
>
{children}
</OverflowDiv>
);
}
// ---------------------------------------------------------------------------
// Footer — pinned content below the scroll area
// ---------------------------------------------------------------------------
interface SidebarFooterProps {
children?: React.ReactNode;
}
function SidebarFooter({ children }: SidebarFooterProps) {
if (!children) return null;
return <div className="px-2">{children}</div>;
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export {
SidebarRoot as Root,
SidebarHeader as Header,
SidebarBody as Body,
SidebarFooter as Footer,
};

View File

@@ -122,7 +122,7 @@ export const ART_ASSISTANT_ID = -3;
export const MAX_FILES_TO_SHOW = 3;
// SIZES
export const MOBILE_SIDEBAR_BREAKPOINT_PX = 640;
export const MOBILE_SIDEBAR_BREAKPOINT_PX = 724;
export const DESKTOP_SMALL_BREAKPOINT_PX = 912;
export const DESKTOP_MEDIUM_BREAKPOINT_PX = 1232;
export const DEFAULT_AVATAR_SIZE_PX = 18;

View File

@@ -1,10 +1,18 @@
"use client";
import { useCallback } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
type Dispatch,
type SetStateAction,
} from "react";
import { usePathname } from "next/navigation";
import { useSettingsContext } from "@/providers/SettingsProvider";
import SidebarSection from "@/sections/sidebar/SidebarSection";
import SidebarWrapper from "@/sections/sidebar/SidebarWrapper";
import * as SidebarLayouts from "@/layouts/sidebar-layouts";
import { useSidebarFolded } from "@/layouts/sidebar-layouts";
import { useIsKGExposed } from "@/app/admin/kg/utils";
import { useCustomAnalyticsEnabled } from "@/lib/hooks/useCustomAnalyticsEnabled";
import { useUser } from "@/providers/UserProvider";
@@ -12,10 +20,9 @@ import { UserRole } from "@/lib/types";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { CombinedSettings } from "@/interfaces/settings";
import { SidebarTab } from "@opal/components";
import SidebarBody from "@/sections/sidebar/SidebarBody";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import { Disabled } from "@opal/core";
import { SvgArrowUpCircle, SvgUserManage, SvgX } from "@opal/icons";
import { SvgArrowUpCircle, SvgSearch, SvgUserManage, SvgX } from "@opal/icons";
import {
useBillingInformation,
useLicense,
@@ -184,9 +191,29 @@ function groupBySection(items: SidebarItemEntry[]) {
interface AdminSidebarProps {
enableCloudSS: boolean;
folded: boolean;
onFoldChange: Dispatch<SetStateAction<boolean>>;
}
export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
interface AdminSidebarInnerProps {
enableCloudSS: boolean;
onFoldChange: Dispatch<SetStateAction<boolean>>;
}
function AdminSidebarInner({
enableCloudSS,
onFoldChange,
}: AdminSidebarInnerProps) {
const folded = useSidebarFolded();
const searchRef = useRef<HTMLInputElement>(null);
const [focusSearch, setFocusSearch] = useState(false);
useEffect(() => {
if (focusSearch && !folded && searchRef.current) {
searchRef.current.focus();
setFocusSearch(false);
}
}, [focusSearch, folded]);
const { kgExposed } = useIsKGExposed();
const pathname = usePathname();
const { customAnalyticsEnabled } = useCustomAnalyticsEnabled();
@@ -227,28 +254,78 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
const groups = groupBySection(filtered);
return (
<SidebarWrapper>
<SidebarBody
scrollKey="admin-sidebar"
pinnedContent={
<div className="flex flex-col w-full">
<>
<SidebarLayouts.Header>
<div className="flex flex-col w-full">
<SidebarTab
icon={({ className }) => <SvgX className={className} size={16} />}
href="/app"
variant="sidebar-light"
folded={folded}
>
Exit Admin Panel
</SidebarTab>
{folded ? (
<SidebarTab
icon={({ className }) => <SvgX className={className} size={16} />}
href="/app"
variant="sidebar-light"
icon={SvgSearch}
folded
onClick={() => {
onFoldChange(false);
setFocusSearch(true);
}}
>
Exit Admin Panel
Search
</SidebarTab>
) : (
<InputTypeIn
ref={searchRef}
variant="internal"
leftSearchIcon
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
}
footer={
)}
</div>
</SidebarLayouts.Header>
<SidebarLayouts.Body scrollKey="admin-sidebar">
{groups.map((group, groupIndex) => {
const tabs = group.items.map(({ link, icon, name, disabled }) => (
<Disabled key={link} disabled={disabled}>
{/*
# NOTE (@raunakab)
We intentionally add a `div` intermediary here.
Without it, the disabled styling that is default provided by the `Disabled` component (which we want here) would be overridden by the custom disabled styling provided by the `SidebarTab`.
Therefore, in order to avoid that overriding, we add a layer of indirection.
*/}
<div>
<SidebarTab
disabled={disabled}
icon={icon}
href={disabled ? undefined : link}
selected={pathname.startsWith(link)}
>
{name}
</SidebarTab>
</div>
</Disabled>
));
if (!group.section) {
return <div key={groupIndex}>{tabs}</div>;
}
return (
<SidebarSection key={groupIndex} title={group.section}>
{tabs}
</SidebarSection>
);
})}
</SidebarLayouts.Body>
<SidebarLayouts.Footer>
{!folded && (
<Section gap={0} height="fit" alignItems="start">
<div className="p-[0.38rem] w-full">
<Content
@@ -284,41 +361,23 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
)}
</div>
</Section>
}
>
{groups.map((group, groupIndex) => {
const tabs = group.items.map(({ link, icon, name, disabled }) => (
<Disabled key={link} disabled={disabled}>
{/*
# NOTE (@raunakab)
We intentionally add a `div` intermediary here.
Without it, the disabled styling that is default provided by the `Disabled` component (which we want here) would be overridden by the custom disabled styling provided by the `SidebarTab`.
Therefore, in order to avoid that overriding, we add a layer of indirection.
*/}
<div>
<SidebarTab
disabled={disabled}
icon={icon}
href={disabled ? undefined : link}
selected={pathname.startsWith(link)}
>
{name}
</SidebarTab>
</div>
</Disabled>
));
if (!group.section) {
return <div key={groupIndex}>{tabs}</div>;
}
return (
<SidebarSection key={groupIndex} title={group.section}>
{tabs}
</SidebarSection>
);
})}
</SidebarBody>
</SidebarWrapper>
)}
</SidebarLayouts.Footer>
</>
);
}
export default function AdminSidebar({
enableCloudSS,
folded,
onFoldChange,
}: AdminSidebarProps) {
return (
<SidebarLayouts.Root folded={folded} onFoldChange={onFoldChange}>
<AdminSidebarInner
enableCloudSS={enableCloudSS}
onFoldChange={onFoldChange}
/>
</SidebarLayouts.Root>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -65,11 +65,13 @@ module.exports = {
"neutral-10": "var(--neutral-10) 5%",
},
screens: {
sm: "724px",
md: "912px",
lg: "1232px",
"2xl": "1420px",
"3xl": "1700px",
"4xl": "2000px",
mobile: { max: "767px" },
desktop: "768px",
mobile: { max: "724px" },
tall: { raw: "(min-height: 800px)" },
short: { raw: "(max-height: 799px)" },
"very-short": { raw: "(max-height: 600px)" },

View File

@@ -59,10 +59,7 @@ for (const theme of THEMES) {
await expectScreenshot(page, {
name: `admin-${theme}-${slug}`,
mask: [
'[data-testid="admin-date-range-selector-button"]',
'[data-column-id="updated_at"]',
],
mask: ['[data-testid="admin-date-range-selector-button"]'],
});
},
{ box: true }

View File

@@ -142,6 +142,8 @@ test.describe("Chat Search Command Menu", () => {
}
await expect(dialog.getByText("Sessions")).toBeVisible();
await expectScreenshot(page, { name: "command-menu-sessions-filter" });
});
test('"Projects" filter expands to show all 4 projects', async ({ page }) => {

View File

@@ -135,65 +135,6 @@ export async function waitForAnimations(page: Page): Promise<void> {
});
}
/**
* Wait for every **visible** `<img>` on the page to finish loading (or error).
*
* This prevents screenshot flakiness caused by images that have been added to
* the DOM but haven't been decoded yet — `networkidle` only guarantees that
* fewer than 2 connections are in flight, not that every image is painted.
*
* Only images that are actually visible and in (or near) the viewport are
* waited on. Hidden images (e.g. the `dark:hidden` / `hidden dark:block`
* alternates created by `createLogoIcon`) and offscreen lazy-loaded images
* are skipped so they don't force a needless timeout.
*
* Times out after `timeoutMs` (default 5 000 ms) so a single broken image
* doesn't block the entire test forever.
*/
export async function waitForImages(
page: Page,
timeoutMs: number = 5_000
): Promise<void> {
await page.evaluate(async (timeout) => {
const images = Array.from(document.querySelectorAll("img")).filter(
(img) => {
// Skip images hidden via CSS (display:none, visibility:hidden, etc.)
// This covers createLogoIcon's dark-mode alternates.
const style = getComputedStyle(img);
if (
style.display === "none" ||
style.visibility === "hidden" ||
style.opacity === "0"
) {
return false;
}
// Skip images that have no layout box (zero size or detached).
const rect = img.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return false;
// Skip images far below the viewport (lazy-loaded, not yet needed).
if (rect.top > window.innerHeight * 2) return false;
return true;
}
);
await Promise.race([
Promise.allSettled(
images.map((img) => {
if (img.complete) return Promise.resolve();
return new Promise<void>((resolve) => {
img.addEventListener("load", () => resolve(), { once: true });
img.addEventListener("error", () => resolve(), { once: true });
});
})
),
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
]);
}, timeoutMs);
}
/**
* Take a screenshot and optionally assert it matches the stored baseline.
*
@@ -247,10 +188,6 @@ export async function expectScreenshot(
page.locator(selector)
);
// Wait for images to finish loading / decoding so that logo icons
// and other <img> elements are fully painted before the screenshot.
await waitForImages(page);
// Wait for any in-flight CSS animations / transitions to settle so that
// screenshots are deterministic (e.g. slide-in card animations on the
// onboarding flow).
@@ -342,9 +279,6 @@ export async function expectElementScreenshot(
page.locator(selector)
);
// Wait for images to finish loading / decoding.
await waitForImages(page);
// Wait for any in-flight CSS animations / transitions to settle so that
// element screenshots are deterministic (same reasoning as expectScreenshot).
await waitForAnimations(page);