mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-03 14:02:42 +00:00
Compare commits
8 Commits
jamison/me
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4a96d70f3 | ||
|
|
5b000c2173 | ||
|
|
d62af28e40 | ||
|
|
593678a14f | ||
|
|
e6f7c2b45c | ||
|
|
f77128d929 | ||
|
|
1d4ca769e7 | ||
|
|
e002f6c195 |
@@ -1,20 +1,14 @@
|
||||
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
|
||||
|
||||
@@ -29,26 +23,16 @@ logger = setup_logger()
|
||||
trail=False,
|
||||
)
|
||||
def perform_ttl_management_task(
|
||||
self: Task, retention_limit_days: int, *, tenant_id: str
|
||||
self: Task, retention_limit_days: int, *, tenant_id: str # noqa: ARG001
|
||||
) -> 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
|
||||
@@ -65,23 +49,10 @@ 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
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ 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,
|
||||
)
|
||||
@@ -106,14 +107,19 @@ 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,
|
||||
@@ -136,10 +142,11 @@ 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:
|
||||
# Double check that tenant info is correct.
|
||||
# 2.a. 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: "
|
||||
@@ -148,16 +155,62 @@ def migrate_chunks_from_vespa_to_opensearch_task(
|
||||
task_logger.error(err_str)
|
||||
return False
|
||||
|
||||
with (
|
||||
get_session_with_current_tenant() as db_session,
|
||||
get_vespa_http_client(
|
||||
timeout=VESPA_MIGRATION_REQUEST_TIMEOUT_S
|
||||
) as vespa_client,
|
||||
):
|
||||
# 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.
|
||||
try_insert_opensearch_tenant_migration_record_with_commit(db_session)
|
||||
|
||||
# 2.d. Get search settings.
|
||||
search_settings = get_current_search_settings(db_session)
|
||||
tenant_state = TenantState(tenant_id=tenant_id, multitenant=MULTI_TENANT)
|
||||
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)
|
||||
opensearch_document_index = OpenSearchDocumentIndex(
|
||||
tenant_state=tenant_state,
|
||||
index_name=search_settings.index_name,
|
||||
@@ -171,22 +224,14 @@ def migrate_chunks_from_vespa_to_opensearch_task(
|
||||
httpx_client=vespa_client,
|
||||
)
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
# 2.h. Get the approximate chunk count in Vespa as of this time to
|
||||
# update the migration record.
|
||||
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..."
|
||||
)
|
||||
@@ -195,25 +240,12 @@ 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()
|
||||
):
|
||||
(
|
||||
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}"
|
||||
)
|
||||
|
||||
# 3.a. Get the next batch of raw chunks from Vespa.
|
||||
get_vespa_chunks_start_time = time.monotonic()
|
||||
raw_vespa_chunks, next_continuation_token_map = (
|
||||
vespa_document_index.get_all_raw_document_chunks_paginated(
|
||||
@@ -226,6 +258,7 @@ 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,
|
||||
@@ -240,6 +273,7 @@ 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
|
||||
@@ -251,12 +285,38 @@ 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)
|
||||
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,
|
||||
|
||||
# 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}"
|
||||
)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -324,6 +324,15 @@ 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]:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from typing import Any
|
||||
@@ -20,9 +21,13 @@ 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
|
||||
@@ -75,17 +80,50 @@ 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.
|
||||
"""
|
||||
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_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
|
||||
)
|
||||
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.
|
||||
short_tenant_id = get_tenant_id_short_string(tenant_state.tenant_id)
|
||||
opensearch_doc_chunk_id = f"{short_tenant_id}__{opensearch_doc_chunk_id}"
|
||||
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}"
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import re
|
||||
|
||||
MAX_DOCUMENT_ID_ENCODED_LENGTH: int = 512
|
||||
|
||||
def filter_and_validate_document_id(document_id: str) -> str:
|
||||
|
||||
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:
|
||||
"""
|
||||
Filters and validates a document ID such that it can be used as an ID in
|
||||
OpenSearch.
|
||||
@@ -19,9 +27,13 @@ def filter_and_validate_document_id(document_id: str) -> str:
|
||||
|
||||
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:
|
||||
ValueError: If the document ID is empty or too long after filtering.
|
||||
DocumentIDTooLongError: If the document ID is too long after filtering.
|
||||
ValueError: If the document ID is empty after filtering.
|
||||
|
||||
Returns:
|
||||
str: The filtered document ID.
|
||||
@@ -29,6 +41,8 @@ def filter_and_validate_document_id(document_id: str) -> str:
|
||||
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")) >= 512:
|
||||
raise ValueError(f"Document ID {document_id} is too long 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."
|
||||
)
|
||||
return filtered_document_id
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
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
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
)
|
||||
@@ -24,6 +25,8 @@ func newChatCmd() *cobra.Command {
|
||||
cfg = *result
|
||||
}
|
||||
|
||||
starprompt.MaybePrompt()
|
||||
|
||||
m := tui.NewModel(cfg)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
_, err := p.Run()
|
||||
|
||||
83
cli/internal/starprompt/starprompt.go
Normal file
83
cli/internal/starprompt/starprompt.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1302,4 +1302,18 @@ 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 ""
|
||||
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
|
||||
|
||||
@@ -586,7 +586,10 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
|
||||
// Data / Display cell
|
||||
return (
|
||||
<TableCell key={cell.id}>
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
data-column-id={cell.column.id}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
|
||||
@@ -127,13 +127,13 @@ function Main() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2">
|
||||
<div className="flex flex-col gap-2 desktop:flex-row desktop:items-center desktop:gap-2">
|
||||
{isApiKeySet ? (
|
||||
<>
|
||||
<Button variant="danger" onClick={handleDelete}>
|
||||
Delete API Key
|
||||
</Button>
|
||||
<Text as="p" mainContentBody text04 className="sm:mt-0">
|
||||
<Text as="p" mainContentBody text04 className="desktop:mt-0">
|
||||
Delete the current API key before updating.
|
||||
</Text>
|
||||
</>
|
||||
|
||||
@@ -238,9 +238,7 @@ function BuildSessionButton({
|
||||
<Text
|
||||
as="p"
|
||||
data-state={isActive ? "active" : "inactive"}
|
||||
className={cn(
|
||||
"sidebar-tab-text-defaulted line-clamp-1 break-all text-left"
|
||||
)}
|
||||
className="line-clamp-1 break-all text-left"
|
||||
mainUiBody
|
||||
>
|
||||
<TypewriterText
|
||||
|
||||
399
web/src/app/css/button.css
Normal file
399
web/src/app/css/button.css
Normal file
@@ -0,0 +1,399 @@
|
||||
/* ============================================================================
|
||||
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);
|
||||
}
|
||||
@@ -467,6 +467,10 @@
|
||||
|
||||
/* 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 */
|
||||
@@ -671,4 +675,8 @@
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
38
web/src/app/css/square-button.css
Normal file
38
web/src/app/css/square-button.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "css/attachment-button.css";
|
||||
@import "css/button.css";
|
||||
@import "css/card.css";
|
||||
@import "css/code.css";
|
||||
@import "css/color-swatch.css";
|
||||
@@ -8,8 +9,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";
|
||||
|
||||
@@ -126,17 +127,8 @@
|
||||
}
|
||||
|
||||
@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 */
|
||||
@@ -361,27 +353,9 @@
|
||||
|
||||
/* 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;
|
||||
}
|
||||
@@ -391,78 +365,21 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.inputscroll::-webkit-scrollbar-track {
|
||||
background: #e5e7eb;
|
||||
.inputscroll {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
::-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 */
|
||||
/* Ensure native scrollbars are visible */
|
||||
@layer base {
|
||||
* {
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import AdminSidebar from "@/sections/sidebar/AdminSidebar";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
@@ -8,8 +7,6 @@ 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;
|
||||
@@ -52,8 +49,6 @@ const SETTINGS_LAYOUT_PREFIXES = [
|
||||
];
|
||||
|
||||
export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
|
||||
const [sidebarFolded, setSidebarFolded] = useState(true);
|
||||
const { isMobile } = useScreenSize();
|
||||
const pathname = usePathname();
|
||||
const settings = useSettingsContext();
|
||||
|
||||
@@ -87,11 +82,7 @@ export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
|
||||
<div className="flex-1 min-w-0 min-h-0 overflow-y-auto">{children}</div>
|
||||
) : (
|
||||
<>
|
||||
<AdminSidebar
|
||||
enableCloudSS={enableCloud}
|
||||
folded={sidebarFolded}
|
||||
onFoldChange={setSidebarFolded}
|
||||
/>
|
||||
<AdminSidebar enableCloudSS={enableCloud} />
|
||||
<div
|
||||
data-main-container
|
||||
className={cn(
|
||||
@@ -99,15 +90,6 @@ 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>
|
||||
</>
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
"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,
|
||||
};
|
||||
@@ -122,7 +122,7 @@ export const ART_ASSISTANT_ID = -3;
|
||||
export const MAX_FILES_TO_SHOW = 3;
|
||||
|
||||
// SIZES
|
||||
export const MOBILE_SIDEBAR_BREAKPOINT_PX = 724;
|
||||
export const MOBILE_SIDEBAR_BREAKPOINT_PX = 640;
|
||||
export const DESKTOP_SMALL_BREAKPOINT_PX = 912;
|
||||
export const DESKTOP_MEDIUM_BREAKPOINT_PX = 1232;
|
||||
export const DEFAULT_AVATAR_SIZE_PX = 18;
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { useCallback } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import SidebarSection from "@/sections/sidebar/SidebarSection";
|
||||
import * as SidebarLayouts from "@/layouts/sidebar-layouts";
|
||||
import { useSidebarFolded } from "@/layouts/sidebar-layouts";
|
||||
import SidebarWrapper from "@/sections/sidebar/SidebarWrapper";
|
||||
import { useIsKGExposed } from "@/app/admin/kg/utils";
|
||||
import { useCustomAnalyticsEnabled } from "@/lib/hooks/useCustomAnalyticsEnabled";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
@@ -20,9 +12,10 @@ 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, SvgSearch, SvgUserManage, SvgX } from "@opal/icons";
|
||||
import { SvgArrowUpCircle, SvgUserManage, SvgX } from "@opal/icons";
|
||||
import {
|
||||
useBillingInformation,
|
||||
useLicense,
|
||||
@@ -191,29 +184,9 @@ function groupBySection(items: SidebarItemEntry[]) {
|
||||
|
||||
interface AdminSidebarProps {
|
||||
enableCloudSS: boolean;
|
||||
folded: boolean;
|
||||
onFoldChange: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
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]);
|
||||
export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
|
||||
const { kgExposed } = useIsKGExposed();
|
||||
const pathname = usePathname();
|
||||
const { customAnalyticsEnabled } = useCustomAnalyticsEnabled();
|
||||
@@ -254,78 +227,28 @@ function AdminSidebarInner({
|
||||
const groups = groupBySection(filtered);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 ? (
|
||||
<SidebarWrapper>
|
||||
<SidebarBody
|
||||
scrollKey="admin-sidebar"
|
||||
pinnedContent={
|
||||
<div className="flex flex-col w-full">
|
||||
<SidebarTab
|
||||
icon={SvgSearch}
|
||||
folded
|
||||
onClick={() => {
|
||||
onFoldChange(false);
|
||||
setFocusSearch(true);
|
||||
}}
|
||||
icon={({ className }) => <SvgX className={className} size={16} />}
|
||||
href="/app"
|
||||
variant="sidebar-light"
|
||||
>
|
||||
Search
|
||||
Exit Admin Panel
|
||||
</SidebarTab>
|
||||
) : (
|
||||
<InputTypeIn
|
||||
ref={searchRef}
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</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 && (
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<Section gap={0} height="fit" alignItems="start">
|
||||
<div className="p-[0.38rem] w-full">
|
||||
<Content
|
||||
@@ -361,23 +284,41 @@ function AdminSidebarInner({
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</SidebarLayouts.Footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
>
|
||||
{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>
|
||||
));
|
||||
|
||||
export default function AdminSidebar({
|
||||
enableCloudSS,
|
||||
folded,
|
||||
onFoldChange,
|
||||
}: AdminSidebarProps) {
|
||||
return (
|
||||
<SidebarLayouts.Root folded={folded} onFoldChange={onFoldChange}>
|
||||
<AdminSidebarInner
|
||||
enableCloudSS={enableCloudSS}
|
||||
onFoldChange={onFoldChange}
|
||||
/>
|
||||
</SidebarLayouts.Root>
|
||||
if (!group.section) {
|
||||
return <div key={groupIndex}>{tabs}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarSection key={groupIndex} title={group.section}>
|
||||
{tabs}
|
||||
</SidebarSection>
|
||||
);
|
||||
})}
|
||||
</SidebarBody>
|
||||
</SidebarWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -65,13 +65,11 @@ module.exports = {
|
||||
"neutral-10": "var(--neutral-10) 5%",
|
||||
},
|
||||
screens: {
|
||||
sm: "724px",
|
||||
md: "912px",
|
||||
lg: "1232px",
|
||||
"2xl": "1420px",
|
||||
"3xl": "1700px",
|
||||
"4xl": "2000px",
|
||||
mobile: { max: "724px" },
|
||||
mobile: { max: "767px" },
|
||||
desktop: "768px",
|
||||
tall: { raw: "(min-height: 800px)" },
|
||||
short: { raw: "(max-height: 799px)" },
|
||||
"very-short": { raw: "(max-height: 600px)" },
|
||||
|
||||
@@ -59,7 +59,10 @@ for (const theme of THEMES) {
|
||||
|
||||
await expectScreenshot(page, {
|
||||
name: `admin-${theme}-${slug}`,
|
||||
mask: ['[data-testid="admin-date-range-selector-button"]'],
|
||||
mask: [
|
||||
'[data-testid="admin-date-range-selector-button"]',
|
||||
'[data-column-id="updated_at"]',
|
||||
],
|
||||
});
|
||||
},
|
||||
{ box: true }
|
||||
|
||||
@@ -142,8 +142,6 @@ 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 }) => {
|
||||
|
||||
@@ -135,6 +135,65 @@ 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.
|
||||
*
|
||||
@@ -188,6 +247,10 @@ 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).
|
||||
@@ -279,6 +342,9 @@ 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);
|
||||
|
||||
Reference in New Issue
Block a user