Compare commits

...

5 Commits

52 changed files with 1245 additions and 743 deletions

View File

@@ -805,6 +805,10 @@ MINI_CHUNK_SIZE = 150
# This is the number of regular chunks per large chunk
LARGE_CHUNK_RATIO = 4
# The maximum number of chunks that can be held for 1 document processing batch
# The purpose of this is to set an upper bound on memory usage
MAX_CHUNKS_PER_DOC_BATCH = int(os.environ.get("MAX_CHUNKS_PER_DOC_BATCH") or 1000)
# Include the document level metadata in each chunk. If the metadata is too long, then it is thrown out
# We don't want the metadata to overwhelm the actual contents of the chunk
SKIP_METADATA_IN_CHUNK = os.environ.get("SKIP_METADATA_IN_CHUNK", "").lower() == "true"

View File

@@ -6,6 +6,7 @@ import httpx
from opensearchpy import NotFoundError
from onyx.access.models import DocumentAccess
from onyx.configs.app_configs import MAX_CHUNKS_PER_DOC_BATCH
from onyx.configs.app_configs import VERIFY_CREATE_OPENSEARCH_INDEX_ON_INIT_MT
from onyx.configs.chat_configs import NUM_RETURNED_HITS
from onyx.configs.chat_configs import TITLE_CONTENT_RATIO
@@ -738,6 +739,9 @@ class OpenSearchDocumentIndex(DocumentIndex):
_flush_chunks(current_chunks)
current_doc_id = doc_id
current_chunks = [chunk]
elif len(current_chunks) >= MAX_CHUNKS_PER_DOC_BATCH:
_flush_chunks(current_chunks)
current_chunks = [chunk]
else:
current_chunks.append(chunk)

View File

@@ -10,6 +10,7 @@ import httpx
from pydantic import BaseModel
from retry import retry
from onyx.configs.app_configs import MAX_CHUNKS_PER_DOC_BATCH
from onyx.configs.app_configs import RECENCY_BIAS_MULTIPLIER
from onyx.configs.app_configs import RERANK_COUNT
from onyx.configs.chat_configs import DOC_TIME_DECAY
@@ -427,7 +428,9 @@ class VespaDocumentIndex(DocumentIndex):
new_document_id_to_original_document_id,
all_cleaned_doc_ids,
)
for chunk_batch in batch_generator(cleaned_chunks, BATCH_SIZE):
for chunk_batch in batch_generator(
cleaned_chunks, min(BATCH_SIZE, MAX_CHUNKS_PER_DOC_BATCH)
):
batch_index_vespa_chunks(
chunks=chunk_batch,
index_name=self._index_name,

View File

@@ -19,7 +19,8 @@ from onyx.db.document import update_docs_updated_at__no_commit
from onyx.db.document_set import fetch_document_sets_for_documents
from onyx.indexing.indexing_pipeline import DocumentBatchPrepareContext
from onyx.indexing.indexing_pipeline import index_doc_batch_prepare
from onyx.indexing.models import BuildMetadataAwareChunksResult
from onyx.indexing.models import ChunkEnrichmentContext
from onyx.indexing.models import DocAwareChunk
from onyx.indexing.models import DocMetadataAwareIndexChunk
from onyx.indexing.models import IndexChunk
from onyx.indexing.models import UpdatableChunkData
@@ -85,14 +86,21 @@ class DocumentIndexingBatchAdapter:
) as transaction:
yield transaction
def build_metadata_aware_chunks(
def prepare_enrichment(
self,
chunks_with_embeddings: list[IndexChunk],
chunk_content_scores: list[float],
tenant_id: str,
context: DocumentBatchPrepareContext,
) -> BuildMetadataAwareChunksResult:
"""Enrich chunks with access, document sets, boosts, token counts, and hierarchy."""
tenant_id: str,
chunks: list[DocAwareChunk],
) -> "DocumentChunkEnricher":
"""Do all DB lookups once and return a per-chunk enricher."""
updatable_ids = [doc.id for doc in context.updatable_docs]
doc_id_to_new_chunk_cnt: dict[str, int] = {
doc_id: 0 for doc_id in updatable_ids
}
for chunk in chunks:
if chunk.source_document.id in doc_id_to_new_chunk_cnt:
doc_id_to_new_chunk_cnt[chunk.source_document.id] += 1
no_access = DocumentAccess.build(
user_emails=[],
@@ -102,67 +110,30 @@ class DocumentIndexingBatchAdapter:
is_public=False,
)
updatable_ids = [doc.id for doc in context.updatable_docs]
doc_id_to_access_info = get_access_for_documents(
document_ids=updatable_ids, db_session=self.db_session
)
doc_id_to_document_set = {
document_id: document_sets
for document_id, document_sets in fetch_document_sets_for_documents(
return DocumentChunkEnricher(
doc_id_to_access_info=get_access_for_documents(
document_ids=updatable_ids, db_session=self.db_session
)
}
doc_id_to_previous_chunk_cnt: dict[str, int] = {
document_id: chunk_count
for document_id, chunk_count in fetch_chunk_counts_for_documents(
document_ids=updatable_ids,
db_session=self.db_session,
)
}
doc_id_to_new_chunk_cnt: dict[str, int] = {
doc_id: 0 for doc_id in updatable_ids
}
for chunk in chunks_with_embeddings:
if chunk.source_document.id in doc_id_to_new_chunk_cnt:
doc_id_to_new_chunk_cnt[chunk.source_document.id] += 1
# Get ancestor hierarchy node IDs for each document
doc_id_to_ancestor_ids = self._get_ancestor_ids_for_documents(
context.updatable_docs, tenant_id
)
access_aware_chunks = [
DocMetadataAwareIndexChunk.from_index_chunk(
index_chunk=chunk,
access=doc_id_to_access_info.get(chunk.source_document.id, no_access),
document_sets=set(
doc_id_to_document_set.get(chunk.source_document.id, [])
),
user_project=[],
personas=[],
boost=(
context.id_to_boost_map[chunk.source_document.id]
if chunk.source_document.id in context.id_to_boost_map
else DEFAULT_BOOST
),
tenant_id=tenant_id,
aggregated_chunk_boost_factor=chunk_content_scores[chunk_num],
ancestor_hierarchy_node_ids=doc_id_to_ancestor_ids[
chunk.source_document.id
],
)
for chunk_num, chunk in enumerate(chunks_with_embeddings)
]
return BuildMetadataAwareChunksResult(
chunks=access_aware_chunks,
doc_id_to_previous_chunk_cnt=doc_id_to_previous_chunk_cnt,
doc_id_to_new_chunk_cnt=doc_id_to_new_chunk_cnt,
user_file_id_to_raw_text={},
user_file_id_to_token_count={},
),
doc_id_to_document_set={
document_id: document_sets
for document_id, document_sets in fetch_document_sets_for_documents(
document_ids=updatable_ids, db_session=self.db_session
)
},
doc_id_to_ancestor_ids=self._get_ancestor_ids_for_documents(
context.updatable_docs, tenant_id
),
id_to_boost_map=context.id_to_boost_map,
doc_id_to_previous_chunk_cnt={
document_id: chunk_count
for document_id, chunk_count in fetch_chunk_counts_for_documents(
document_ids=updatable_ids,
db_session=self.db_session,
)
},
doc_id_to_new_chunk_cnt=dict(doc_id_to_new_chunk_cnt),
no_access=no_access,
tenant_id=tenant_id,
)
def _get_ancestor_ids_for_documents(
@@ -203,7 +174,7 @@ class DocumentIndexingBatchAdapter:
context: DocumentBatchPrepareContext,
updatable_chunk_data: list[UpdatableChunkData],
filtered_documents: list[Document],
result: BuildMetadataAwareChunksResult,
enrichment: ChunkEnrichmentContext,
) -> None:
"""Finalize DB updates, store plaintext, and mark docs as indexed."""
updatable_ids = [doc.id for doc in context.updatable_docs]
@@ -227,7 +198,7 @@ class DocumentIndexingBatchAdapter:
update_docs_chunk_count__no_commit(
document_ids=updatable_ids,
doc_id_to_chunk_count=result.doc_id_to_new_chunk_cnt,
doc_id_to_chunk_count=enrichment.doc_id_to_new_chunk_cnt,
db_session=self.db_session,
)
@@ -249,3 +220,52 @@ class DocumentIndexingBatchAdapter:
)
self.db_session.commit()
class DocumentChunkEnricher:
"""Pre-computed metadata for per-chunk enrichment of connector documents."""
def __init__(
self,
doc_id_to_access_info: dict[str, DocumentAccess],
doc_id_to_document_set: dict[str, list[str]],
doc_id_to_ancestor_ids: dict[str, list[int]],
id_to_boost_map: dict[str, int],
doc_id_to_previous_chunk_cnt: dict[str, int],
doc_id_to_new_chunk_cnt: dict[str, int],
no_access: DocumentAccess,
tenant_id: str,
) -> None:
self._doc_id_to_access_info = doc_id_to_access_info
self._doc_id_to_document_set = doc_id_to_document_set
self._doc_id_to_ancestor_ids = doc_id_to_ancestor_ids
self._id_to_boost_map = id_to_boost_map
self._no_access = no_access
self._tenant_id = tenant_id
self.doc_id_to_previous_chunk_cnt = doc_id_to_previous_chunk_cnt
self.doc_id_to_new_chunk_cnt = doc_id_to_new_chunk_cnt
def enrich_chunk(
self, chunk: IndexChunk, score: float
) -> DocMetadataAwareIndexChunk:
return DocMetadataAwareIndexChunk.from_index_chunk(
index_chunk=chunk,
access=self._doc_id_to_access_info.get(
chunk.source_document.id, self._no_access
),
document_sets=set(
self._doc_id_to_document_set.get(chunk.source_document.id, [])
),
user_project=[],
personas=[],
boost=(
self._id_to_boost_map[chunk.source_document.id]
if chunk.source_document.id in self._id_to_boost_map
else DEFAULT_BOOST
),
tenant_id=self._tenant_id,
aggregated_chunk_boost_factor=score,
ancestor_hierarchy_node_ids=self._doc_id_to_ancestor_ids[
chunk.source_document.id
],
)

View File

@@ -1,6 +1,9 @@
from __future__ import annotations
import contextlib
import datetime
import time
from collections import defaultdict
from collections.abc import Generator
from uuid import UUID
@@ -24,7 +27,8 @@ from onyx.db.user_file import fetch_persona_ids_for_user_files
from onyx.db.user_file import fetch_user_project_ids_for_user_files
from onyx.file_store.utils import store_user_file_plaintext
from onyx.indexing.indexing_pipeline import DocumentBatchPrepareContext
from onyx.indexing.models import BuildMetadataAwareChunksResult
from onyx.indexing.models import ChunkEnrichmentContext
from onyx.indexing.models import DocAwareChunk
from onyx.indexing.models import DocMetadataAwareIndexChunk
from onyx.indexing.models import IndexChunk
from onyx.indexing.models import UpdatableChunkData
@@ -102,13 +106,20 @@ class UserFileIndexingAdapter:
f"Failed to acquire locks after {_NUM_LOCK_ATTEMPTS} attempts for user files: {[doc.id for doc in documents]}"
)
def build_metadata_aware_chunks(
def prepare_enrichment(
self,
chunks_with_embeddings: list[IndexChunk],
chunk_content_scores: list[float],
tenant_id: str,
context: DocumentBatchPrepareContext,
) -> BuildMetadataAwareChunksResult:
tenant_id: str,
chunks: list[DocAwareChunk],
) -> UserFileChunkEnricher:
"""Do all DB lookups and pre-compute file metadata from chunks."""
updatable_ids = [doc.id for doc in context.updatable_docs]
doc_id_to_new_chunk_cnt: dict[str, int] = defaultdict(int)
content_by_file: dict[str, list[str]] = defaultdict(list)
for chunk in chunks:
doc_id_to_new_chunk_cnt[chunk.source_document.id] += 1
content_by_file[chunk.source_document.id].append(chunk.content)
no_access = DocumentAccess.build(
user_emails=[],
@@ -118,7 +129,6 @@ class UserFileIndexingAdapter:
is_public=False,
)
updatable_ids = [doc.id for doc in context.updatable_docs]
user_file_id_to_project_ids = fetch_user_project_ids_for_user_files(
user_file_ids=updatable_ids,
db_session=self.db_session,
@@ -139,17 +149,6 @@ class UserFileIndexingAdapter:
)
}
user_file_id_to_new_chunk_cnt: dict[str, int] = {
user_file_id: len(
[
chunk
for chunk in chunks_with_embeddings
if chunk.source_document.id == user_file_id
]
)
for user_file_id in updatable_ids
}
# Initialize tokenizer used for token count calculation
try:
llm = get_default_llm()
@@ -164,15 +163,9 @@ class UserFileIndexingAdapter:
user_file_id_to_raw_text: dict[str, str] = {}
user_file_id_to_token_count: dict[str, int | None] = {}
for user_file_id in updatable_ids:
user_file_chunks = [
chunk
for chunk in chunks_with_embeddings
if chunk.source_document.id == user_file_id
]
if user_file_chunks:
combined_content = " ".join(
[chunk.content for chunk in user_file_chunks]
)
contents = content_by_file.get(user_file_id)
if contents:
combined_content = " ".join(contents)
user_file_id_to_raw_text[str(user_file_id)] = combined_content
token_count: int = (
count_tokens(combined_content, llm_tokenizer)
@@ -184,28 +177,16 @@ class UserFileIndexingAdapter:
user_file_id_to_raw_text[str(user_file_id)] = ""
user_file_id_to_token_count[str(user_file_id)] = None
access_aware_chunks = [
DocMetadataAwareIndexChunk.from_index_chunk(
index_chunk=chunk,
access=user_file_id_to_access.get(chunk.source_document.id, no_access),
document_sets=set(),
user_project=user_file_id_to_project_ids.get(
chunk.source_document.id, []
),
personas=user_file_id_to_persona_ids.get(chunk.source_document.id, []),
boost=DEFAULT_BOOST,
tenant_id=tenant_id,
aggregated_chunk_boost_factor=chunk_content_scores[chunk_num],
)
for chunk_num, chunk in enumerate(chunks_with_embeddings)
]
return BuildMetadataAwareChunksResult(
chunks=access_aware_chunks,
return UserFileChunkEnricher(
user_file_id_to_access=user_file_id_to_access,
user_file_id_to_project_ids=user_file_id_to_project_ids,
user_file_id_to_persona_ids=user_file_id_to_persona_ids,
doc_id_to_previous_chunk_cnt=user_file_id_to_previous_chunk_cnt,
doc_id_to_new_chunk_cnt=user_file_id_to_new_chunk_cnt,
doc_id_to_new_chunk_cnt=dict(doc_id_to_new_chunk_cnt),
user_file_id_to_raw_text=user_file_id_to_raw_text,
user_file_id_to_token_count=user_file_id_to_token_count,
no_access=no_access,
tenant_id=tenant_id,
)
def _notify_assistant_owners_if_files_ready(
@@ -249,8 +230,9 @@ class UserFileIndexingAdapter:
context: DocumentBatchPrepareContext,
updatable_chunk_data: list[UpdatableChunkData], # noqa: ARG002
filtered_documents: list[Document], # noqa: ARG002
result: BuildMetadataAwareChunksResult,
enrichment: ChunkEnrichmentContext,
) -> None:
assert isinstance(enrichment, UserFileChunkEnricher)
user_file_ids = [doc.id for doc in context.updatable_docs]
user_files = (
@@ -266,8 +248,10 @@ class UserFileIndexingAdapter:
user_file.last_project_sync_at = datetime.datetime.now(
datetime.timezone.utc
)
user_file.chunk_count = result.doc_id_to_new_chunk_cnt[str(user_file.id)]
user_file.token_count = result.user_file_id_to_token_count[
user_file.chunk_count = enrichment.doc_id_to_new_chunk_cnt.get(
str(user_file.id), 0
)
user_file.token_count = enrichment.user_file_id_to_token_count[
str(user_file.id)
]
@@ -279,8 +263,54 @@ class UserFileIndexingAdapter:
# Store the plaintext in the file store for faster retrieval
# NOTE: this creates its own session to avoid committing the overall
# transaction.
for user_file_id, raw_text in result.user_file_id_to_raw_text.items():
for user_file_id, raw_text in enrichment.user_file_id_to_raw_text.items():
store_user_file_plaintext(
user_file_id=UUID(user_file_id),
plaintext_content=raw_text,
)
class UserFileChunkEnricher:
"""Pre-computed metadata for per-chunk enrichment of user-uploaded files."""
def __init__(
self,
user_file_id_to_access: dict[str, DocumentAccess],
user_file_id_to_project_ids: dict[str, list[int]],
user_file_id_to_persona_ids: dict[str, list[int]],
doc_id_to_previous_chunk_cnt: dict[str, int],
doc_id_to_new_chunk_cnt: dict[str, int],
user_file_id_to_raw_text: dict[str, str],
user_file_id_to_token_count: dict[str, int | None],
no_access: DocumentAccess,
tenant_id: str,
) -> None:
self._user_file_id_to_access = user_file_id_to_access
self._user_file_id_to_project_ids = user_file_id_to_project_ids
self._user_file_id_to_persona_ids = user_file_id_to_persona_ids
self._no_access = no_access
self._tenant_id = tenant_id
self.doc_id_to_previous_chunk_cnt = doc_id_to_previous_chunk_cnt
self.doc_id_to_new_chunk_cnt = doc_id_to_new_chunk_cnt
self.user_file_id_to_raw_text = user_file_id_to_raw_text
self.user_file_id_to_token_count = user_file_id_to_token_count
def enrich_chunk(
self, chunk: IndexChunk, score: float
) -> DocMetadataAwareIndexChunk:
return DocMetadataAwareIndexChunk.from_index_chunk(
index_chunk=chunk,
access=self._user_file_id_to_access.get(
chunk.source_document.id, self._no_access
),
document_sets=set(),
user_project=self._user_file_id_to_project_ids.get(
chunk.source_document.id, []
),
personas=self._user_file_id_to_persona_ids.get(
chunk.source_document.id, []
),
boost=DEFAULT_BOOST,
tenant_id=self._tenant_id,
aggregated_chunk_boost_factor=score,
)

View File

@@ -1,5 +1,7 @@
from collections import defaultdict
from collections.abc import Callable
from collections.abc import Iterable
from typing import cast
from typing import Protocol
from pydantic import BaseModel
@@ -47,6 +49,7 @@ from onyx.indexing.chunker import Chunker
from onyx.indexing.embedder import embed_chunks_with_failure_handling
from onyx.indexing.embedder import IndexingEmbedder
from onyx.indexing.models import DocAwareChunk
from onyx.indexing.models import DocMetadataAwareIndexChunk
from onyx.indexing.models import IndexingBatchAdapter
from onyx.indexing.models import UpdatableChunkData
from onyx.indexing.vector_db_insertion import write_chunks_to_vector_db_with_backoff
@@ -91,6 +94,15 @@ class IndexingPipelineResult(BaseModel):
failures: list[ConnectorFailure]
@classmethod
def empty(cls, total_docs: int) -> "IndexingPipelineResult":
return cls(
new_docs=0,
total_docs=total_docs,
total_chunks=0,
failures=[],
)
class IndexingPipelineProtocol(Protocol):
def __call__(
@@ -672,12 +684,7 @@ def index_doc_batch(
filtered_documents = filter_fnc(document_batch)
context = adapter.prepare(filtered_documents, ignore_time_skip)
if not context:
return IndexingPipelineResult(
new_docs=0,
total_docs=len(filtered_documents),
total_chunks=0,
failures=[],
)
return IndexingPipelineResult.empty(len(filtered_documents))
# Convert documents to IndexingDocument objects with processed section
# logger.debug("Processing image sections")
@@ -748,19 +755,29 @@ def index_doc_batch(
# we still write data here for the immediate and most likely correct sync, but
# to resolve this, an update of the last modified field at the end of this loop
# always triggers a final metadata sync via the celery queue
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=chunks_with_embeddings,
chunk_content_scores=chunk_content_scores,
tenant_id=tenant_id,
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=tenant_id,
chunks=cast(list[DocAwareChunk], chunks_with_embeddings),
)
short_descriptor_list = [chunk.to_short_descriptor() for chunk in result.chunks]
metadata_aware_chunks = [
enricher.enrich_chunk(chunk, score)
for chunk, score in zip(chunks_with_embeddings, chunk_content_scores)
]
short_descriptor_list = [
chunk.to_short_descriptor() for chunk in metadata_aware_chunks
]
short_descriptor_log = str(short_descriptor_list)[:1024]
logger.debug(f"Indexing the following chunks: {short_descriptor_log}")
primary_doc_idx_insertion_records: list[DocumentInsertionRecord] | None = None
primary_doc_idx_vector_db_write_failures: list[ConnectorFailure] | None = None
def chunk_iterable_creator() -> Iterable[DocMetadataAwareIndexChunk]:
return metadata_aware_chunks
for document_index in document_indices:
# A document will not be spread across different batches, so all the
# documents with chunks in this set, are fully represented by the chunks
@@ -770,10 +787,10 @@ def index_doc_batch(
vector_db_write_failures,
) = write_chunks_to_vector_db_with_backoff(
document_index=document_index,
chunks=result.chunks,
make_chunks=chunk_iterable_creator,
index_batch_params=IndexBatchParams(
doc_id_to_previous_chunk_cnt=result.doc_id_to_previous_chunk_cnt,
doc_id_to_new_chunk_cnt=result.doc_id_to_new_chunk_cnt,
doc_id_to_previous_chunk_cnt=enricher.doc_id_to_previous_chunk_cnt,
doc_id_to_new_chunk_cnt=enricher.doc_id_to_new_chunk_cnt,
tenant_id=tenant_id,
large_chunks_enabled=chunker.enable_large_chunks,
),
@@ -802,7 +819,7 @@ def index_doc_batch(
f"Updatable IDs: {updatable_ids}, "
f"Returned IDs: {all_returned_doc_ids}. "
"This should never happen."
f"This occured for document index {document_index.__class__.__name__}"
f"This occurred for document index {document_index.__class__.__name__}"
)
# We treat the first document index we got as the primary one used
# for reporting the state of indexing.
@@ -815,7 +832,7 @@ def index_doc_batch(
context=context,
updatable_chunk_data=updatable_chunk_data,
filtered_documents=filtered_documents,
result=result,
enrichment=enricher,
)
assert primary_doc_idx_insertion_records is not None

View File

@@ -235,12 +235,16 @@ class UpdatableChunkData(BaseModel):
boost_score: float
class BuildMetadataAwareChunksResult(BaseModel):
chunks: list[DocMetadataAwareIndexChunk]
class ChunkEnrichmentContext(Protocol):
"""Returned by prepare_enrichment. Holds pre-computed metadata lookups
and provides per-chunk enrichment."""
doc_id_to_previous_chunk_cnt: dict[str, int]
doc_id_to_new_chunk_cnt: dict[str, int]
user_file_id_to_raw_text: dict[str, str]
user_file_id_to_token_count: dict[str, int | None]
def enrich_chunk(
self, chunk: IndexChunk, score: float
) -> DocMetadataAwareIndexChunk: ...
class IndexingBatchAdapter(Protocol):
@@ -254,18 +258,24 @@ class IndexingBatchAdapter(Protocol):
) -> Generator[TransactionalContext, None, None]:
"""Provide a transaction/row-lock context for critical updates."""
def build_metadata_aware_chunks(
def prepare_enrichment(
self,
chunks_with_embeddings: list[IndexChunk],
chunk_content_scores: list[float],
tenant_id: str,
context: "DocumentBatchPrepareContext",
) -> BuildMetadataAwareChunksResult: ...
tenant_id: str,
chunks: list[DocAwareChunk],
) -> ChunkEnrichmentContext:
"""Prepare per-chunk enrichment data (access, document sets, boost, etc.).
Precondition: ``chunks`` have already been through the embedding step
(i.e. they are ``IndexChunk`` instances with populated embeddings,
passed here as the base ``DocAwareChunk`` type).
"""
...
def post_index(
self,
context: "DocumentBatchPrepareContext",
updatable_chunk_data: list[UpdatableChunkData],
filtered_documents: list[Document],
result: BuildMetadataAwareChunksResult,
enrichment: ChunkEnrichmentContext,
) -> None: ...

View File

@@ -1,6 +1,9 @@
import time
from collections import defaultdict
from collections.abc import Callable
from collections.abc import Iterable
from http import HTTPStatus
from itertools import chain
from itertools import groupby
import httpx
@@ -28,22 +31,22 @@ def _log_insufficient_storage_error(e: Exception) -> None:
def write_chunks_to_vector_db_with_backoff(
document_index: DocumentIndex,
chunks: list[DocMetadataAwareIndexChunk],
make_chunks: Callable[[], Iterable[DocMetadataAwareIndexChunk]],
index_batch_params: IndexBatchParams,
) -> tuple[list[DocumentInsertionRecord], list[ConnectorFailure]]:
"""Tries to insert all chunks in one large batch. If that batch fails for any reason,
goes document by document to isolate the failure(s).
IMPORTANT: must pass in whole documents at a time not individual chunks, since the
vector DB interface assumes that all chunks for a single document are present.
vector DB interface assumes that all chunks for a single document are present. The
chunks must also be in contiguous batches
"""
# first try to write the chunks to the vector db
try:
return (
list(
document_index.index(
chunks=chunks,
chunks=make_chunks(),
index_batch_params=index_batch_params,
)
),
@@ -60,14 +63,23 @@ def write_chunks_to_vector_db_with_backoff(
# wait a couple seconds just to give the vector db a chance to recover
time.sleep(2)
# try writing each doc one by one
chunks_for_docs: dict[str, list[DocMetadataAwareIndexChunk]] = defaultdict(list)
for chunk in chunks:
chunks_for_docs[chunk.source_document.id].append(chunk)
insertion_records: list[DocumentInsertionRecord] = []
failures: list[ConnectorFailure] = []
for doc_id, chunks_for_doc in chunks_for_docs.items():
def key(chunk: DocMetadataAwareIndexChunk) -> str:
return chunk.source_document.id
seen_doc_ids: set[str] = set()
for doc_id, chunks_for_doc in groupby(make_chunks(), key=key):
if doc_id in seen_doc_ids:
raise RuntimeError(
f"Doc chunks are not arriving in order. Current doc_id={doc_id}, seen_doc_ids={list(seen_doc_ids)}"
)
seen_doc_ids.add(doc_id)
first_chunk = next(chunks_for_doc)
chunks_for_doc = chain([first_chunk], chunks_for_doc)
try:
insertion_records.extend(
document_index.index(
@@ -87,9 +99,7 @@ def write_chunks_to_vector_db_with_backoff(
ConnectorFailure(
failed_document=DocumentFailure(
document_id=doc_id,
document_link=(
chunks_for_doc[0].get_link() if chunks_for_doc else None
),
document_link=first_chunk.get_link(),
),
failure_message=str(e),
exception=e,

View File

@@ -153,15 +153,13 @@ class TestAdapterWritesBothMetadataFields:
doc = chunk.source_document
context = DocumentBatchPrepareContext(updatable_docs=[doc], id_to_boost_map={})
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id=TEST_TENANT_ID,
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=TEST_TENANT_ID,
chunks=[chunk],
)
aware_chunk = enricher.enrich_chunk(chunk, 1.0)
assert len(result.chunks) == 1
aware_chunk = result.chunks[0]
assert persona.id in aware_chunk.personas
assert aware_chunk.user_project == []
@@ -190,15 +188,13 @@ class TestAdapterWritesBothMetadataFields:
updatable_docs=[chunk.source_document], id_to_boost_map={}
)
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id=TEST_TENANT_ID,
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=TEST_TENANT_ID,
chunks=[chunk],
)
aware_chunk = enricher.enrich_chunk(chunk, 1.0)
assert len(result.chunks) == 1
aware_chunk = result.chunks[0]
assert project.id in aware_chunk.user_project
assert aware_chunk.personas == []
@@ -229,14 +225,13 @@ class TestAdapterWritesBothMetadataFields:
updatable_docs=[chunk.source_document], id_to_boost_map={}
)
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id=TEST_TENANT_ID,
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=TEST_TENANT_ID,
chunks=[chunk],
)
aware_chunk = enricher.enrich_chunk(chunk, 1.0)
aware_chunk = result.chunks[0]
assert persona.id in aware_chunk.personas
assert project.id in aware_chunk.user_project
@@ -261,14 +256,13 @@ class TestAdapterWritesBothMetadataFields:
updatable_docs=[chunk.source_document], id_to_boost_map={}
)
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id=TEST_TENANT_ID,
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=TEST_TENANT_ID,
chunks=[chunk],
)
aware_chunk = enricher.enrich_chunk(chunk, 1.0)
aware_chunk = result.chunks[0]
assert aware_chunk.personas == []
assert aware_chunk.user_project == []
@@ -300,12 +294,11 @@ class TestAdapterWritesBothMetadataFields:
updatable_docs=[chunk.source_document], id_to_boost_map={}
)
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id=TEST_TENANT_ID,
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=TEST_TENANT_ID,
chunks=[chunk],
)
aware_chunk = enricher.enrich_chunk(chunk, 1.0)
aware_chunk = result.chunks[0]
assert set(aware_chunk.personas) == {persona_a.id, persona_b.id}

View File

@@ -0,0 +1,223 @@
from unittest.mock import MagicMock
from unittest.mock import patch
from onyx.access.models import DocumentAccess
from onyx.configs.constants import DocumentSource
from onyx.connectors.models import Document
from onyx.connectors.models import TextSection
from onyx.document_index.interfaces_new import IndexingMetadata
from onyx.document_index.interfaces_new import TenantState
from onyx.document_index.opensearch.opensearch_document_index import (
OpenSearchDocumentIndex,
)
from onyx.indexing.models import ChunkEmbedding
from onyx.indexing.models import DocMetadataAwareIndexChunk
def _make_chunk(
doc_id: str,
chunk_id: int,
) -> DocMetadataAwareIndexChunk:
"""Creates a minimal DocMetadataAwareIndexChunk for testing."""
doc = Document(
id=doc_id,
sections=[TextSection(text="test", link="http://test.com")],
source=DocumentSource.FILE,
semantic_identifier="test_doc",
metadata={},
)
access = DocumentAccess.build(
user_emails=[],
user_groups=[],
external_user_emails=[],
external_user_group_ids=[],
is_public=True,
)
return DocMetadataAwareIndexChunk(
chunk_id=chunk_id,
blurb="test",
content="test content",
source_links={0: "http://test.com"},
image_file_id=None,
section_continuation=False,
source_document=doc,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
mini_chunk_texts=None,
large_chunk_id=None,
doc_summary="",
chunk_context="",
contextual_rag_reserved_tokens=0,
embeddings=ChunkEmbedding(full_embedding=[0.1] * 10, mini_chunk_embeddings=[]),
title_embedding=[0.1] * 10,
tenant_id="test_tenant",
access=access,
document_sets=set(),
user_project=[],
personas=[],
boost=0,
aggregated_chunk_boost_factor=1.0,
ancestor_hierarchy_node_ids=[],
)
def _make_index() -> tuple[OpenSearchDocumentIndex, MagicMock]:
"""Creates an OpenSearchDocumentIndex with a mocked client.
Returns the index and the mock for bulk_index_documents."""
mock_client = MagicMock()
mock_bulk = MagicMock()
mock_client.bulk_index_documents = mock_bulk
tenant_state = TenantState(tenant_id="test_tenant", multitenant=False)
index = OpenSearchDocumentIndex.__new__(OpenSearchDocumentIndex)
index._index_name = "test_index"
index._client = mock_client
index._tenant_state = tenant_state
return index, mock_bulk
def _make_metadata(doc_id: str, chunk_count: int) -> IndexingMetadata:
return IndexingMetadata(
doc_id_to_chunk_cnt_diff={
doc_id: IndexingMetadata.ChunkCounts(
old_chunk_cnt=0,
new_chunk_cnt=chunk_count,
),
},
)
@patch(
"onyx.document_index.opensearch.opensearch_document_index.MAX_CHUNKS_PER_DOC_BATCH",
100,
)
def test_single_doc_under_batch_limit_flushes_once() -> None:
"""A document with fewer chunks than MAX_CHUNKS_PER_DOC_BATCH should flush once."""
index, mock_bulk = _make_index()
doc_id = "doc_1"
num_chunks = 50
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
assert mock_bulk.call_count == 1
batch_arg = mock_bulk.call_args_list[0]
assert len(batch_arg.kwargs["documents"]) == num_chunks
@patch(
"onyx.document_index.opensearch.opensearch_document_index.MAX_CHUNKS_PER_DOC_BATCH",
100,
)
def test_single_doc_over_batch_limit_flushes_multiple_times() -> None:
"""A document with more chunks than MAX_CHUNKS_PER_DOC_BATCH should flush multiple times."""
index, mock_bulk = _make_index()
doc_id = "doc_1"
num_chunks = 250
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
# 250 chunks / 100 per batch = 3 flushes (100 + 100 + 50)
assert mock_bulk.call_count == 3
batch_sizes = [len(call.kwargs["documents"]) for call in mock_bulk.call_args_list]
assert batch_sizes == [100, 100, 50]
@patch(
"onyx.document_index.opensearch.opensearch_document_index.MAX_CHUNKS_PER_DOC_BATCH",
100,
)
def test_single_doc_exactly_at_batch_limit() -> None:
"""A document with exactly MAX_CHUNKS_PER_DOC_BATCH chunks should flush once
(the flush happens on the next chunk, not at the boundary)."""
index, mock_bulk = _make_index()
doc_id = "doc_1"
num_chunks = 100
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
# 100 chunks hit the >= check on chunk 101 which doesn't exist,
# so final flush handles all 100
# Actually: the elif fires when len(current_chunks) >= 100, which happens
# when current_chunks has 100 items and the 101st chunk arrives.
# With exactly 100 chunks, the 100th chunk makes len == 99, then appended -> 100.
# No 101st chunk arrives, so the final flush handles all 100.
assert mock_bulk.call_count == 1
@patch(
"onyx.document_index.opensearch.opensearch_document_index.MAX_CHUNKS_PER_DOC_BATCH",
100,
)
def test_single_doc_one_over_batch_limit() -> None:
"""101 chunks for one doc: first 100 flushed when the 101st arrives, then
the 101st is flushed at the end."""
index, mock_bulk = _make_index()
doc_id = "doc_1"
num_chunks = 101
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
assert mock_bulk.call_count == 2
batch_sizes = [len(call.kwargs["documents"]) for call in mock_bulk.call_args_list]
assert batch_sizes == [100, 1]
@patch(
"onyx.document_index.opensearch.opensearch_document_index.MAX_CHUNKS_PER_DOC_BATCH",
100,
)
def test_multiple_docs_each_under_limit_flush_per_doc() -> None:
"""Multiple documents each under the batch limit should flush once per document."""
index, mock_bulk = _make_index()
chunks = []
for doc_idx in range(3):
doc_id = f"doc_{doc_idx}"
for chunk_idx in range(50):
chunks.append(_make_chunk(doc_id, chunk_idx))
metadata = IndexingMetadata(
doc_id_to_chunk_cnt_diff={
f"doc_{i}": IndexingMetadata.ChunkCounts(old_chunk_cnt=0, new_chunk_cnt=50)
for i in range(3)
},
)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
# 3 documents = 3 flushes (one per doc boundary + final)
assert mock_bulk.call_count == 3
@patch(
"onyx.document_index.opensearch.opensearch_document_index.MAX_CHUNKS_PER_DOC_BATCH",
100,
)
def test_delete_called_once_per_document() -> None:
"""Even with multiple flushes for a single document, delete should only be
called once per document."""
index, _mock_bulk = _make_index()
doc_id = "doc_1"
num_chunks = 250
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0) as mock_delete:
index.index(chunks, metadata)
mock_delete.assert_called_once_with(doc_id, None)

View File

@@ -0,0 +1,152 @@
"""Unit tests for VespaDocumentIndex.index().
These tests mock all external I/O (HTTP calls, thread pools) and verify
the streaming logic, ID cleaning/mapping, and DocumentInsertionRecord
construction.
"""
from unittest.mock import MagicMock
from unittest.mock import patch
from onyx.access.models import DocumentAccess
from onyx.configs.constants import DocumentSource
from onyx.connectors.models import Document
from onyx.connectors.models import TextSection
from onyx.document_index.interfaces import EnrichedDocumentIndexingInfo
from onyx.document_index.interfaces_new import IndexingMetadata
from onyx.document_index.interfaces_new import TenantState
from onyx.document_index.vespa.vespa_document_index import VespaDocumentIndex
from onyx.indexing.models import ChunkEmbedding
from onyx.indexing.models import DocMetadataAwareIndexChunk
from onyx.indexing.models import IndexChunk
def _make_chunk(
doc_id: str,
chunk_id: int = 0,
content: str = "test content",
) -> DocMetadataAwareIndexChunk:
doc = Document(
id=doc_id,
semantic_identifier="test_doc",
sections=[TextSection(text=content, link=None)],
source=DocumentSource.NOT_APPLICABLE,
metadata={},
)
index_chunk = IndexChunk(
chunk_id=chunk_id,
blurb=content[:50],
content=content,
source_links=None,
image_file_id=None,
section_continuation=False,
source_document=doc,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
contextual_rag_reserved_tokens=0,
doc_summary="",
chunk_context="",
mini_chunk_texts=None,
large_chunk_id=None,
embeddings=ChunkEmbedding(
full_embedding=[0.1] * 10,
mini_chunk_embeddings=[],
),
title_embedding=None,
)
access = DocumentAccess.build(
user_emails=[],
user_groups=[],
external_user_emails=[],
external_user_group_ids=[],
is_public=True,
)
return DocMetadataAwareIndexChunk.from_index_chunk(
index_chunk=index_chunk,
access=access,
document_sets=set(),
user_project=[],
personas=[],
boost=0,
aggregated_chunk_boost_factor=1.0,
tenant_id="test_tenant",
)
def _make_indexing_metadata(
doc_ids: list[str],
old_counts: list[int],
new_counts: list[int],
) -> IndexingMetadata:
return IndexingMetadata(
doc_id_to_chunk_cnt_diff={
doc_id: IndexingMetadata.ChunkCounts(
old_chunk_cnt=old,
new_chunk_cnt=new,
)
for doc_id, old, new in zip(doc_ids, old_counts, new_counts)
}
)
def _stub_enrich(
doc_id: str,
old_chunk_cnt: int,
) -> EnrichedDocumentIndexingInfo:
"""Build an EnrichedDocumentIndexingInfo that says 'no chunks to delete'
when old_chunk_cnt == 0, or 'has existing chunks' otherwise."""
return EnrichedDocumentIndexingInfo(
doc_id=doc_id,
chunk_start_index=0,
old_version=False,
chunk_end_index=old_chunk_cnt,
)
@patch("onyx.document_index.vespa.vespa_document_index.batch_index_vespa_chunks")
@patch("onyx.document_index.vespa.vespa_document_index.delete_vespa_chunks")
@patch(
"onyx.document_index.vespa.vespa_document_index.get_document_chunk_ids",
return_value=[],
)
@patch("onyx.document_index.vespa.vespa_document_index._enrich_basic_chunk_info")
@patch(
"onyx.document_index.vespa.vespa_document_index.BATCH_SIZE",
3,
)
def test_index_respects_batch_size(
mock_enrich: MagicMock,
mock_get_chunk_ids: MagicMock, # noqa: ARG001
mock_delete: MagicMock, # noqa: ARG001
mock_batch_index: MagicMock,
) -> None:
"""When chunks exceed BATCH_SIZE, batch_index_vespa_chunks is called
multiple times with correctly sized batches."""
mock_enrich.return_value = _stub_enrich("doc1", old_chunk_cnt=0)
index = VespaDocumentIndex(
index_name="test_index",
tenant_state=TenantState(tenant_id="test_tenant", multitenant=False),
large_chunks_enabled=False,
httpx_client=MagicMock(),
)
chunks = [_make_chunk("doc1", chunk_id=i) for i in range(7)]
metadata = _make_indexing_metadata(["doc1"], old_counts=[0], new_counts=[7])
results = index.index(chunks=chunks, indexing_metadata=metadata)
assert len(results) == 1
# With BATCH_SIZE=3 and 7 chunks: batches of 3, 3, 1
assert mock_batch_index.call_count == 3
batch_sizes = [len(c.kwargs["chunks"]) for c in mock_batch_index.call_args_list]
assert batch_sizes == [3, 3, 1]
# Verify all chunks are accounted for and in order
all_indexed = [
chunk for c in mock_batch_index.call_args_list for chunk in c.kwargs["chunks"]
]
assert len(all_indexed) == 7
assert [c.chunk_id for c in all_indexed] == list(range(7))

View File

@@ -116,7 +116,7 @@ def _run_adapter_build(
project_ids_map: dict[str, list[int]],
persona_ids_map: dict[str, list[int]],
) -> list[DocMetadataAwareIndexChunk]:
"""Helper that runs UserFileIndexingAdapter.build_metadata_aware_chunks
"""Helper that runs UserFileIndexingAdapter.prepare_enrichment + enrich_chunk
with all external dependencies mocked."""
from onyx.indexing.adapters.user_file_indexing_adapter import (
UserFileIndexingAdapter,
@@ -155,14 +155,12 @@ def _run_adapter_build(
side_effect=Exception("no LLM in tests"),
),
):
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id="test_tenant",
enricher = adapter.prepare_enrichment(
context=context,
tenant_id="test_tenant",
chunks=[chunk],
)
return result.chunks
return [enricher.enrich_chunk(chunk, 1.0)]
def test_build_metadata_aware_chunks_includes_persona_ids() -> None:

View File

@@ -104,7 +104,7 @@ function Button({
isLarge ? "default" : size === "2xs" ? "mini" : "compact"
}
>
<div className="flex flex-row items-center gap-1 interactive-foreground">
<div className="flex flex-row items-center gap-1">
{iconWrapper(Icon, size, !!children)}
{labelEl}

View File

@@ -67,7 +67,7 @@ function FilterButton({
state={active ? "selected" : "empty"}
>
<Interactive.Container type="button">
<div className="interactive-foreground flex flex-row items-center gap-1">
<div className="flex flex-row items-center gap-1">
{iconWrapper(Icon, "lg", true)}
<Text font="main-ui-action" color="inherit" nowrap>
{children}

View File

@@ -16,7 +16,7 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
type ContentPassthroughProps = DistributiveOmit<
ContentActionProps,
"paddingVariant" | "widthVariant" | "ref" | "withInteractive"
"paddingVariant" | "widthVariant" | "ref"
>;
type LineItemButtonOwnProps = Pick<
@@ -92,7 +92,6 @@ function LineItemButton({
>
<ContentAction
{...(contentActionProps as ContentActionProps)}
withInteractive
paddingVariant="fit"
/>
</Interactive.Container>

View File

@@ -132,7 +132,7 @@ function OpenButton({
>
<div
className={cn(
"interactive-foreground flex flex-row items-center",
"flex flex-row items-center",
justifyContent === "between" ? "w-full justify-between" : "gap-1",
foldable &&
justifyContent !== "between" &&

View File

@@ -1,3 +1,5 @@
"use client";
import "@opal/components/buttons/select-button/styles.css";
import {
Interactive,
@@ -84,11 +86,13 @@ function SelectButton({
const isLarge = size === "lg";
const labelEl = children ? (
<span className="opal-select-button-label">
<Text font={isLarge ? "main-ui-body" : "secondary-body"} color="inherit">
{children}
</Text>
</span>
<Text
font={isLarge ? "main-ui-body" : "secondary-body"}
color="inherit"
nowrap
>
{children}
</Text>
) : null;
const button = (
@@ -103,7 +107,7 @@ function SelectButton({
>
<div
className={cn(
"opal-select-button interactive-foreground",
"opal-select-button",
foldable && "interactive-foldable-host"
)}
>

View File

@@ -3,7 +3,3 @@
.opal-select-button {
@apply flex flex-row items-center gap-1;
}
.opal-select-button-label {
@apply whitespace-nowrap;
}

View File

@@ -390,14 +390,12 @@ function PaginationCount({
<span className={textClasses(size, "muted")}>of</span>
{totalItems}
{units && (
<span className="ml-1">
<Text
color="inherit"
font={size === "sm" ? "secondary-body" : "main-ui-muted"}
>
{units}
</Text>
</span>
<Text
color="inherit"
font={size === "sm" ? "secondary-body" : "main-ui-muted"}
>
{units}
</Text>
)}
</span>

View File

@@ -1,5 +1,4 @@
import "@opal/components/tag/styles.css";
import type { IconFunctionComponent, RichStr } from "@opal/types";
import { Text } from "@opal/components";
import { cn } from "@opal/utils";
@@ -46,20 +45,22 @@ function Tag({ icon: Icon, title, color = "gray", size = "sm" }: TagProps) {
const config = COLOR_CONFIG[color];
return (
<div className={cn("opal-auxiliary-tag", config.bg)} data-size={size}>
<div
className={cn("opal-auxiliary-tag", config.bg, config.text)}
data-size={size}
>
{Icon && (
<div className="opal-auxiliary-tag-icon-container">
<Icon className={cn("opal-auxiliary-tag-icon", config.text)} />
</div>
)}
<span className={cn("opal-auxiliary-tag-title px-[2px]", config.text)}>
<Text
font={size === "md" ? "secondary-body" : "figure-small-value"}
color="inherit"
>
{title}
</Text>
</span>
<Text
font={size === "md" ? "secondary-body" : "figure-small-value"}
color="inherit"
nowrap
>
{title}
</Text>
</div>
);
}

View File

@@ -13,7 +13,9 @@ const SAFE_PROTOCOL = /^https?:|^mailto:|^tel:/i;
const ALLOWED_ELEMENTS = ["p", "br", "a", "strong", "em", "code", "del"];
const INLINE_COMPONENTS = {
p: ({ children }: { children?: ReactNode }) => <>{children}</>,
p: ({ children }: { children?: ReactNode }) => (
<span className="block">{children}</span>
),
a: ({ children, href }: { children?: ReactNode; href?: string }) => {
if (!href || !SAFE_PROTOCOL.test(href)) {
return <>{children}</>;

View File

@@ -125,6 +125,7 @@ function Text({
...rest
}: TextProps) {
const resolvedClassName = cn(
"px-[2px]",
FONT_CONFIG[font],
COLOR_CONFIG[color],
nowrap && "whitespace-nowrap",

View File

@@ -18,9 +18,11 @@ export {
import { InteractiveStateless } from "@opal/core/interactive/stateless/components";
import { InteractiveStateful } from "@opal/core/interactive/stateful/components";
import { InteractiveContainer } from "@opal/core/interactive/container/components";
import { InteractiveSimple } from "@opal/core/interactive/simple/components";
import { Foldable } from "@opal/core/interactive/foldable/components";
const Interactive = {
Simple: InteractiveSimple,
Stateless: InteractiveStateless,
Stateful: InteractiveStateful,
Container: InteractiveContainer,
@@ -50,3 +52,5 @@ export type {
} from "@opal/core/interactive/container/components";
export type { FoldableProps } from "@opal/core/interactive/foldable/components";
export type { InteractiveSimpleProps } from "@opal/core/interactive/simple/components";

View File

@@ -9,7 +9,6 @@ const VARIANT_PROMINENCE_MAP: Record<string, string[]> = {
default: ["primary", "secondary", "tertiary", "internal"],
action: ["primary", "secondary", "tertiary", "internal"],
danger: ["primary", "secondary", "tertiary", "internal"],
none: [],
};
const SIZE_VARIANTS = ["lg", "md", "sm", "xs", "2xs", "fit"] as const;
@@ -43,7 +42,7 @@ export const Default: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Secondary</span>
<span>Secondary</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -53,7 +52,7 @@ export const Default: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Primary</span>
<span>Primary</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -63,7 +62,7 @@ export const Default: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Tertiary</span>
<span>Tertiary</span>
</Interactive.Container>
</Interactive.Stateless>
</div>
@@ -115,9 +114,7 @@ export const VariantMatrix: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">
{prominence}
</span>
<span>{prominence}</span>
</Interactive.Container>
</Interactive.Stateless>
<span
@@ -150,7 +147,7 @@ export const Sizes: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border heightVariant={size}>
<span className="interactive-foreground">{size}</span>
<span>{size}</span>
</Interactive.Container>
</Interactive.Stateless>
))}
@@ -168,7 +165,7 @@ export const WidthFull: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border widthVariant="full">
<span className="interactive-foreground">Full width container</span>
<span>Full width container</span>
</Interactive.Container>
</Interactive.Stateless>
</div>
@@ -187,7 +184,7 @@ export const Rounding: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border roundingVariant={rounding}>
<span className="interactive-foreground">{rounding}</span>
<span>{rounding}</span>
</Interactive.Container>
</Interactive.Stateless>
))}
@@ -207,7 +204,7 @@ export const DisabledStory: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Disabled</span>
<span>Disabled</span>
</Interactive.Container>
</Interactive.Stateless>
</Disabled>
@@ -218,7 +215,7 @@ export const DisabledStory: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Enabled</span>
<span>Enabled</span>
</Interactive.Container>
</Interactive.Stateless>
</div>
@@ -236,7 +233,7 @@ export const Interaction: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Forced hover</span>
<span>Forced hover</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -247,7 +244,7 @@ export const Interaction: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Forced active</span>
<span>Forced active</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -257,7 +254,7 @@ export const Interaction: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Normal (rest)</span>
<span>Normal (rest)</span>
</Interactive.Container>
</Interactive.Stateless>
</div>
@@ -274,7 +271,7 @@ export const WithBorder: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">With border</span>
<span>With border</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -284,7 +281,7 @@ export const WithBorder: StoryObj = {
onClick={() => {}}
>
<Interactive.Container>
<span className="interactive-foreground">Without border</span>
<span>Without border</span>
</Interactive.Container>
</Interactive.Stateless>
</div>
@@ -296,7 +293,7 @@ export const AsLink: StoryObj = {
render: () => (
<Interactive.Stateless variant="action" href="/settings">
<Interactive.Container border>
<span className="interactive-foreground">Go to Settings</span>
<span>Go to Settings</span>
</Interactive.Container>
</Interactive.Stateless>
),
@@ -312,7 +309,7 @@ export const SelectVariant: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Selected (light)</span>
<span>Selected (light)</span>
</Interactive.Container>
</Interactive.Stateful>
@@ -322,7 +319,7 @@ export const SelectVariant: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Unselected (light)</span>
<span>Unselected (light)</span>
</Interactive.Container>
</Interactive.Stateful>
@@ -332,7 +329,7 @@ export const SelectVariant: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Selected (heavy)</span>
<span>Selected (heavy)</span>
</Interactive.Container>
</Interactive.Stateful>
@@ -342,7 +339,7 @@ export const SelectVariant: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Unselected (heavy)</span>
<span>Unselected (heavy)</span>
</Interactive.Container>
</Interactive.Stateful>
</div>

View File

@@ -21,9 +21,10 @@
--interactive-easing: ease-in-out;
}
/* Base interactive surface */
/* Base interactive surface — sets color directly so all descendants inherit. */
.interactive {
@apply cursor-pointer select-none;
color: var(--interactive-foreground);
transition:
background-color var(--interactive-duration) var(--interactive-easing),
--interactive-foreground var(--interactive-duration)
@@ -43,14 +44,8 @@
@apply border;
}
/* Utility classes — descendants opt in to parent-controlled foreground color.
No transition here — the parent interpolates the variables directly. */
.interactive-foreground {
color: var(--interactive-foreground);
}
/* Icon foreground — reads from --interactive-foreground-icon, which defaults
to --interactive-foreground via the variant CSS rules. */
/* Icon foreground — reads from --interactive-foreground-icon, which may differ
from --interactive-foreground (e.g. muted icons beside normal text). */
.interactive-foreground-icon {
color: var(--interactive-foreground-icon);
}

View File

@@ -0,0 +1,100 @@
import React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@opal/utils";
import { useDisabled } from "@opal/core/disabled/components";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface InteractiveSimpleProps
extends Omit<
React.HTMLAttributes<HTMLElement>,
"className" | "style" | "color"
> {
ref?: React.Ref<HTMLElement>;
/**
* Tailwind group class (e.g. `"group/Card"`) for `group-hover:*` utilities.
*/
group?: string;
/**
* URL to navigate to when clicked. Passed through Slot to the child.
*/
href?: string;
/**
* Link target (e.g. `"_blank"`). Only used when `href` is provided.
*/
target?: string;
}
// ---------------------------------------------------------------------------
// InteractiveSimple
// ---------------------------------------------------------------------------
/**
* Minimal interactive surface primitive.
*
* Provides cursor styling, click handling, disabled integration, and
* optional link/group support — but **no color or background styling**.
*
* Use this for elements that need interactivity (click, cursor, disabled)
* without participating in the Interactive color system.
*
* Uses Radix `Slot` — merges props onto a single child element without
* adding any DOM node.
*
* @example
* ```tsx
* <Interactive.Simple onClick={handleClick} group="group/Card">
* <Card>...</Card>
* </Interactive.Simple>
* ```
*/
function InteractiveSimple({
ref,
group,
href,
target,
...props
}: InteractiveSimpleProps) {
const { isDisabled, allowClick } = useDisabled();
const classes = cn(
"cursor-pointer select-none",
isDisabled && "cursor-not-allowed",
!props.onClick && !href && "!cursor-default !select-auto",
group
);
const { onClick, ...slotProps } = props;
const linkAttrs = href
? {
href: isDisabled ? undefined : href,
target,
rel: target === "_blank" ? "noopener noreferrer" : undefined,
}
: {};
return (
<Slot
ref={ref}
className={classes}
aria-disabled={isDisabled || undefined}
{...linkAttrs}
{...slotProps}
onClick={
isDisabled && !allowClick
? href
? (e: React.MouseEvent) => e.preventDefault()
: undefined
: onClick
}
/>
);
}
export { InteractiveSimple, type InteractiveSimpleProps };

View File

@@ -15,7 +15,8 @@ type InteractiveStatefulVariant =
| "select-heavy"
| "select-tinted"
| "select-filter"
| "sidebar";
| "sidebar-heavy"
| "sidebar-light";
type InteractiveStatefulState = "empty" | "filled" | "selected";
type InteractiveStatefulInteraction = "rest" | "hover" | "active";
@@ -33,7 +34,8 @@ interface InteractiveStatefulProps
* - `"select-heavy"` — tinted selected background (for list rows, model pickers)
* - `"select-tinted"` — like select-heavy but with a tinted rest background
* - `"select-filter"` — like select-tinted for empty/filled; selected state uses inverted tint backgrounds and inverted text (for filter buttons)
* - `"sidebar"` — for sidebar navigation items
* - `"sidebar-heavy"` — sidebar navigation items: muted when unselected (text-03/text-02), bold when selected (text-04/text-03)
* - `"sidebar-light"` — sidebar navigation items: uniformly muted across all states (text-02/text-02)
*
* @default "select-heavy"
*/

View File

@@ -392,49 +392,115 @@
}
/* ===========================================================================
Sidebar
Sidebar-Heavy
Not selected: muted (text-03 / icon text-02)
Selected: default (text-04 / icon text-03)
=========================================================================== */
/* ---------------------------------------------------------------------------
Sidebar — Empty
Sidebar-Heavy — Empty
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar"][data-interactive-state="empty"] {
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="empty"] {
@apply bg-transparent;
--interactive-foreground: var(--text-03);
--interactive-foreground-icon: var(--text-02);
}
.interactive[data-interactive-variant="sidebar"][data-interactive-state="empty"]:hover:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="empty"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar"][data-interactive-state="empty"][data-interaction="hover"]:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="empty"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;
}
/* ---------------------------------------------------------------------------
Sidebar — Filled
Sidebar-Heavy — Filled
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar"][data-interactive-state="filled"] {
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="filled"] {
@apply bg-transparent;
--interactive-foreground: var(--text-03);
--interactive-foreground-icon: var(--text-02);
}
.interactive[data-interactive-variant="sidebar"][data-interactive-state="filled"]:hover:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="filled"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar"][data-interactive-state="filled"][data-interaction="hover"]:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="filled"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;
}
/* ---------------------------------------------------------------------------
Sidebar — Selected
Sidebar-Heavy — Selected
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar"][data-interactive-state="selected"] {
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="selected"] {
@apply bg-background-tint-00;
--interactive-foreground: var(--text-04);
--interactive-foreground-icon: var(--text-03);
}
.interactive[data-interactive-variant="sidebar"][data-interactive-state="selected"]:hover:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="selected"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar"][data-interactive-state="selected"][data-interaction="hover"]:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="selected"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;
}
/* ===========================================================================
Sidebar-Light
All states: prominence="muted-2x" colors (text-02 / icon text-02)
=========================================================================== */
/* ---------------------------------------------------------------------------
Sidebar-Light — Empty
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="empty"] {
@apply bg-transparent;
--interactive-foreground: var(--text-02);
--interactive-foreground-icon: var(--text-02);
}
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="empty"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="empty"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;
}
/* ---------------------------------------------------------------------------
Sidebar-Light — Filled
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="filled"] {
@apply bg-transparent;
--interactive-foreground: var(--text-02);
--interactive-foreground-icon: var(--text-02);
}
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="filled"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="filled"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;
}
/* ---------------------------------------------------------------------------
Sidebar-Light — Selected
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="selected"] {
@apply bg-background-tint-00;
--interactive-foreground: var(--text-02);
--interactive-foreground-icon: var(--text-02);
}
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="selected"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="selected"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;

View File

@@ -10,7 +10,7 @@ import type { ButtonType, WithoutStyles } from "@opal/types";
// Types
// ---------------------------------------------------------------------------
type InteractiveStatelessVariant = "none" | "default" | "action" | "danger";
type InteractiveStatelessVariant = "default" | "action" | "danger";
type InteractiveStatelessProminence =
| "primary"
| "secondary"
@@ -108,8 +108,8 @@ function InteractiveStateless({
);
const dataAttrs = {
"data-interactive-variant": variant !== "none" ? variant : undefined,
"data-interactive-prominence": variant !== "none" ? prominence : undefined,
"data-interactive-variant": variant,
"data-interactive-prominence": prominence,
"data-interaction": interaction !== "rest" ? interaction : undefined,
"data-disabled": isDisabled ? "true" : undefined,
"aria-disabled": isDisabled || undefined,

View File

@@ -4,10 +4,8 @@ import { Button } from "@opal/components/buttons/button/components";
import type { ContainerSizeVariants } from "@opal/types";
import SvgEdit from "@opal/icons/edit";
import type { IconFunctionComponent, RichStr } from "@opal/types";
import {
resolveStr,
toPlainString,
} from "@opal/components/text/InlineMarkdown";
import { Text, type TextFont } from "@opal/components/text/components";
import { toPlainString } from "@opal/components/text/InlineMarkdown";
import { cn } from "@opal/utils";
import { useState } from "react";
@@ -24,8 +22,8 @@ interface ContentLgPresetConfig {
iconContainerPadding: string;
/** Gap between icon container and content (CSS value). */
gap: string;
/** Tailwind font class for the title. */
titleFont: string;
/** Opal font name for the title (without `font-` prefix). */
titleFont: TextFont;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
@@ -53,9 +51,6 @@ interface ContentLgProps {
/** Size preset. Default: `"headline"`. */
sizePreset?: ContentLgSizePreset;
/** When `true`, the title color hooks into `Interactive`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
@@ -69,7 +64,7 @@ const CONTENT_LG_PRESETS: Record<ContentLgSizePreset, ContentLgPresetConfig> = {
iconSize: "2rem",
iconContainerPadding: "p-0.5",
gap: "0.25rem",
titleFont: "font-heading-h2",
titleFont: "heading-h2",
lineHeight: "2.25rem",
editButtonSize: "md",
editButtonPadding: "p-1",
@@ -78,7 +73,7 @@ const CONTENT_LG_PRESETS: Record<ContentLgSizePreset, ContentLgPresetConfig> = {
iconSize: "1.25rem",
iconContainerPadding: "p-1",
gap: "0rem",
titleFont: "font-heading-h3-muted",
titleFont: "heading-h3-muted",
lineHeight: "1.75rem",
editButtonSize: "sm",
editButtonPadding: "p-0.5",
@@ -96,7 +91,6 @@ function ContentLg({
description,
editable,
onTitleChange,
withInteractive,
ref,
}: ContentLgProps) {
const [editing, setEditing] = useState(false);
@@ -116,12 +110,7 @@ function ContentLg({
}
return (
<div
ref={ref}
className="opal-content-lg"
data-interactive={withInteractive || undefined}
style={{ gap: config.gap }}
>
<div ref={ref} className="opal-content-lg" style={{ gap: config.gap }}>
{Icon && (
<div
className={cn(
@@ -142,14 +131,17 @@ function ContentLg({
{editing ? (
<div className="opal-content-lg-input-sizer">
<span
className={cn("opal-content-lg-input-mirror", config.titleFont)}
className={cn(
"opal-content-lg-input-mirror",
`font-${config.titleFont}`
)}
>
{editValue || "\u00A0"}
</span>
<input
className={cn(
"opal-content-lg-input",
config.titleFont,
`font-${config.titleFont}`,
"text-text-04"
)}
value={editValue}
@@ -169,19 +161,15 @@ function ContentLg({
/>
</div>
) : (
<span
className={cn(
"opal-content-lg-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer"
)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
<Text
font={config.titleFont}
color="inherit"
maxLines={1}
title={toPlainString(title)}
onClick={editable ? startEditing : undefined}
>
{resolveStr(title)}
</span>
{title}
</Text>
)}
{editable && !editing && (
@@ -204,8 +192,10 @@ function ContentLg({
</div>
{description && toPlainString(description) && (
<div className="opal-content-lg-description font-secondary-body text-text-03">
{resolveStr(description)}
<div className="opal-content-lg-description">
<Text font="secondary-body" color="text-03" as="p">
{description}
</Text>
</div>
)}
</div>

View File

@@ -8,10 +8,8 @@ import SvgAlertTriangle from "@opal/icons/alert-triangle";
import SvgEdit from "@opal/icons/edit";
import SvgXOctagon from "@opal/icons/x-octagon";
import type { IconFunctionComponent, RichStr } from "@opal/types";
import {
resolveStr,
toPlainString,
} from "@opal/components/text/InlineMarkdown";
import { Text, type TextFont } from "@opal/components/text/components";
import { toPlainString } from "@opal/components/text/InlineMarkdown";
import { cn } from "@opal/utils";
import { useRef, useState } from "react";
@@ -23,16 +21,18 @@ type ContentMdSizePreset = "main-content" | "main-ui" | "secondary";
type ContentMdAuxIcon = "info-gray" | "info-blue" | "warning" | "error";
type ContentMdSuffix = "optional" | (string & {});
interface ContentMdPresetConfig {
iconSize: string;
iconContainerPadding: string;
iconColorClass: string;
titleFont: string;
titleFont: TextFont;
lineHeight: string;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
editButtonSize: ContainerSizeVariants;
editButtonPadding: string;
optionalFont: string;
optionalFont: TextFont;
/** Aux icon size = lineHeight 2 × p-0.5. */
auxIconSize: string;
/** Left indent for the description so it aligns with the title (past the icon). */
@@ -55,11 +55,11 @@ interface ContentMdProps {
/** Called when the user commits an edit. */
onTitleChange?: (newTitle: string) => void;
/** When `true`, renders "(Optional)" beside the title. */
optional?: boolean;
/** Custom muted suffix rendered beside the title. */
titleSuffix?: string;
/**
* Muted suffix rendered beside the title.
* Use `"optional"` for the standard "(Optional)" label, or pass any string.
*/
suffix?: ContentMdSuffix;
/** Auxiliary status icon rendered beside the title. */
auxIcon?: ContentMdAuxIcon;
@@ -70,18 +70,6 @@ interface ContentMdProps {
/** Size preset. Default: `"main-ui"`. */
sizePreset?: ContentMdSizePreset;
/** When `true`, the title color hooks into `Interactive`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Optional class name applied to the title element. */
titleClassName?: string;
/** Optional class name applied to the icon element. */
iconClassName?: string;
/** Content rendered below the description, indented to align with it. */
bottomChildren?: React.ReactNode;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
@@ -95,11 +83,11 @@ const CONTENT_MD_PRESETS: Record<ContentMdSizePreset, ContentMdPresetConfig> = {
iconSize: "1rem",
iconContainerPadding: "p-1",
iconColorClass: "text-text-04",
titleFont: "font-main-content-emphasis",
titleFont: "main-content-emphasis",
lineHeight: "1.5rem",
editButtonSize: "sm",
editButtonPadding: "p-0",
optionalFont: "font-main-content-muted",
optionalFont: "main-content-muted",
auxIconSize: "1.25rem",
descriptionIndent: "1.625rem",
},
@@ -107,11 +95,11 @@ const CONTENT_MD_PRESETS: Record<ContentMdSizePreset, ContentMdPresetConfig> = {
iconSize: "1rem",
iconContainerPadding: "p-0.5",
iconColorClass: "text-text-03",
titleFont: "font-main-ui-action",
titleFont: "main-ui-action",
lineHeight: "1.25rem",
editButtonSize: "xs",
editButtonPadding: "p-0",
optionalFont: "font-main-ui-muted",
optionalFont: "main-ui-muted",
auxIconSize: "1rem",
descriptionIndent: "1.375rem",
},
@@ -119,11 +107,11 @@ const CONTENT_MD_PRESETS: Record<ContentMdSizePreset, ContentMdPresetConfig> = {
iconSize: "0.75rem",
iconContainerPadding: "p-0.5",
iconColorClass: "text-text-04",
titleFont: "font-secondary-action",
titleFont: "secondary-action",
lineHeight: "1rem",
editButtonSize: "2xs",
editButtonPadding: "p-0",
optionalFont: "font-secondary-action",
optionalFont: "secondary-action",
auxIconSize: "0.75rem",
descriptionIndent: "1.125rem",
},
@@ -149,15 +137,10 @@ function ContentMd({
description,
editable,
onTitleChange,
optional,
titleSuffix,
suffix,
auxIcon,
tag,
sizePreset = "main-ui",
withInteractive,
titleClassName,
iconClassName,
bottomChildren,
ref,
}: ContentMdProps) {
const [editing, setEditing] = useState(false);
@@ -178,11 +161,7 @@ function ContentMd({
}
return (
<div
ref={ref}
className="opal-content-md"
data-interactive={withInteractive || undefined}
>
<div ref={ref} className="opal-content-md">
<div
className="opal-content-md-header"
data-editing={editing || undefined}
@@ -196,11 +175,7 @@ function ContentMd({
style={{ minHeight: config.lineHeight }}
>
<Icon
className={cn(
"opal-content-md-icon",
config.iconColorClass,
iconClassName
)}
className={cn("opal-content-md-icon", config.iconColorClass)}
style={{ width: config.iconSize, height: config.iconSize }}
/>
</div>
@@ -210,7 +185,10 @@ function ContentMd({
{editing ? (
<div className="opal-content-md-input-sizer">
<span
className={cn("opal-content-md-input-mirror", config.titleFont)}
className={cn(
"opal-content-md-input-mirror",
`font-${config.titleFont}`
)}
>
{editValue || "\u00A0"}
</span>
@@ -218,7 +196,7 @@ function ContentMd({
ref={inputRef}
className={cn(
"opal-content-md-input",
config.titleFont,
`font-${config.titleFont}`,
"text-text-04"
)}
value={editValue}
@@ -238,29 +216,21 @@ function ContentMd({
/>
</div>
) : (
<span
className={cn(
"opal-content-md-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer",
titleClassName
)}
<Text
font={config.titleFont}
color="inherit"
maxLines={1}
title={toPlainString(title)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
>
{resolveStr(title)}
</span>
{title}
</Text>
)}
{(optional || titleSuffix) && (
<span
className={cn(config.optionalFont, "text-text-03 shrink-0")}
style={{ height: config.lineHeight }}
>
{titleSuffix ?? "(Optional)"}
</span>
{suffix && (
<Text font={config.optionalFont} color="text-03">
{suffix === "optional" ? "(Optional)" : suffix}
</Text>
)}
{auxIcon &&
@@ -306,17 +276,12 @@ function ContentMd({
{description && toPlainString(description) && (
<div
className="opal-content-md-description font-secondary-body text-text-03"
className="opal-content-md-description"
style={Icon ? { paddingLeft: config.descriptionIndent } : undefined}
>
{resolveStr(description)}
</div>
)}
{bottomChildren && (
<div
style={Icon ? { paddingLeft: config.descriptionIndent } : undefined}
>
{bottomChildren}
<Text font="secondary-body" color="text-03" as="p">
{description}
</Text>
</div>
)}
</div>
@@ -327,5 +292,6 @@ export {
ContentMd,
type ContentMdProps,
type ContentMdSizePreset,
type ContentMdSuffix,
type ContentMdAuxIcon,
};

View File

@@ -1,10 +1,8 @@
"use client";
import type { IconFunctionComponent, RichStr } from "@opal/types";
import {
resolveStr,
toPlainString,
} from "@opal/components/text/InlineMarkdown";
import { Text, type TextFont } from "@opal/components/text/components";
import { toPlainString } from "@opal/components/text/InlineMarkdown";
import { cn } from "@opal/utils";
// ---------------------------------------------------------------------------
@@ -13,15 +11,15 @@ import { cn } from "@opal/utils";
type ContentSmSizePreset = "main-content" | "main-ui" | "secondary";
type ContentSmOrientation = "vertical" | "inline" | "reverse";
type ContentSmProminence = "default" | "muted" | "muted-2x";
type ContentSmProminence = "default" | "muted";
interface ContentSmPresetConfig {
/** Icon width/height (CSS value). */
iconSize: string;
/** Tailwind padding class for the icon container. */
iconContainerPadding: string;
/** Tailwind font class for the title. */
titleFont: string;
/** Font preset for the title. */
titleFont: TextFont;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Gap between icon container and title (CSS value). */
@@ -45,9 +43,6 @@ interface ContentSmProps {
/** Title prominence. Default: `"default"`. */
prominence?: ContentSmProminence;
/** When `true`, the title color hooks into `Interactive`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
@@ -60,21 +55,21 @@ const CONTENT_SM_PRESETS: Record<ContentSmSizePreset, ContentSmPresetConfig> = {
"main-content": {
iconSize: "1rem",
iconContainerPadding: "p-1",
titleFont: "font-main-content-body",
titleFont: "main-content-body",
lineHeight: "1.5rem",
gap: "0.125rem",
},
"main-ui": {
iconSize: "1rem",
iconContainerPadding: "p-0.5",
titleFont: "font-main-ui-action",
titleFont: "main-ui-action",
lineHeight: "1.25rem",
gap: "0.25rem",
},
secondary: {
iconSize: "0.75rem",
iconContainerPadding: "p-0.5",
titleFont: "font-secondary-action",
titleFont: "secondary-action",
lineHeight: "1rem",
gap: "0.125rem",
},
@@ -90,7 +85,6 @@ function ContentSm({
sizePreset = "main-ui",
orientation = "inline",
prominence = "default",
withInteractive,
ref,
}: ContentSmProps) {
const config = CONTENT_SM_PRESETS[sizePreset];
@@ -101,7 +95,6 @@ function ContentSm({
className="opal-content-sm"
data-orientation={orientation}
data-prominence={prominence}
data-interactive={withInteractive || undefined}
style={{ gap: config.gap }}
>
{Icon && (
@@ -119,13 +112,14 @@ function ContentSm({
</div>
)}
<span
className={cn("opal-content-sm-title", config.titleFont)}
style={{ height: config.lineHeight }}
<Text
font={config.titleFont}
color="inherit"
maxLines={1}
title={toPlainString(title)}
>
{resolveStr(title)}
</span>
{title}
</Text>
</div>
);
}

View File

@@ -4,10 +4,8 @@ import { Button } from "@opal/components/buttons/button/components";
import type { ContainerSizeVariants } from "@opal/types";
import SvgEdit from "@opal/icons/edit";
import type { IconFunctionComponent, RichStr } from "@opal/types";
import {
resolveStr,
toPlainString,
} from "@opal/components/text/InlineMarkdown";
import { Text, type TextFont } from "@opal/components/text/components";
import { toPlainString } from "@opal/components/text/InlineMarkdown";
import { cn } from "@opal/utils";
import { useState } from "react";
@@ -30,8 +28,8 @@ interface ContentXlPresetConfig {
moreIcon2Size: string;
/** Tailwind padding class for the more-icon-2 container. */
moreIcon2ContainerPadding: string;
/** Tailwind font class for the title. */
titleFont: string;
/** Opal font name for the title (without `font-` prefix). */
titleFont: TextFont;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
@@ -65,9 +63,6 @@ interface ContentXlProps {
/** Optional tertiary icon rendered in the icon row. */
moreIcon2?: IconFunctionComponent;
/** When `true`, the title color hooks into `Interactive`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
@@ -84,7 +79,7 @@ const CONTENT_XL_PRESETS: Record<ContentXlSizePreset, ContentXlPresetConfig> = {
moreIcon1ContainerPadding: "p-0.5",
moreIcon2Size: "2rem",
moreIcon2ContainerPadding: "p-0.5",
titleFont: "font-heading-h2",
titleFont: "heading-h2",
lineHeight: "2.25rem",
editButtonSize: "md",
editButtonPadding: "p-1",
@@ -96,7 +91,7 @@ const CONTENT_XL_PRESETS: Record<ContentXlSizePreset, ContentXlPresetConfig> = {
moreIcon1ContainerPadding: "p-0.5",
moreIcon2Size: "1.5rem",
moreIcon2ContainerPadding: "p-0.5",
titleFont: "font-heading-h3",
titleFont: "heading-h3",
lineHeight: "1.75rem",
editButtonSize: "sm",
editButtonPadding: "p-0.5",
@@ -116,7 +111,6 @@ function ContentXl({
onTitleChange,
moreIcon1: MoreIcon1,
moreIcon2: MoreIcon2,
withInteractive,
ref,
}: ContentXlProps) {
const [editing, setEditing] = useState(false);
@@ -136,11 +130,7 @@ function ContentXl({
}
return (
<div
ref={ref}
className="opal-content-xl"
data-interactive={withInteractive || undefined}
>
<div ref={ref} className="opal-content-xl">
{(Icon || MoreIcon1 || MoreIcon2) && (
<div className="opal-content-xl-icon-row">
{Icon && (
@@ -199,14 +189,17 @@ function ContentXl({
{editing ? (
<div className="opal-content-xl-input-sizer">
<span
className={cn("opal-content-xl-input-mirror", config.titleFont)}
className={cn(
"opal-content-xl-input-mirror",
`font-${config.titleFont}`
)}
>
{editValue || "\u00A0"}
</span>
<input
className={cn(
"opal-content-xl-input",
config.titleFont,
`font-${config.titleFont}`,
"text-text-04"
)}
value={editValue}
@@ -226,19 +219,15 @@ function ContentXl({
/>
</div>
) : (
<span
className={cn(
"opal-content-xl-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer"
)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
<Text
font={config.titleFont}
color="inherit"
maxLines={1}
title={toPlainString(title)}
onClick={editable ? startEditing : undefined}
>
{resolveStr(title)}
</span>
{title}
</Text>
)}
{editable && !editing && (
@@ -261,8 +250,10 @@ function ContentXl({
</div>
{description && toPlainString(description) && (
<div className="opal-content-xl-description font-secondary-body text-text-03">
{resolveStr(description)}
<div className="opal-content-xl-description">
<Text font="secondary-body" color="text-03" as="p">
{description}
</Text>
</div>
)}
</div>

View File

@@ -1,39 +1,3 @@
// ---------------------------------------------------------------------------
// NOTE (@raunakab): Why Content uses resolveStr() instead of <Text>
//
// Content sub-components (ContentXl, ContentLg, ContentMd, ContentSm) render
// titles and descriptions inside styled <span> elements that carry CSS classes
// (e.g., `.opal-content-md-title`) for:
//
// 1. Truncation — `-webkit-box` + `-webkit-line-clamp` for single-line
// clamping with ellipsis. This requires the text to be a DIRECT child
// of the `-webkit-box` element. Wrapping it in a child <span> (which
// is what <Text> renders) breaks the clamping behavior.
//
// 2. Pixel-exact sizing — the wrapper <span> has an explicit `height`
// matching the font's `line-height`. Adding a child <Text> <span>
// inside creates a double-span where the inner element's line-height
// conflicts with the outer element's height, causing a ~4px vertical
// offset.
//
// 3. Interactive color overrides — CSS selectors like
// `.opal-content-md[data-interactive] .opal-content-md-title` set
// `color: var(--interactive-foreground)`. <Text> with `color="inherit"`
// can inherit this, but <Text> with any explicit color prop overrides
// it. And the wrapper <span> needs the CSS class for the selector to
// match — removing it breaks the cascade.
//
// 4. Horizontal padding — the title CSS class applies `padding: 0 0.125rem`
// (2px). Since <Text> uses WithoutStyles (no className/style), this
// padding cannot be applied to <Text> directly. A wrapper <div> was
// attempted but introduced additional layout conflicts.
//
// For these reasons, Content uses `resolveStr()` from InlineMarkdown.tsx to
// handle `string | RichStr` rendering. `resolveStr()` returns a ReactNode
// that slots directly into the existing single <span> — no extra wrapper,
// no layout conflicts, pixel-exact match with main.
// ---------------------------------------------------------------------------
import "@opal/layouts/content/styles.css";
import {
ContentSm,
@@ -98,9 +62,6 @@ interface ContentBaseProps {
*/
widthVariant?: ExtremaSizeVariants;
/** When `true`, the title color hooks into `Interactive.Stateful`/`Interactive.Stateless`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>` of the resolved layout. */
ref?: React.Ref<HTMLDivElement>;
}
@@ -130,20 +91,12 @@ type LgContentProps = ContentBaseProps & {
type MdContentProps = ContentBaseProps & {
sizePreset: "main-content" | "main-ui" | "secondary";
variant?: "section";
/** When `true`, renders "(Optional)" beside the title in the muted font variant. */
optional?: boolean;
/** Custom muted suffix rendered beside the title. */
titleSuffix?: string;
/** Muted suffix rendered beside the title. Use `"optional"` for "(Optional)". */
suffix?: "optional" | (string & {});
/** Auxiliary status icon rendered beside the title. */
auxIcon?: "info-gray" | "info-blue" | "warning" | "error";
/** Tag rendered beside the title. */
tag?: TagProps;
/** Optional class name applied to the title element. */
titleClassName?: string;
/** Optional class name applied to the icon element. */
iconClassName?: string;
/** Content rendered below the description, indented to align with it. */
bottomChildren?: React.ReactNode;
};
/** ContentSm does not support descriptions or inline editing. */
@@ -174,7 +127,6 @@ function Content(props: ContentProps) {
sizePreset = "headline",
variant = "heading",
widthVariant = "full",
withInteractive,
ref,
...rest
} = props;
@@ -187,7 +139,6 @@ function Content(props: ContentProps) {
layout = (
<ContentXl
sizePreset={sizePreset}
withInteractive={withInteractive}
ref={ref}
{...(rest as Omit<ContentXlProps, "sizePreset">)}
/>
@@ -196,7 +147,6 @@ function Content(props: ContentProps) {
layout = (
<ContentLg
sizePreset={sizePreset}
withInteractive={withInteractive}
ref={ref}
{...(rest as Omit<ContentLgProps, "sizePreset">)}
/>
@@ -210,7 +160,6 @@ function Content(props: ContentProps) {
layout = (
<ContentMd
sizePreset={sizePreset}
withInteractive={withInteractive}
ref={ref}
{...(rest as Omit<ContentMdProps, "sizePreset">)}
/>
@@ -222,7 +171,6 @@ function Content(props: ContentProps) {
layout = (
<ContentSm
sizePreset={sizePreset}
withInteractive={withInteractive}
ref={ref}
{...(rest as Omit<
React.ComponentProps<typeof ContentSm>,

View File

@@ -13,7 +13,7 @@
--------------------------------------------------------------------------- */
.opal-content-xl {
@apply flex flex-col items-start;
@apply flex flex-col items-start text-text-04;
}
/* ---------------------------------------------------------------------------
@@ -63,15 +63,6 @@
gap: 0.25rem;
}
.opal-content-xl-title {
@apply text-left overflow-hidden text-text-04;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-xl-input-sizer {
display: inline-grid;
align-items: stretch;
@@ -110,7 +101,6 @@
.opal-content-xl-description {
@apply text-left w-full;
padding: 0 0.125rem;
}
/* ===========================================================================
@@ -127,7 +117,7 @@
--------------------------------------------------------------------------- */
.opal-content-lg {
@apply flex flex-row items-start;
@apply flex flex-row items-start text-text-04;
}
/* ---------------------------------------------------------------------------
@@ -162,15 +152,6 @@
gap: 0.25rem;
}
.opal-content-lg-title {
@apply text-left overflow-hidden text-text-04;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-lg-input-sizer {
display: inline-grid;
align-items: stretch;
@@ -209,7 +190,6 @@
.opal-content-lg-description {
@apply text-left w-full;
padding: 0 0.125rem;
}
/* ===========================================================================
@@ -224,7 +204,7 @@
--------------------------------------------------------------------------- */
.opal-content-md {
@apply flex flex-col items-start;
@apply flex flex-col items-start text-text-04;
}
.opal-content-md-header {
@@ -255,15 +235,6 @@
gap: 0.25rem;
}
.opal-content-md-title {
@apply text-left overflow-hidden text-text-04;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-md-input-sizer {
display: inline-grid;
align-items: stretch;
@@ -313,7 +284,6 @@
.opal-content-md-description {
@apply text-left w-full;
padding: 0 0.125rem;
}
/* ===========================================================================
@@ -325,7 +295,7 @@
reverse : flex-row-reverse — title left, icon right
Icon color is always text-03. Title color varies by prominence
(text-04 default, text-03 muted, text-02 muted-2x) via data-prominence.
(text-04 default, text-03 muted) via data-prominence.
=========================================================================== */
/* ---------------------------------------------------------------------------
@@ -334,7 +304,7 @@
.opal-content-sm {
/* since `ContentSm` doesn't have a description, it's possible to center-align the icon and text */
@apply flex items-center;
@apply flex items-center text-text-04;
}
.opal-content-sm[data-orientation="inline"] {
@@ -367,68 +337,51 @@
@apply text-text-02;
}
.opal-content-sm[data-prominence="muted-2x"] .opal-content-sm-icon {
@apply text-text-02;
}
/* ---------------------------------------------------------------------------
Title
--------------------------------------------------------------------------- */
.opal-content-sm-title {
@apply text-left overflow-hidden text-text-04 truncate;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-sm[data-prominence="muted"] .opal-content-sm-title {
.opal-content-sm[data-prominence="muted"] {
@apply text-text-03;
}
.opal-content-sm[data-prominence="muted-2x"] .opal-content-sm-title {
@apply text-text-02;
}
/* ===========================================================================
Interactive-foreground opt-in
Interactive override
When a Content variant is nested inside an Interactive and
`withInteractive` is set, the title and icon delegate their color to the
`--interactive-foreground` / `--interactive-foreground-icon` CSS variables
controlled by the ancestor Interactive variant.
When a Content variant is nested inside an `.interactive` element,
the title inherits color from the Interactive's `--interactive-foreground`
and icons switch to `--interactive-foreground-icon`. This is automatic —
no opt-in prop is required.
=========================================================================== */
.opal-content-xl[data-interactive] .opal-content-xl-title {
color: var(--interactive-foreground);
.interactive .opal-content-xl {
color: inherit;
}
.opal-content-xl[data-interactive] .opal-content-xl-icon {
.interactive .opal-content-xl .opal-content-xl-icon {
color: var(--interactive-foreground-icon);
}
.opal-content-lg[data-interactive] .opal-content-lg-title {
color: var(--interactive-foreground);
.interactive .opal-content-lg {
color: inherit;
}
.opal-content-lg[data-interactive] .opal-content-lg-icon {
.interactive .opal-content-lg .opal-content-lg-icon {
color: var(--interactive-foreground-icon);
}
.opal-content-md[data-interactive] .opal-content-md-title {
color: var(--interactive-foreground);
.interactive .opal-content-md {
color: inherit;
}
.opal-content-md[data-interactive] .opal-content-md-icon {
.interactive .opal-content-md .opal-content-md-icon {
color: var(--interactive-foreground-icon);
}
.opal-content-sm[data-interactive] .opal-content-sm-title {
color: var(--interactive-foreground);
.interactive .opal-content-sm {
color: inherit;
}
.opal-content-sm[data-interactive] .opal-content-sm-icon {
.interactive .opal-content-sm .opal-content-sm-icon {
color: var(--interactive-foreground-icon);
}

View File

@@ -6,7 +6,14 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Wraps a string for inline markdown parsing by `Text` and other Opal components. */
export function markdown(content: string): RichStr {
return { __brand: "RichStr", raw: content };
/**
* Wraps strings for inline markdown parsing by `Text` and other Opal components.
*
* Multiple arguments are joined with newlines, so each string renders on its own line:
* ```tsx
* markdown("Line one", "Line two", "Line three")
* ```
*/
export function markdown(...lines: string[]): RichStr {
return { __brand: "RichStr", raw: lines.join("\n") };
}

View File

@@ -229,7 +229,7 @@ export const RenderField: FC<RenderFieldProps> = ({
name={field.name}
title={label}
description={description}
optional={field.optional}
suffix={field.optional ? "optional" : undefined}
>
<InputTextAreaField
name={field.name}

View File

@@ -15,9 +15,8 @@ interface OrientationLayoutProps {
nonInteractive?: boolean;
children?: React.ReactNode;
title: string | RichStr;
titleSuffix?: string;
description?: string | RichStr;
optional?: boolean;
suffix?: "optional" | (string & {});
sizePreset?: "main-content" | "main-ui";
}
@@ -53,18 +52,16 @@ function VerticalInputLayout({
children,
subDescription,
title,
titleSuffix,
description,
optional,
suffix,
sizePreset = "main-content",
}: VerticalLayoutProps) {
const content = (
<Section gap={0.25} alignItems="start">
<Content
title={title}
titleSuffix={titleSuffix}
description={description}
optional={optional}
suffix={suffix}
sizePreset={sizePreset}
variant="section"
/>
@@ -130,9 +127,8 @@ function HorizontalInputLayout({
children,
center,
title,
titleSuffix,
description,
optional,
suffix,
sizePreset = "main-content",
}: HorizontalLayoutProps) {
const content = (
@@ -145,9 +141,8 @@ function HorizontalInputLayout({
<div className="flex flex-col flex-1 min-w-0 self-stretch">
<Content
title={title}
titleSuffix={titleSuffix}
description={description}
optional={optional}
suffix={suffix}
sizePreset={sizePreset}
variant="section"
widthVariant="full"

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { getUserOAuthTokenStatus, initiateOAuthFlow } from "@/lib/oauth/api";
import { useCallback, useEffect, useRef } from "react";
import useSWR from "swr";
import { errorHandlingFetcher, skipRetryOnAuthError } from "@/lib/fetcher";
import { initiateOAuthFlow } from "@/lib/oauth/api";
import { OAuthTokenStatus, ToolSnapshot } from "@/lib/tools/interfaces";
export interface ToolAuthStatus {
@@ -10,29 +12,38 @@ export interface ToolAuthStatus {
}
export function useToolOAuthStatus(agentId?: number) {
const [oauthTokenStatuses, setOauthTokenStatuses] = useState<
OAuthTokenStatus[]
>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchOAuthStatus = useCallback(async () => {
try {
setLoading(true);
setError(null);
const statuses = await getUserOAuthTokenStatus();
setOauthTokenStatuses(statuses);
} catch (err) {
console.error("Error fetching OAuth token statuses:", err);
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setLoading(false);
const {
data: oauthTokenStatuses = [],
isLoading: loading,
error: swrError,
mutate,
} = useSWR<OAuthTokenStatus[]>(
"/api/user-oauth-token/status",
errorHandlingFetcher,
{
revalidateOnFocus: false,
dedupingInterval: 60_000,
onErrorRetry: skipRetryOnAuthError,
onError: (err) =>
console.error("[useToolOAuthStatus] fetch failed:", err),
}
}, []);
);
const error: string | null = swrError
? swrError instanceof Error
? swrError.message
: "An error occurred"
: null;
// Re-validate when the active agent changes so the UI reflects fresh token
// state for the new agent's tools without waiting for the dedup interval.
const prevAgentIdRef = useRef(agentId);
useEffect(() => {
fetchOAuthStatus();
}, [agentId, fetchOAuthStatus]);
if (prevAgentIdRef.current !== agentId) {
prevAgentIdRef.current = agentId;
mutate();
}
}, [agentId, mutate]);
/**
* Get OAuth status for a specific tool
@@ -98,6 +109,6 @@ export function useToolOAuthStatus(agentId?: number) {
getToolAuthStatus,
authenticateTool,
getToolsNeedingAuth,
refetch: fetchOAuthStatus,
refetch: () => mutate(),
};
}

View File

@@ -12,6 +12,8 @@ import {
Dispatch,
SetStateAction,
} from "react";
import useSWR from "swr";
import { errorHandlingFetcher, skipRetryOnAuthError } from "@/lib/fetcher";
import type {
CategorizedFiles,
Project,
@@ -160,6 +162,21 @@ export function ProjectsProvider({ children }: ProjectsProviderProps) {
const route = useAppRouter();
const settingsContext = useContext(SettingsContext);
// SWR-backed fetch for recent files. Deduplicates across all mounts and
// handles React StrictMode double-invocation without firing duplicate requests.
const { data: recentFilesData, mutate: mutateRecentFiles } = useSWR<
ProjectFile[]
>("/api/user/files/recent", errorHandlingFetcher, {
revalidateOnFocus: false,
dedupingInterval: 60_000,
onErrorRetry: skipRetryOnAuthError,
onError: (err) =>
console.error("[ProjectsContext] recent files fetch failed:", err),
});
// Track whether allRecentFiles has been seeded from the initial server fetch.
// Subsequent updates come through the merge effect below, not a full reset.
const hasInitializedAllRecentFilesRef = useRef(false);
// Use SWR's mutate to refresh projects - returns the new data
const fetchProjects = useCallback(async (): Promise<Project[]> => {
try {
@@ -286,9 +303,8 @@ export function ProjectsProvider({ children }: ProjectsProviderProps) {
}, []);
const refreshRecentFiles = useCallback(async () => {
const files = await getRecentFiles();
setRecentFiles(files);
}, [getRecentFiles]);
await mutateRecentFiles();
}, [mutateRecentFiles]);
const getTempIdMap = (files: File[], optimisticFiles: ProjectFile[]) => {
const tempIdMap = new Map<string, string>();
@@ -521,13 +537,17 @@ export function ProjectsProvider({ children }: ProjectsProviderProps) {
[]
);
// Sync SWR-fetched recent files into local state. On first arrival, seed
// allRecentFiles as well; subsequent updates only touch recentFiles so the
// merge effect below can non-destructively apply them to allRecentFiles.
useEffect(() => {
// Initial load - only fetch recent files since projects come from props
getRecentFiles().then((recent) => {
setRecentFiles(recent);
setAllRecentFiles(recent);
});
}, [getRecentFiles]);
if (!recentFilesData) return;
setRecentFiles(recentFilesData);
if (!hasInitializedAllRecentFilesRef.current) {
setAllRecentFiles(recentFilesData);
hasInitializedAllRecentFilesRef.current = true;
}
}, [recentFilesData]);
useEffect(() => {
setAllRecentFiles((prev) =>

View File

@@ -57,7 +57,7 @@ export default function SidebarTab({
const content = (
<div className="relative">
<Interactive.Stateful
variant="sidebar"
variant={lowlight ? "sidebar-light" : "sidebar-heavy"}
state={selected ? "selected" : "empty"}
onClick={onClick}
type="button"
@@ -90,9 +90,6 @@ export default function SidebarTab({
title={folded ? "" : children}
sizePreset="main-ui"
variant="body"
prominence={
lowlight ? "muted-2x" : selected ? "default" : "muted"
}
widthVariant="full"
paddingVariant="fit"
rightChildren={truncationSpacer}

View File

@@ -1268,7 +1268,7 @@ export default function AgentEditorPage({
<InputLayouts.Vertical
name="description"
title="Description"
optional
suffix="optional"
>
<InputTextAreaField
name="description"
@@ -1293,7 +1293,7 @@ export default function AgentEditorPage({
<InputLayouts.Vertical
name="instructions"
title="Instructions"
optional
suffix="optional"
description="Add instructions to tailor the response for this agent."
>
<InputTextAreaField
@@ -1306,7 +1306,7 @@ export default function AgentEditorPage({
name="starter_messages"
title="Conversation Starters"
description="Example messages that help users understand what this agent can do and how to interact with it effectively."
optional
suffix="optional"
>
<StarterMessages />
</InputLayouts.Vertical>
@@ -1546,7 +1546,7 @@ export default function AgentEditorPage({
<InputLayouts.Horizontal
name="knowledge_cutoff_date"
title="Knowledge Cutoff Date"
optional
suffix="optional"
description="Documents with a last-updated date prior to this will be ignored."
>
<InputDatePickerField
@@ -1557,7 +1557,7 @@ export default function AgentEditorPage({
<InputLayouts.Horizontal
name="replace_base_system_prompt"
title="Overwrite System Prompt"
titleSuffix="(Not Recommended)"
suffix="(Not Recommended)"
description='Remove the base system prompt which includes useful instructions (e.g. "You can use Markdown tables"). This may affect response quality.'
>
<SwitchField name="replace_base_system_prompt" />
@@ -1568,7 +1568,7 @@ export default function AgentEditorPage({
<InputLayouts.Vertical
name="reminders"
title="Reminders"
optional
suffix="optional"
>
<InputTextAreaField
name="reminders"

View File

@@ -5,7 +5,8 @@ import { toast } from "@/hooks/useToast";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { cn } from "@/lib/utils";
import { ContentAction } from "@opal/layouts";
import { markdown } from "@opal/utils";
import { Content } from "@opal/layouts";
import Card from "@/refresh-components/cards/Card";
import Text from "@/refresh-components/texts/Text";
import { Section } from "@/layouts/general-layouts";
@@ -310,110 +311,109 @@ export default function ConnectedHookCard({
!hook.is_active && "!bg-background-neutral-02"
)}
>
<ContentAction
sizePreset="main-ui"
variant="section"
paddingVariant="sm"
icon={HookIcon}
title={hook.name}
titleClassName={!hook.is_active ? "line-through" : undefined}
iconClassName="text-text-04"
description={`Hook Point: ${spec?.display_name ?? hook.hook_point}`}
bottomChildren={
spec?.docs_url ? (
<div className="w-full flex flex-row">
<div className="flex-1 p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={HookIcon}
title={!hook.is_active ? markdown(`~~${hook.name}~~`) : hook.name}
description={`Hook Point: ${
spec?.display_name ?? hook.hook_point
}`}
/>
{spec?.docs_url && (
<a
href={spec.docs_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 w-fit font-secondary-body text-text-03"
className="pl-6 flex items-center gap-1"
>
<span className="underline">Documentation</span>
<span className="underline font-secondary-body text-text-03">
Documentation
</span>
<SvgExternalLink size={12} className="shrink-0" />
</a>
) : undefined
}
rightChildren={
<Section
flexDirection="column"
alignItems="end"
width="fit"
height="fit"
gap={0}
>
<div className="flex items-center gap-1 p-2">
)}
</div>
<Section
flexDirection="column"
alignItems="end"
width="fit"
height="fit"
gap={0}
>
<div className="flex items-center gap-1 p-2">
{hook.is_active ? (
<>
<Text mainUiAction text03>
Connected
</Text>
<SvgCheckCircle
size={16}
className="text-status-success-05"
/>
</>
) : (
<div
className={cn(
"flex items-center gap-1",
isBusy ? "opacity-50 pointer-events-none" : "cursor-pointer"
)}
onClick={handleActivate}
>
<Text mainUiAction text03>
Reconnect
</Text>
<SvgPlug size={16} className="text-text-03 shrink-0" />
</div>
)}
</div>
<Disabled disabled={isBusy}>
<div className="flex items-center gap-0.5 pl-1 pr-1 pb-1">
{hook.is_active ? (
<>
<Text mainUiAction text03>
Connected
</Text>
<SvgCheckCircle
size={16}
className="text-status-success-05"
/>
</>
) : (
<div
className={cn(
"flex items-center gap-1",
isBusy
? "opacity-50 pointer-events-none"
: "cursor-pointer"
)}
onClick={handleActivate}
>
<Text mainUiAction text03>
Reconnect
</Text>
<SvgPlug size={16} className="text-text-03 shrink-0" />
</div>
)}
</div>
<Disabled disabled={isBusy}>
{/* Plain div instead of Section: Section applies style={{ padding }} inline which
overrides Tailwind padding classes, making per-side padding (pl/pr/pb) ineffective. */}
<div className="flex items-center gap-0.5 pl-1 pr-1 pb-1">
{hook.is_active ? (
<>
<Button
prominence="tertiary"
size="sm"
icon={SvgUnplug}
onClick={() => setDisconnectConfirmOpen(true)}
tooltip="Disconnect Hook"
aria-label="Deactivate hook"
/>
<Button
prominence="tertiary"
size="sm"
icon={SvgRefreshCw}
onClick={handleValidate}
tooltip="Test Connection"
aria-label="Re-validate hook"
/>
</>
) : (
<Button
prominence="tertiary"
size="sm"
icon={SvgTrash}
onClick={() => setDeleteConfirmOpen(true)}
tooltip="Delete"
aria-label="Delete hook"
icon={SvgUnplug}
onClick={() => setDisconnectConfirmOpen(true)}
tooltip="Disconnect Hook"
aria-label="Deactivate hook"
/>
)}
<Button
prominence="tertiary"
size="sm"
icon={SvgRefreshCw}
onClick={handleValidate}
tooltip="Test Connection"
aria-label="Re-validate hook"
/>
</>
) : (
<Button
prominence="tertiary"
size="sm"
icon={SvgSettings}
onClick={onEdit}
tooltip="Manage"
aria-label="Configure hook"
icon={SvgTrash}
onClick={() => setDeleteConfirmOpen(true)}
tooltip="Delete"
aria-label="Delete hook"
/>
</div>
</Disabled>
</Section>
}
/>
)}
<Button
prominence="tertiary"
size="sm"
icon={SvgSettings}
onClick={onEdit}
tooltip="Manage"
aria-label="Configure hook"
/>
</div>
</Disabled>
</Section>
</div>
</Card>
</>
);

View File

@@ -5,7 +5,7 @@ import { useHookSpecs } from "@/hooks/useHookSpecs";
import { useHooks } from "@/hooks/useHooks";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { Button } from "@opal/components";
import { ContentAction } from "@opal/layouts";
import { Content } from "@opal/layouts";
import InputSearch from "@/refresh-components/inputs/InputSearch";
import Card from "@/refresh-components/cards/Card";
import Text from "@/refresh-components/texts/Text";
@@ -17,6 +17,7 @@ import type {
HookPointMeta,
HookResponse,
} from "@/refresh-pages/admin/HooksPage/interfaces";
import { markdown } from "@opal/utils";
// ---------------------------------------------------------------------------
// Main component
@@ -145,37 +146,39 @@ export default function HooksContent() {
gap={0}
className="hover:border-border-02"
>
<ContentAction
sizePreset="main-ui"
variant="section"
paddingVariant="sm"
icon={UnconnectedIcon}
title={spec.display_name}
iconClassName="text-text-04"
description={spec.description}
bottomChildren={
spec.docs_url ? (
<div className="w-full flex flex-row">
<div className="flex-1 p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={UnconnectedIcon}
title={spec.display_name}
description={spec.description}
/>
{spec.docs_url && (
<a
href={spec.docs_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 w-fit font-secondary-body text-text-03"
className="pl-6 flex items-center gap-1"
>
<span className="underline">Documentation</span>
<span className="underline font-secondary-body text-text-03">
Documentation
</span>
<SvgExternalLink size={12} className="shrink-0" />
</a>
) : undefined
}
rightChildren={
<Button
prominence="tertiary"
rightIcon={SvgArrowExchange}
onClick={() => setConnectSpec(spec)}
>
Connect
</Button>
}
/>
)}
</div>
<Button
prominence="tertiary"
rightIcon={SvgArrowExchange}
onClick={() => setConnectSpec(spec)}
>
Connect
</Button>
</div>
</Card>
);
})}

View File

@@ -216,7 +216,7 @@ function ExistingProviderCard({
icon={getProviderIcon(provider.provider)}
title={provider.name}
description={getProviderDisplayName(provider.provider)}
sizePreset="main-content"
sizePreset="main-ui"
variant="section"
tag={isDefault ? { title: "Default", color: "blue" } : undefined}
rightChildren={
@@ -280,7 +280,7 @@ function NewProviderCard({
icon={getProviderIcon(provider.name)}
title={getProviderProductName(provider.name)}
description={getProviderDisplayName(provider.name)}
sizePreset="main-content"
sizePreset="main-ui"
variant="section"
rightChildren={
<Button
@@ -316,7 +316,7 @@ function NewCustomProviderCard({
icon={getProviderIcon("custom")}
title={getProviderProductName("custom")}
description={getProviderDisplayName("custom")}
sizePreset="main-content"
sizePreset="main-ui"
variant="section"
rightChildren={
<Button

View File

@@ -158,7 +158,7 @@ export default function AddMCPServerModal({
<InputLayouts.Vertical
name="description"
title="Description"
optional
suffix="optional"
>
<InputTextAreaField
name="description"

View File

@@ -127,10 +127,9 @@ export default function AgentCard({ agent }: AgentCardProps) {
{fullAgent && <AgentViewerModal agent={fullAgent} />}
</agentViewerModal.Provider>
<Interactive.Stateless
<Interactive.Simple
onClick={() => agentViewerModal.toggle(true)}
group="group/AgentCard"
variant="none"
>
<Card
padding={0}
@@ -232,7 +231,7 @@ export default function AgentCard({ agent }: AgentCardProps) {
</div>
</div>
</Card>
</Interactive.Stateless>
</Interactive.Simple>
</>
);
}

View File

@@ -29,13 +29,12 @@ export default function DocumentSetCard({
disabled={!disabled || !disabledTooltip}
>
<div className="max-w-[12rem]">
<Interactive.Stateless
<Interactive.Simple
onClick={
disabled || isSelected === undefined
? undefined
: () => onSelectToggle?.(!isSelected)
}
variant="none"
>
<Interactive.Container
data-testid={`document-set-card-${documentSet.id}`}
@@ -64,7 +63,7 @@ export default function DocumentSetCard({
/>
<Spacer horizontal rem={0.5} />
</Interactive.Container>
</Interactive.Stateless>
</Interactive.Simple>
</div>
</SimpleTooltip>
);

View File

@@ -80,7 +80,7 @@ export default function FeedbackModal({
<InputLayouts.Vertical
name="additional_feedback"
title="Provide Additional Details"
optional={feedbackType === "like"}
suffix={feedbackType === "like" ? "optional" : undefined}
>
<InputTextAreaField
name="additional_feedback"

View File

@@ -126,7 +126,7 @@ function BifrostModalInternals({
<InputLayouts.Vertical
name="api_key"
title="API Key"
optional={true}
suffix="optional"
subDescription={markdown(
"Paste your API key from [Bifrost](https://docs.getbifrost.ai/overview) to access your models."
)}

View File

@@ -147,7 +147,7 @@ function LMStudioFormInternals({
name="custom_config.LM_STUDIO_API_KEY"
title="API Key"
subDescription="Optional API key if your LM Studio server requires authentication."
optional
suffix="optional"
>
<PasswordInputTypeInField
name="custom_config.LM_STUDIO_API_KEY"

View File

@@ -103,7 +103,7 @@ export function APIKeyField({
? `Paste your API key from ${providerName} to access your models.`
: "Paste your API key to access your models."
}
optional={optional}
suffix={optional ? "optional" : undefined}
>
<PasswordInputTypeInField name="api_key" placeholder="API Key" />
</InputLayouts.Vertical>