mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-04 14:32:41 +00:00
Compare commits
10 Commits
jamison/wo
...
v3.1.0-clo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8737122133 | ||
|
|
c5d7cfa896 | ||
|
|
297c931191 | ||
|
|
ae343c718b | ||
|
|
ce39442478 | ||
|
|
256996f27c | ||
|
|
9dbe7acac6 | ||
|
|
8d43d73f83 | ||
|
|
559bac9f78 | ||
|
|
e81bbe6f69 |
@@ -27,13 +27,13 @@ from shared_configs.configs import MULTI_TENANT
|
||||
from shared_configs.configs import TENANT_ID_PREFIX
|
||||
|
||||
# Maximum tenants to provision in a single task run.
|
||||
# Each tenant takes ~80s (alembic migrations), so 5 tenants ≈ 7 minutes.
|
||||
_MAX_TENANTS_PER_RUN = 5
|
||||
# Each tenant takes ~80s (alembic migrations), so 15 tenants ≈ 20 minutes.
|
||||
_MAX_TENANTS_PER_RUN = 15
|
||||
|
||||
# Time limits sized for worst-case: provisioning up to _MAX_TENANTS_PER_RUN new tenants
|
||||
# (~90s each) plus migrating up to TARGET_AVAILABLE_TENANTS pool tenants (~90s each).
|
||||
_TENANT_PROVISIONING_SOFT_TIME_LIMIT = 60 * 20 # 20 minutes
|
||||
_TENANT_PROVISIONING_TIME_LIMIT = 60 * 25 # 25 minutes
|
||||
_TENANT_PROVISIONING_SOFT_TIME_LIMIT = 60 * 40 # 40 minutes
|
||||
_TENANT_PROVISIONING_TIME_LIMIT = 60 * 45 # 45 minutes
|
||||
|
||||
|
||||
@shared_task(
|
||||
|
||||
@@ -302,7 +302,7 @@ beat_cloud_tasks: list[dict] = [
|
||||
{
|
||||
"name": f"{ONYX_CLOUD_CELERY_TASK_PREFIX}_check-available-tenants",
|
||||
"task": OnyxCeleryTask.CLOUD_CHECK_AVAILABLE_TENANTS,
|
||||
"schedule": timedelta(minutes=10),
|
||||
"schedule": timedelta(minutes=2),
|
||||
"options": {
|
||||
"queue": OnyxCeleryQueues.MONITORING,
|
||||
"priority": OnyxCeleryPriority.HIGH,
|
||||
|
||||
@@ -44,7 +44,7 @@ _NOTION_CALL_TIMEOUT = 30 # 30 seconds
|
||||
_MAX_PAGES = 1000
|
||||
|
||||
|
||||
# TODO: Tables need to be ingested, Pages need to have their metadata ingested
|
||||
# TODO: Pages need to have their metadata ingested
|
||||
|
||||
|
||||
class NotionPage(BaseModel):
|
||||
@@ -452,6 +452,19 @@ class NotionConnector(LoadConnector, PollConnector):
|
||||
sub_inner_dict: dict[str, Any] | list[Any] | str = inner_dict
|
||||
while isinstance(sub_inner_dict, dict) and "type" in sub_inner_dict:
|
||||
type_name = sub_inner_dict["type"]
|
||||
|
||||
# Notion user objects (people properties, created_by, etc.) have
|
||||
# "name" at the same level as "type": "person"/"bot". If we drill
|
||||
# into the person/bot sub-dict we lose the name. Capture it here
|
||||
# before descending, but skip "title"-type properties where "name"
|
||||
# is not the display value we want.
|
||||
if (
|
||||
"name" in sub_inner_dict
|
||||
and isinstance(sub_inner_dict["name"], str)
|
||||
and type_name not in ("title",)
|
||||
):
|
||||
return sub_inner_dict["name"]
|
||||
|
||||
sub_inner_dict = sub_inner_dict[type_name]
|
||||
|
||||
# If the innermost layer is None, the value is not set
|
||||
@@ -663,6 +676,19 @@ class NotionConnector(LoadConnector, PollConnector):
|
||||
text = rich_text["text"]["content"]
|
||||
cur_result_text_arr.append(text)
|
||||
|
||||
# table_row blocks store content in "cells" (list of lists
|
||||
# of rich text objects) rather than "rich_text"
|
||||
if "cells" in result_obj:
|
||||
row_cells: list[str] = []
|
||||
for cell in result_obj["cells"]:
|
||||
cell_texts = [
|
||||
rt.get("plain_text", "")
|
||||
for rt in cell
|
||||
if isinstance(rt, dict)
|
||||
]
|
||||
row_cells.append(" ".join(cell_texts))
|
||||
cur_result_text_arr.append("\t".join(row_cells))
|
||||
|
||||
if result["has_children"]:
|
||||
if result_type == "child_page":
|
||||
# Child pages will not be included at this top level, it will be a separate document.
|
||||
|
||||
@@ -190,16 +190,23 @@ def delete_messages_and_files_from_chat_session(
|
||||
chat_session_id: UUID, db_session: Session
|
||||
) -> None:
|
||||
# Select messages older than cutoff_time with files
|
||||
messages_with_files = db_session.execute(
|
||||
select(ChatMessage.id, ChatMessage.files).where(
|
||||
ChatMessage.chat_session_id == chat_session_id,
|
||||
messages_with_files = (
|
||||
db_session.execute(
|
||||
select(ChatMessage.id, ChatMessage.files).where(
|
||||
ChatMessage.chat_session_id == chat_session_id,
|
||||
)
|
||||
)
|
||||
).fetchall()
|
||||
.tuples()
|
||||
.all()
|
||||
)
|
||||
|
||||
file_store = get_default_file_store()
|
||||
for _, files in messages_with_files:
|
||||
file_store = get_default_file_store()
|
||||
for file_info in files or []:
|
||||
file_store.delete_file(file_id=file_info.get("id"), error_on_missing=False)
|
||||
if file_info.get("user_file_id"):
|
||||
# user files are managed by the user file lifecycle
|
||||
continue
|
||||
file_store.delete_file(file_id=file_info["id"], error_on_missing=False)
|
||||
|
||||
# Delete ChatMessage records - CASCADE constraints will automatically handle:
|
||||
# - ChatMessage__StandardAnswer relationship records
|
||||
|
||||
@@ -9,6 +9,7 @@ This test verifies the full flow: provisioning failure → rollback → schema c
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import text
|
||||
@@ -55,18 +56,28 @@ class TestTenantProvisioningRollback:
|
||||
created_tenant_id = tenant_id
|
||||
return create_schema_if_not_exists(tenant_id)
|
||||
|
||||
# Mock setup_tenant to fail after schema creation
|
||||
# Mock setup_tenant to fail after schema creation.
|
||||
# Also mock the Redis lock so the test doesn't compete with a live
|
||||
# monitoring worker that may already hold the provision lock.
|
||||
mock_lock = MagicMock()
|
||||
mock_lock.acquire.return_value = True
|
||||
|
||||
with patch(
|
||||
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.setup_tenant"
|
||||
) as mock_setup:
|
||||
mock_setup.side_effect = Exception("Simulated provisioning failure")
|
||||
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.get_redis_client"
|
||||
) as mock_redis:
|
||||
mock_redis.return_value.lock.return_value = mock_lock
|
||||
|
||||
with patch(
|
||||
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.create_schema_if_not_exists",
|
||||
side_effect=track_schema_creation,
|
||||
):
|
||||
# Run pre-provisioning - it should fail and trigger rollback
|
||||
pre_provision_tenant()
|
||||
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.setup_tenant"
|
||||
) as mock_setup:
|
||||
mock_setup.side_effect = Exception("Simulated provisioning failure")
|
||||
|
||||
with patch(
|
||||
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.create_schema_if_not_exists",
|
||||
side_effect=track_schema_creation,
|
||||
):
|
||||
# Run pre-provisioning - it should fail and trigger rollback
|
||||
pre_provision_tenant()
|
||||
|
||||
# Verify that the schema was created and then cleaned up
|
||||
assert created_tenant_id is not None, "Schema should have been created"
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Unit tests for Notion connector handling of people properties and table blocks.
|
||||
|
||||
Reproduces two bugs:
|
||||
1. ENG-3970: People-type database properties (user mentions) are not extracted —
|
||||
the user's "name" field is lost when _recurse_properties drills into the
|
||||
"person" sub-dict.
|
||||
2. ENG-3971: Inline table blocks (table/table_row) are not indexed — table_row
|
||||
blocks store content in "cells" rather than "rich_text", so no text is extracted.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from onyx.connectors.notion.connector import NotionConnector
|
||||
|
||||
|
||||
def _make_connector() -> NotionConnector:
|
||||
connector = NotionConnector()
|
||||
connector.load_credentials({"notion_integration_token": "fake-token"})
|
||||
return connector
|
||||
|
||||
|
||||
class TestPeoplePropertyExtraction:
|
||||
"""ENG-3970: Verifies that 'people' type database properties extract user names."""
|
||||
|
||||
def test_single_person_property(self) -> None:
|
||||
"""A database cell with a single @mention should extract the user name."""
|
||||
properties = {
|
||||
"Team Lead": {
|
||||
"id": "abc",
|
||||
"type": "people",
|
||||
"people": [
|
||||
{
|
||||
"object": "user",
|
||||
"id": "user-uuid-1",
|
||||
"name": "Arturo Martinez",
|
||||
"type": "person",
|
||||
"person": {"email": "arturo@example.com"},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
result = NotionConnector._properties_to_str(properties)
|
||||
assert (
|
||||
"Arturo Martinez" in result
|
||||
), f"Expected 'Arturo Martinez' in extracted text, got: {result!r}"
|
||||
|
||||
def test_multiple_people_property(self) -> None:
|
||||
"""A database cell with multiple @mentions should extract all user names."""
|
||||
properties = {
|
||||
"Members": {
|
||||
"id": "def",
|
||||
"type": "people",
|
||||
"people": [
|
||||
{
|
||||
"object": "user",
|
||||
"id": "user-uuid-1",
|
||||
"name": "Arturo Martinez",
|
||||
"type": "person",
|
||||
"person": {"email": "arturo@example.com"},
|
||||
},
|
||||
{
|
||||
"object": "user",
|
||||
"id": "user-uuid-2",
|
||||
"name": "Jane Smith",
|
||||
"type": "person",
|
||||
"person": {"email": "jane@example.com"},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
result = NotionConnector._properties_to_str(properties)
|
||||
assert (
|
||||
"Arturo Martinez" in result
|
||||
), f"Expected 'Arturo Martinez' in extracted text, got: {result!r}"
|
||||
assert (
|
||||
"Jane Smith" in result
|
||||
), f"Expected 'Jane Smith' in extracted text, got: {result!r}"
|
||||
|
||||
def test_bot_user_property(self) -> None:
|
||||
"""Bot users (integrations) have 'type': 'bot' — name should still be extracted."""
|
||||
properties = {
|
||||
"Created By": {
|
||||
"id": "ghi",
|
||||
"type": "people",
|
||||
"people": [
|
||||
{
|
||||
"object": "user",
|
||||
"id": "bot-uuid-1",
|
||||
"name": "Onyx Integration",
|
||||
"type": "bot",
|
||||
"bot": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
result = NotionConnector._properties_to_str(properties)
|
||||
assert (
|
||||
"Onyx Integration" in result
|
||||
), f"Expected 'Onyx Integration' in extracted text, got: {result!r}"
|
||||
|
||||
def test_person_without_person_details(self) -> None:
|
||||
"""Some user objects may have an empty/null person sub-dict."""
|
||||
properties = {
|
||||
"Assignee": {
|
||||
"id": "jkl",
|
||||
"type": "people",
|
||||
"people": [
|
||||
{
|
||||
"object": "user",
|
||||
"id": "user-uuid-3",
|
||||
"name": "Ghost User",
|
||||
"type": "person",
|
||||
"person": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
result = NotionConnector._properties_to_str(properties)
|
||||
assert (
|
||||
"Ghost User" in result
|
||||
), f"Expected 'Ghost User' in extracted text, got: {result!r}"
|
||||
|
||||
def test_people_mixed_with_other_properties(self) -> None:
|
||||
"""People property should work alongside other property types."""
|
||||
properties = {
|
||||
"Name": {
|
||||
"id": "aaa",
|
||||
"type": "title",
|
||||
"title": [
|
||||
{
|
||||
"plain_text": "Project Alpha",
|
||||
"type": "text",
|
||||
"text": {"content": "Project Alpha"},
|
||||
}
|
||||
],
|
||||
},
|
||||
"Lead": {
|
||||
"id": "bbb",
|
||||
"type": "people",
|
||||
"people": [
|
||||
{
|
||||
"object": "user",
|
||||
"id": "user-uuid-1",
|
||||
"name": "Arturo Martinez",
|
||||
"type": "person",
|
||||
"person": {"email": "arturo@example.com"},
|
||||
}
|
||||
],
|
||||
},
|
||||
"Status": {
|
||||
"id": "ccc",
|
||||
"type": "status",
|
||||
"status": {"name": "In Progress", "id": "status-1"},
|
||||
},
|
||||
}
|
||||
result = NotionConnector._properties_to_str(properties)
|
||||
assert "Arturo Martinez" in result
|
||||
assert "In Progress" in result
|
||||
|
||||
|
||||
class TestTableBlockExtraction:
|
||||
"""ENG-3971: Verifies that inline table blocks (table/table_row) are indexed."""
|
||||
|
||||
def _make_blocks_response(self, results: list) -> dict:
|
||||
return {"results": results, "next_cursor": None}
|
||||
|
||||
def test_table_row_cells_are_extracted(self) -> None:
|
||||
"""table_row blocks store content in 'cells', not 'rich_text'.
|
||||
The connector should extract text from cells."""
|
||||
connector = _make_connector()
|
||||
connector.workspace_id = "ws-1"
|
||||
|
||||
table_block = {
|
||||
"id": "table-block-1",
|
||||
"type": "table",
|
||||
"table": {
|
||||
"has_column_header": True,
|
||||
"has_row_header": False,
|
||||
"table_width": 3,
|
||||
},
|
||||
"has_children": True,
|
||||
}
|
||||
|
||||
header_row = {
|
||||
"id": "row-1",
|
||||
"type": "table_row",
|
||||
"table_row": {
|
||||
"cells": [
|
||||
[
|
||||
{
|
||||
"type": "text",
|
||||
"text": {"content": "Name"},
|
||||
"plain_text": "Name",
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"type": "text",
|
||||
"text": {"content": "Role"},
|
||||
"plain_text": "Role",
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"type": "text",
|
||||
"text": {"content": "Team"},
|
||||
"plain_text": "Team",
|
||||
}
|
||||
],
|
||||
]
|
||||
},
|
||||
"has_children": False,
|
||||
}
|
||||
|
||||
data_row = {
|
||||
"id": "row-2",
|
||||
"type": "table_row",
|
||||
"table_row": {
|
||||
"cells": [
|
||||
[
|
||||
{
|
||||
"type": "text",
|
||||
"text": {"content": "Arturo Martinez"},
|
||||
"plain_text": "Arturo Martinez",
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"type": "text",
|
||||
"text": {"content": "Engineer"},
|
||||
"plain_text": "Engineer",
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"type": "text",
|
||||
"text": {"content": "Platform"},
|
||||
"plain_text": "Platform",
|
||||
}
|
||||
],
|
||||
]
|
||||
},
|
||||
"has_children": False,
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
connector,
|
||||
"_fetch_child_blocks",
|
||||
side_effect=[
|
||||
self._make_blocks_response([table_block]),
|
||||
self._make_blocks_response([header_row, data_row]),
|
||||
],
|
||||
):
|
||||
output = connector._read_blocks("page-1")
|
||||
|
||||
all_text = " ".join(block.text for block in output.blocks)
|
||||
assert "Arturo Martinez" in all_text, (
|
||||
f"Expected 'Arturo Martinez' in table row text, got blocks: "
|
||||
f"{[(b.id, b.text) for b in output.blocks]}"
|
||||
)
|
||||
assert "Engineer" in all_text, (
|
||||
f"Expected 'Engineer' in table row text, got blocks: "
|
||||
f"{[(b.id, b.text) for b in output.blocks]}"
|
||||
)
|
||||
assert "Platform" in all_text, (
|
||||
f"Expected 'Platform' in table row text, got blocks: "
|
||||
f"{[(b.id, b.text) for b in output.blocks]}"
|
||||
)
|
||||
|
||||
def test_table_with_empty_cells(self) -> None:
|
||||
"""Table rows with some empty cells should still extract non-empty content."""
|
||||
connector = _make_connector()
|
||||
connector.workspace_id = "ws-1"
|
||||
|
||||
table_block = {
|
||||
"id": "table-block-2",
|
||||
"type": "table",
|
||||
"table": {
|
||||
"has_column_header": False,
|
||||
"has_row_header": False,
|
||||
"table_width": 2,
|
||||
},
|
||||
"has_children": True,
|
||||
}
|
||||
|
||||
row_with_empty = {
|
||||
"id": "row-3",
|
||||
"type": "table_row",
|
||||
"table_row": {
|
||||
"cells": [
|
||||
[
|
||||
{
|
||||
"type": "text",
|
||||
"text": {"content": "Has Value"},
|
||||
"plain_text": "Has Value",
|
||||
}
|
||||
],
|
||||
[], # empty cell
|
||||
]
|
||||
},
|
||||
"has_children": False,
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
connector,
|
||||
"_fetch_child_blocks",
|
||||
side_effect=[
|
||||
self._make_blocks_response([table_block]),
|
||||
self._make_blocks_response([row_with_empty]),
|
||||
],
|
||||
):
|
||||
output = connector._read_blocks("page-2")
|
||||
|
||||
all_text = " ".join(block.text for block in output.blocks)
|
||||
assert "Has Value" in all_text, (
|
||||
f"Expected 'Has Value' in table row text, got blocks: "
|
||||
f"{[(b.id, b.text) for b in output.blocks]}"
|
||||
)
|
||||
100
backend/tests/unit/onyx/db/test_chat_message_cleanup.py
Normal file
100
backend/tests/unit/onyx/db/test_chat_message_cleanup.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Regression tests for delete_messages_and_files_from_chat_session.
|
||||
|
||||
Verifies that user-owned files (those with user_file_id) are never deleted
|
||||
during chat session cleanup — only chat-only files should be removed.
|
||||
"""
|
||||
|
||||
from unittest.mock import call
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from onyx.db.chat import delete_messages_and_files_from_chat_session
|
||||
|
||||
_MODULE = "onyx.db.chat"
|
||||
|
||||
|
||||
def _make_db_session(
|
||||
rows: list[tuple[int, list[dict[str, str]] | None]],
|
||||
) -> MagicMock:
|
||||
db_session = MagicMock()
|
||||
db_session.execute.return_value.tuples.return_value.all.return_value = rows
|
||||
return db_session
|
||||
|
||||
|
||||
@patch(f"{_MODULE}.delete_orphaned_search_docs")
|
||||
@patch(f"{_MODULE}.get_default_file_store")
|
||||
def test_user_files_are_not_deleted(
|
||||
mock_get_file_store: MagicMock,
|
||||
_mock_orphan_cleanup: MagicMock,
|
||||
) -> None:
|
||||
"""User files (with user_file_id) must be skipped during cleanup."""
|
||||
file_store = MagicMock()
|
||||
mock_get_file_store.return_value = file_store
|
||||
|
||||
db_session = _make_db_session(
|
||||
[
|
||||
(
|
||||
1,
|
||||
[
|
||||
{"id": "chat-file-1", "type": "image"},
|
||||
{"id": "user-file-1", "type": "document", "user_file_id": "uf-1"},
|
||||
{"id": "chat-file-2", "type": "image"},
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
delete_messages_and_files_from_chat_session(uuid4(), db_session)
|
||||
|
||||
assert file_store.delete_file.call_count == 2
|
||||
file_store.delete_file.assert_has_calls(
|
||||
[
|
||||
call(file_id="chat-file-1", error_on_missing=False),
|
||||
call(file_id="chat-file-2", error_on_missing=False),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@patch(f"{_MODULE}.delete_orphaned_search_docs")
|
||||
@patch(f"{_MODULE}.get_default_file_store")
|
||||
def test_only_user_files_means_no_deletions(
|
||||
mock_get_file_store: MagicMock,
|
||||
_mock_orphan_cleanup: MagicMock,
|
||||
) -> None:
|
||||
"""When every file in the session is a user file, nothing should be deleted."""
|
||||
file_store = MagicMock()
|
||||
mock_get_file_store.return_value = file_store
|
||||
|
||||
db_session = _make_db_session(
|
||||
[
|
||||
(1, [{"id": "uf-a", "type": "document", "user_file_id": "uf-1"}]),
|
||||
(2, [{"id": "uf-b", "type": "document", "user_file_id": "uf-2"}]),
|
||||
]
|
||||
)
|
||||
|
||||
delete_messages_and_files_from_chat_session(uuid4(), db_session)
|
||||
|
||||
file_store.delete_file.assert_not_called()
|
||||
|
||||
|
||||
@patch(f"{_MODULE}.delete_orphaned_search_docs")
|
||||
@patch(f"{_MODULE}.get_default_file_store")
|
||||
def test_messages_with_no_files(
|
||||
mock_get_file_store: MagicMock,
|
||||
_mock_orphan_cleanup: MagicMock,
|
||||
) -> None:
|
||||
"""Messages with None or empty file lists should not trigger any deletions."""
|
||||
file_store = MagicMock()
|
||||
mock_get_file_store.return_value = file_store
|
||||
|
||||
db_session = _make_db_session(
|
||||
[
|
||||
(1, None),
|
||||
(2, []),
|
||||
]
|
||||
)
|
||||
|
||||
delete_messages_and_files_from_chat_session(uuid4(), db_session)
|
||||
|
||||
file_store.delete_file.assert_not_called()
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
)
|
||||
|
||||
func newChatCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
var noStreamMarkdown bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "chat",
|
||||
Short: "Launch the interactive chat TUI (default)",
|
||||
Long: `Launch the interactive terminal UI for chatting with your Onyx agent.
|
||||
@@ -30,6 +32,12 @@ an interactive setup wizard will guide you through configuration.`,
|
||||
cfg = *result
|
||||
}
|
||||
|
||||
// CLI flag overrides config/env
|
||||
if cmd.Flags().Changed("no-stream-markdown") {
|
||||
v := !noStreamMarkdown
|
||||
cfg.Features.StreamMarkdown = &v
|
||||
}
|
||||
|
||||
starprompt.MaybePrompt()
|
||||
|
||||
m := tui.NewModel(cfg)
|
||||
@@ -38,4 +46,8 @@ an interactive setup wizard will guide you through configuration.`,
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&noStreamMarkdown, "no-stream-markdown", false, "Disable progressive markdown rendering during streaming")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
20
cli/cmd/experiments.go
Normal file
20
cli/cmd/experiments.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newExperimentsCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "experiments",
|
||||
Short: "List experimental features and their status",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg := config.Load()
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), config.ExperimentsText(cfg.Features))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ func Execute() error {
|
||||
rootCmd.AddCommand(newValidateConfigCmd())
|
||||
rootCmd.AddCommand(newServeCmd())
|
||||
rootCmd.AddCommand(newInstallSkillCmd())
|
||||
rootCmd.AddCommand(newExperimentsCmd())
|
||||
|
||||
// Default command is chat, but intercept --version first
|
||||
rootCmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
|
||||
@@ -9,28 +9,47 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
EnvServerURL = "ONYX_SERVER_URL"
|
||||
EnvAPIKey = "ONYX_API_KEY"
|
||||
EnvAgentID = "ONYX_PERSONA_ID"
|
||||
EnvSSHHostKey = "ONYX_SSH_HOST_KEY"
|
||||
EnvServerURL = "ONYX_SERVER_URL"
|
||||
EnvAPIKey = "ONYX_API_KEY"
|
||||
EnvAgentID = "ONYX_PERSONA_ID"
|
||||
EnvSSHHostKey = "ONYX_SSH_HOST_KEY"
|
||||
EnvStreamMarkdown = "ONYX_STREAM_MARKDOWN"
|
||||
)
|
||||
|
||||
// Features holds experimental feature flags for the CLI.
|
||||
type Features struct {
|
||||
// StreamMarkdown enables progressive markdown rendering during streaming,
|
||||
// so output is formatted as it arrives rather than after completion.
|
||||
// nil means use the app default (true).
|
||||
StreamMarkdown *bool `json:"stream_markdown,omitempty"`
|
||||
}
|
||||
|
||||
// OnyxCliConfig holds the CLI configuration.
|
||||
type OnyxCliConfig struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
DefaultAgentID int `json:"default_persona_id"`
|
||||
ServerURL string `json:"server_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
DefaultAgentID int `json:"default_persona_id"`
|
||||
Features Features `json:"features,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultConfig returns a config with default values.
|
||||
func DefaultConfig() OnyxCliConfig {
|
||||
return OnyxCliConfig{
|
||||
ServerURL: "https://cloud.onyx.app",
|
||||
APIKey: "",
|
||||
ServerURL: "https://cloud.onyx.app",
|
||||
APIKey: "",
|
||||
DefaultAgentID: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// StreamMarkdownEnabled returns whether stream markdown is enabled,
|
||||
// defaulting to true when the user hasn't set an explicit preference.
|
||||
func (f Features) StreamMarkdownEnabled() bool {
|
||||
if f.StreamMarkdown != nil {
|
||||
return *f.StreamMarkdown
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsConfigured returns true if the config has an API key.
|
||||
func (c OnyxCliConfig) IsConfigured() bool {
|
||||
return c.APIKey != ""
|
||||
@@ -91,6 +110,13 @@ func Load() OnyxCliConfig {
|
||||
cfg.DefaultAgentID = id
|
||||
}
|
||||
}
|
||||
if v := os.Getenv(EnvStreamMarkdown); v != "" {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
cfg.Features.StreamMarkdown = &b
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "warning: invalid value %q for %s (expected true/false), ignoring\n", v, EnvStreamMarkdown)
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
func clearEnvVars(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, key := range []string{EnvServerURL, EnvAPIKey, EnvAgentID} {
|
||||
for _, key := range []string{EnvServerURL, EnvAPIKey, EnvAgentID, EnvStreamMarkdown} {
|
||||
t.Setenv(key, "")
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -199,6 +199,48 @@ func TestSaveAndReload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultFeaturesStreamMarkdownNil(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg.Features.StreamMarkdown != nil {
|
||||
t.Error("expected StreamMarkdown to be nil by default")
|
||||
}
|
||||
if !cfg.Features.StreamMarkdownEnabled() {
|
||||
t.Error("expected StreamMarkdownEnabled() to return true when nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvOverrideStreamMarkdownFalse(t *testing.T) {
|
||||
clearEnvVars(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
t.Setenv(EnvStreamMarkdown, "false")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.Features.StreamMarkdown == nil || *cfg.Features.StreamMarkdown {
|
||||
t.Error("expected StreamMarkdown=false from env override")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFeaturesFromFile(t *testing.T) {
|
||||
clearEnvVars(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"server_url": "https://example.com",
|
||||
"api_key": "key",
|
||||
"features": map[string]interface{}{
|
||||
"stream_markdown": true,
|
||||
},
|
||||
})
|
||||
writeConfig(t, dir, data)
|
||||
|
||||
cfg := Load()
|
||||
if cfg.Features.StreamMarkdown == nil || !*cfg.Features.StreamMarkdown {
|
||||
t.Error("expected StreamMarkdown=true from config file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCreatesParentDirs(t *testing.T) {
|
||||
clearEnvVars(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
46
cli/internal/config/experiments.go
Normal file
46
cli/internal/config/experiments.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package config
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Experiment describes an experimental feature flag.
|
||||
type Experiment struct {
|
||||
Name string
|
||||
Flag string // CLI flag name
|
||||
EnvVar string // environment variable name
|
||||
Config string // JSON path in config file
|
||||
Enabled bool
|
||||
Desc string
|
||||
}
|
||||
|
||||
// Experiments returns the list of available experimental features
|
||||
// with their current status based on the given feature flags.
|
||||
func Experiments(f Features) []Experiment {
|
||||
return []Experiment{
|
||||
{
|
||||
Name: "Stream Markdown",
|
||||
Flag: "--no-stream-markdown",
|
||||
EnvVar: EnvStreamMarkdown,
|
||||
Config: "features.stream_markdown",
|
||||
Enabled: f.StreamMarkdownEnabled(),
|
||||
Desc: "Render markdown progressively as the response streams in (enabled by default)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ExperimentsText formats the experiments list for display.
|
||||
func ExperimentsText(f Features) string {
|
||||
exps := Experiments(f)
|
||||
text := "Experimental Features\n\n"
|
||||
for _, e := range exps {
|
||||
status := "off"
|
||||
if e.Enabled {
|
||||
status = "on"
|
||||
}
|
||||
text += fmt.Sprintf(" %-20s [%s]\n", e.Name, status)
|
||||
text += fmt.Sprintf(" %s\n", e.Desc)
|
||||
text += fmt.Sprintf(" flag: %s env: %s config: %s\n\n", e.Flag, e.EnvVar, e.Config)
|
||||
}
|
||||
text += "Toggle via CLI flag, environment variable, or config file.\n"
|
||||
text += "Example: onyx-cli chat --no-stream-markdown"
|
||||
return text
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func NewModel(cfg config.OnyxCliConfig) Model {
|
||||
return Model{
|
||||
config: cfg,
|
||||
client: client,
|
||||
viewport: newViewport(80),
|
||||
viewport: newViewport(80, cfg.Features.StreamMarkdownEnabled()),
|
||||
input: newInputModel(),
|
||||
status: newStatusBar(),
|
||||
agentID: cfg.DefaultAgentID,
|
||||
|
||||
@@ -67,6 +67,10 @@ func handleSlashCommand(m Model, text string) (Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "/experiments":
|
||||
m.viewport.addInfo(m.experimentsText())
|
||||
return m, nil
|
||||
|
||||
case "/quit":
|
||||
return m, tea.Quit
|
||||
|
||||
|
||||
8
cli/internal/tui/experiments.go
Normal file
8
cli/internal/tui/experiments.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package tui
|
||||
|
||||
import "github.com/onyx-dot-app/onyx/cli/internal/config"
|
||||
|
||||
// experimentsText returns the formatted experiments list for the current config.
|
||||
func (m Model) experimentsText() string {
|
||||
return config.ExperimentsText(m.config.Features)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const helpText = `Onyx CLI Commands
|
||||
/configure Re-run connection setup
|
||||
/connectors Open connectors page in browser
|
||||
/settings Open Onyx settings in browser
|
||||
/experiments List experimental features and their status
|
||||
/quit Exit Onyx CLI
|
||||
|
||||
Keyboard Shortcuts
|
||||
|
||||
@@ -24,6 +24,7 @@ var slashCommands = []slashCommand{
|
||||
{"/configure", "Re-run connection setup"},
|
||||
{"/connectors", "Open connectors in browser"},
|
||||
{"/settings", "Open settings in browser"},
|
||||
{"/experiments", "List experimental features"},
|
||||
{"/quit", "Exit Onyx CLI"},
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/glamour/styles"
|
||||
@@ -44,6 +45,9 @@ type pickerItem struct {
|
||||
label string
|
||||
}
|
||||
|
||||
// streamRenderInterval is the minimum time between markdown re-renders during streaming.
|
||||
const streamRenderInterval = 100 * time.Millisecond
|
||||
|
||||
// viewport manages the chat display.
|
||||
type viewport struct {
|
||||
entries []chatEntry
|
||||
@@ -57,6 +61,12 @@ type viewport struct {
|
||||
pickerIndex int
|
||||
pickerType pickerKind
|
||||
scrollOffset int // lines scrolled up from bottom (0 = pinned to bottom)
|
||||
|
||||
// Progressive markdown rendering during streaming
|
||||
streamMarkdown bool // feature flag: render markdown while streaming
|
||||
streamRendered string // cached rendered output during streaming
|
||||
lastRenderTime time.Time
|
||||
lastRenderLen int // length of streamBuf at last render (skip if unchanged)
|
||||
}
|
||||
|
||||
// newMarkdownRenderer creates a Glamour renderer with zero left margin.
|
||||
@@ -71,10 +81,11 @@ func newMarkdownRenderer(width int) *glamour.TermRenderer {
|
||||
return r
|
||||
}
|
||||
|
||||
func newViewport(width int) *viewport {
|
||||
func newViewport(width int, streamMarkdown bool) *viewport {
|
||||
return &viewport{
|
||||
width: width,
|
||||
renderer: newMarkdownRenderer(width),
|
||||
width: width,
|
||||
renderer: newMarkdownRenderer(width),
|
||||
streamMarkdown: streamMarkdown,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,12 +119,27 @@ func (v *viewport) addUserMessage(msg string) {
|
||||
func (v *viewport) startAgent() {
|
||||
v.streaming = true
|
||||
v.streamBuf = ""
|
||||
v.streamRendered = ""
|
||||
v.lastRenderLen = 0
|
||||
v.lastRenderTime = time.Time{}
|
||||
// Add a blank-line spacer entry before the agent message
|
||||
v.entries = append(v.entries, chatEntry{kind: entryInfo, rendered: ""})
|
||||
}
|
||||
|
||||
func (v *viewport) appendToken(token string) {
|
||||
v.streamBuf += token
|
||||
|
||||
if !v.streamMarkdown {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
bufLen := len(v.streamBuf)
|
||||
if bufLen != v.lastRenderLen && now.Sub(v.lastRenderTime) >= streamRenderInterval {
|
||||
v.streamRendered = v.renderAgentContent(v.streamBuf)
|
||||
v.lastRenderTime = now
|
||||
v.lastRenderLen = bufLen
|
||||
}
|
||||
}
|
||||
|
||||
func (v *viewport) finishAgent() {
|
||||
@@ -135,6 +161,8 @@ func (v *viewport) finishAgent() {
|
||||
})
|
||||
v.streaming = false
|
||||
v.streamBuf = ""
|
||||
v.streamRendered = ""
|
||||
v.lastRenderLen = 0
|
||||
}
|
||||
|
||||
func (v *viewport) renderAgentContent(content string) string {
|
||||
@@ -358,6 +386,22 @@ func (v *viewport) renderPicker(width, height int) string {
|
||||
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, panel)
|
||||
}
|
||||
|
||||
// streamingContent returns the display content for the in-progress stream.
|
||||
func (v *viewport) streamingContent() string {
|
||||
if v.streamMarkdown && v.streamRendered != "" {
|
||||
return v.streamRendered
|
||||
}
|
||||
// Fall back to raw text with agent dot prefix
|
||||
bufLines := strings.Split(v.streamBuf, "\n")
|
||||
if len(bufLines) > 0 {
|
||||
bufLines[0] = agentDot + " " + bufLines[0]
|
||||
for i := 1; i < len(bufLines); i++ {
|
||||
bufLines[i] = " " + bufLines[i]
|
||||
}
|
||||
}
|
||||
return strings.Join(bufLines, "\n")
|
||||
}
|
||||
|
||||
// totalLines computes the total number of rendered content lines.
|
||||
func (v *viewport) totalLines() int {
|
||||
var lines []string
|
||||
@@ -368,14 +412,7 @@ func (v *viewport) totalLines() int {
|
||||
lines = append(lines, e.rendered)
|
||||
}
|
||||
if v.streaming && v.streamBuf != "" {
|
||||
bufLines := strings.Split(v.streamBuf, "\n")
|
||||
if len(bufLines) > 0 {
|
||||
bufLines[0] = agentDot + " " + bufLines[0]
|
||||
for i := 1; i < len(bufLines); i++ {
|
||||
bufLines[i] = " " + bufLines[i]
|
||||
}
|
||||
}
|
||||
lines = append(lines, strings.Join(bufLines, "\n"))
|
||||
lines = append(lines, v.streamingContent())
|
||||
} else if v.streaming {
|
||||
lines = append(lines, agentDot+" ")
|
||||
}
|
||||
@@ -399,16 +436,9 @@ func (v *viewport) view(height int) string {
|
||||
lines = append(lines, e.rendered)
|
||||
}
|
||||
|
||||
// Streaming buffer (plain text, not markdown)
|
||||
// Streaming buffer
|
||||
if v.streaming && v.streamBuf != "" {
|
||||
bufLines := strings.Split(v.streamBuf, "\n")
|
||||
if len(bufLines) > 0 {
|
||||
bufLines[0] = agentDot + " " + bufLines[0]
|
||||
for i := 1; i < len(bufLines); i++ {
|
||||
bufLines[i] = " " + bufLines[i]
|
||||
}
|
||||
}
|
||||
lines = append(lines, strings.Join(bufLines, "\n"))
|
||||
lines = append(lines, v.streamingContent())
|
||||
} else if v.streaming {
|
||||
lines = append(lines, agentDot+" ")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stripANSI removes ANSI escape sequences for test comparisons.
|
||||
@@ -14,7 +15,7 @@ func stripANSI(s string) string {
|
||||
}
|
||||
|
||||
func TestAddUserMessage(t *testing.T) {
|
||||
v := newViewport(80)
|
||||
v := newViewport(80, false)
|
||||
v.addUserMessage("hello world")
|
||||
|
||||
if len(v.entries) != 1 {
|
||||
@@ -37,7 +38,7 @@ func TestAddUserMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStartAndFinishAgent(t *testing.T) {
|
||||
v := newViewport(80)
|
||||
v := newViewport(80, false)
|
||||
v.startAgent()
|
||||
|
||||
if !v.streaming {
|
||||
@@ -83,7 +84,7 @@ func TestStartAndFinishAgent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFinishAgentNoPadding(t *testing.T) {
|
||||
v := newViewport(80)
|
||||
v := newViewport(80, false)
|
||||
v.startAgent()
|
||||
v.appendToken("Test message")
|
||||
v.finishAgent()
|
||||
@@ -98,7 +99,7 @@ func TestFinishAgentNoPadding(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFinishAgentMultiline(t *testing.T) {
|
||||
v := newViewport(80)
|
||||
v := newViewport(80, false)
|
||||
v.startAgent()
|
||||
v.appendToken("Line one\n\nLine three")
|
||||
v.finishAgent()
|
||||
@@ -115,7 +116,7 @@ func TestFinishAgentMultiline(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFinishAgentEmpty(t *testing.T) {
|
||||
v := newViewport(80)
|
||||
v := newViewport(80, false)
|
||||
v.startAgent()
|
||||
v.finishAgent()
|
||||
|
||||
@@ -128,7 +129,7 @@ func TestFinishAgentEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAddInfo(t *testing.T) {
|
||||
v := newViewport(80)
|
||||
v := newViewport(80, false)
|
||||
v.addInfo("test info")
|
||||
|
||||
if len(v.entries) != 1 {
|
||||
@@ -145,7 +146,7 @@ func TestAddInfo(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAddError(t *testing.T) {
|
||||
v := newViewport(80)
|
||||
v := newViewport(80, false)
|
||||
v.addError("something broke")
|
||||
|
||||
if len(v.entries) != 1 {
|
||||
@@ -162,7 +163,7 @@ func TestAddError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAddCitations(t *testing.T) {
|
||||
v := newViewport(80)
|
||||
v := newViewport(80, false)
|
||||
v.addCitations(map[int]string{1: "doc-a", 2: "doc-b"})
|
||||
|
||||
if len(v.entries) != 1 {
|
||||
@@ -182,7 +183,7 @@ func TestAddCitations(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAddCitationsEmpty(t *testing.T) {
|
||||
v := newViewport(80)
|
||||
v := newViewport(80, false)
|
||||
v.addCitations(map[int]string{})
|
||||
|
||||
if len(v.entries) != 0 {
|
||||
@@ -191,7 +192,7 @@ func TestAddCitationsEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCitationVisibility(t *testing.T) {
|
||||
v := newViewport(80)
|
||||
v := newViewport(80, false)
|
||||
v.addInfo("hello")
|
||||
v.addCitations(map[int]string{1: "doc"})
|
||||
|
||||
@@ -211,7 +212,7 @@ func TestCitationVisibility(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClearAll(t *testing.T) {
|
||||
v := newViewport(80)
|
||||
v := newViewport(80, false)
|
||||
v.addUserMessage("test")
|
||||
v.startAgent()
|
||||
v.appendToken("response")
|
||||
@@ -230,7 +231,7 @@ func TestClearAll(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClearDisplay(t *testing.T) {
|
||||
v := newViewport(80)
|
||||
v := newViewport(80, false)
|
||||
v.addUserMessage("test")
|
||||
v.clearDisplay()
|
||||
|
||||
@@ -240,7 +241,7 @@ func TestClearDisplay(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestViewPadsShortContent(t *testing.T) {
|
||||
v := newViewport(80)
|
||||
v := newViewport(80, false)
|
||||
v.addInfo("hello")
|
||||
|
||||
view := v.view(10)
|
||||
@@ -251,7 +252,7 @@ func TestViewPadsShortContent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestViewTruncatesTallContent(t *testing.T) {
|
||||
v := newViewport(80)
|
||||
v := newViewport(80, false)
|
||||
for i := 0; i < 20; i++ {
|
||||
v.addInfo("line")
|
||||
}
|
||||
@@ -262,3 +263,93 @@ func TestViewTruncatesTallContent(t *testing.T) {
|
||||
t.Errorf("expected 5 lines (truncated), got %d", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamMarkdownRendersOnThrottle(t *testing.T) {
|
||||
v := newViewport(80, true)
|
||||
v.startAgent()
|
||||
|
||||
// First token: no prior render, so it should render immediately
|
||||
v.appendToken("**bold text**")
|
||||
|
||||
if v.streamRendered == "" {
|
||||
t.Error("expected streamRendered to be populated after first token")
|
||||
}
|
||||
plain := stripANSI(v.streamRendered)
|
||||
if !strings.Contains(plain, "bold text") {
|
||||
t.Errorf("expected rendered to contain 'bold text', got %q", plain)
|
||||
}
|
||||
// Should not contain raw markdown asterisks
|
||||
if strings.Contains(plain, "**") {
|
||||
t.Errorf("expected markdown to be rendered (no **), got %q", plain)
|
||||
}
|
||||
|
||||
// Second token within throttle window: should NOT re-render
|
||||
v.lastRenderTime = time.Now() // simulate recent render
|
||||
prevRendered := v.streamRendered
|
||||
v.appendToken(" more")
|
||||
if v.streamRendered != prevRendered {
|
||||
t.Error("expected streamRendered to be unchanged within throttle window")
|
||||
}
|
||||
|
||||
// After throttle interval: should re-render
|
||||
v.lastRenderTime = time.Now().Add(-streamRenderInterval - time.Millisecond)
|
||||
v.appendToken("!")
|
||||
if v.streamRendered == prevRendered {
|
||||
t.Error("expected streamRendered to update after throttle interval")
|
||||
}
|
||||
plain = stripANSI(v.streamRendered)
|
||||
if !strings.Contains(plain, "bold text more!") {
|
||||
t.Errorf("expected updated rendered content, got %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamMarkdownDisabledNoRender(t *testing.T) {
|
||||
v := newViewport(80, false)
|
||||
v.startAgent()
|
||||
v.appendToken("**bold**")
|
||||
|
||||
if v.streamRendered != "" {
|
||||
t.Error("expected no streamRendered when streamMarkdown is disabled")
|
||||
}
|
||||
|
||||
// View should show raw markdown
|
||||
view := v.view(10)
|
||||
plain := stripANSI(view)
|
||||
if !strings.Contains(plain, "**bold**") {
|
||||
t.Errorf("expected raw markdown in view, got %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamMarkdownViewUsesRendered(t *testing.T) {
|
||||
v := newViewport(80, true)
|
||||
v.startAgent()
|
||||
v.appendToken("**formatted**")
|
||||
|
||||
view := v.view(10)
|
||||
plain := stripANSI(view)
|
||||
// Should show rendered content, not raw **formatted**
|
||||
if strings.Contains(plain, "**") {
|
||||
t.Errorf("expected rendered markdown in view (no **), got %q", plain)
|
||||
}
|
||||
if !strings.Contains(plain, "formatted") {
|
||||
t.Errorf("expected 'formatted' in view, got %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamMarkdownResetOnStart(t *testing.T) {
|
||||
v := newViewport(80, true)
|
||||
|
||||
// First stream cycle
|
||||
v.startAgent()
|
||||
v.appendToken("first")
|
||||
v.finishAgent()
|
||||
|
||||
// Start second stream - state should be clean
|
||||
v.startAgent()
|
||||
if v.streamRendered != "" {
|
||||
t.Error("expected streamRendered cleared on startAgent")
|
||||
}
|
||||
if v.lastRenderLen != 0 {
|
||||
t.Error("expected lastRenderLen reset on startAgent")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ const SvgBifrost = ({ size, className, ...props }: IconProps) => (
|
||||
viewBox="0 0 37 46"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn(className, "text-[#33C19E] dark:text-white")}
|
||||
className={cn(className, "!text-[#33C19E]")}
|
||||
{...props}
|
||||
>
|
||||
<title>Bifrost</title>
|
||||
|
||||
@@ -127,13 +127,13 @@ function Main() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 desktop:flex-row desktop:items-center desktop:gap-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2">
|
||||
{isApiKeySet ? (
|
||||
<>
|
||||
<Button variant="danger" onClick={handleDelete}>
|
||||
Delete API Key
|
||||
</Button>
|
||||
<Text as="p" mainContentBody text04 className="desktop:mt-0">
|
||||
<Text as="p" mainContentBody text04 className="sm:mt-0">
|
||||
Delete the current API key before updating.
|
||||
</Text>
|
||||
</>
|
||||
|
||||
@@ -16,7 +16,7 @@ import Text from "@/refresh-components/texts/Text";
|
||||
import SidebarWrapper from "@/sections/sidebar/SidebarWrapper";
|
||||
import SidebarBody from "@/sections/sidebar/SidebarBody";
|
||||
import SidebarSection from "@/sections/sidebar/SidebarSection";
|
||||
import UserAvatarPopover from "@/sections/sidebar/UserAvatarPopover";
|
||||
import AccountPopover from "@/sections/sidebar/AccountPopover";
|
||||
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import ButtonRenaming from "@/refresh-components/buttons/ButtonRenaming";
|
||||
@@ -398,7 +398,7 @@ const MemoizedBuildSidebarInner = memo(
|
||||
() => (
|
||||
<div>
|
||||
{backToChatButton}
|
||||
<UserAvatarPopover folded={folded} />
|
||||
<AccountPopover folded={folded} />
|
||||
</div>
|
||||
),
|
||||
[folded, backToChatButton]
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { AppMode } from "@/providers/QueryControllerProvider";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { useQueryController } from "@/providers/QueryControllerProvider";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { useAppSidebarContext } from "@/providers/AppSidebarProvider";
|
||||
import { useSidebarState } from "@/layouts/sidebar-layouts";
|
||||
import useScreenSize from "@/hooks/useScreenSize";
|
||||
|
||||
const footerMarkdownComponents = {
|
||||
@@ -61,7 +61,7 @@ export default function NRFChrome() {
|
||||
const { state, setAppMode } = useQueryController();
|
||||
const settings = useSettingsContext();
|
||||
const { isMobile } = useScreenSize();
|
||||
const { setFolded } = useAppSidebarContext();
|
||||
const { setFolded } = useSidebarState();
|
||||
const appFocus = useAppFocus();
|
||||
const [modePopoverOpen, setModePopoverOpen] = useState(false);
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import { ApplicationStatus } from "@/interfaces/settings";
|
||||
import { Button } from "@opal/components";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ADMIN_ROUTES } from "@/lib/admin-routes";
|
||||
import useScreenSize from "@/hooks/useScreenSize";
|
||||
import { SvgSidebar } from "@opal/icons";
|
||||
import { useSidebarState } from "@/layouts/sidebar-layouts";
|
||||
|
||||
export interface ClientLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -49,6 +52,9 @@ const SETTINGS_LAYOUT_PREFIXES = [
|
||||
];
|
||||
|
||||
export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
|
||||
const { folded: sidebarFolded, setFolded: setSidebarFolded } =
|
||||
useSidebarState();
|
||||
const { isMobile } = useScreenSize();
|
||||
const pathname = usePathname();
|
||||
const settings = useSettingsContext();
|
||||
|
||||
@@ -82,7 +88,11 @@ export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
|
||||
<div className="flex-1 min-w-0 min-h-0 overflow-y-auto">{children}</div>
|
||||
) : (
|
||||
<>
|
||||
<AdminSidebar enableCloudSS={enableCloud} />
|
||||
<AdminSidebar
|
||||
enableCloudSS={enableCloud}
|
||||
folded={sidebarFolded}
|
||||
onFoldChange={setSidebarFolded}
|
||||
/>
|
||||
<div
|
||||
data-main-container
|
||||
className={cn(
|
||||
@@ -90,6 +100,15 @@ export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
|
||||
!hasOwnLayout && "py-10 px-4 md:px-12"
|
||||
)}
|
||||
>
|
||||
{isMobile && (
|
||||
<div className="flex items-center px-4 pt-2">
|
||||
<Button
|
||||
prominence="internal"
|
||||
icon={SvgSidebar}
|
||||
onClick={() => setSidebarFolded(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* Tests logo icons to ensure they render correctly with proper accessibility
|
||||
* and support various display sizes.
|
||||
*/
|
||||
import React from "react";
|
||||
import { SvgBifrost } from "@opal/icons";
|
||||
import { render } from "@tests/setup/test-utils";
|
||||
import { GithubIcon, GitbookIcon, ConfluenceIcon } from "./icons";
|
||||
@@ -60,7 +59,11 @@ describe("Logo Icons", () => {
|
||||
const icon = container.querySelector("svg");
|
||||
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveClass("custom", "text-[#33C19E]", "dark:text-white");
|
||||
expect(icon).not.toHaveClass("text-red-500", "dark:text-black");
|
||||
expect(icon).toHaveClass(
|
||||
"custom",
|
||||
"text-red-500",
|
||||
"dark:text-black",
|
||||
"!text-[#33C19E]"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ import { PopoverSearchInput } from "@/sections/sidebar/ChatButton";
|
||||
import SimplePopover from "@/refresh-components/SimplePopover";
|
||||
import { Interactive } from "@opal/core";
|
||||
import { Button, OpenButton } from "@opal/components";
|
||||
import { useAppSidebarContext } from "@/providers/AppSidebarProvider";
|
||||
import { useSidebarState } from "@/layouts/sidebar-layouts";
|
||||
import useScreenSize from "@/hooks/useScreenSize";
|
||||
import {
|
||||
SvgBubbleText,
|
||||
@@ -91,7 +91,7 @@ function Header() {
|
||||
const { state, setAppMode } = useQueryController();
|
||||
const settings = useSettingsContext();
|
||||
const { isMobile } = useScreenSize();
|
||||
const { setFolded } = useAppSidebarContext();
|
||||
const { setFolded } = useSidebarState();
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [showMoveCustomAgentModal, setShowMoveCustomAgentModal] =
|
||||
|
||||
322
web/src/layouts/sidebar-layouts.tsx
Normal file
322
web/src/layouts/sidebar-layouts.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Sidebar Layout Components
|
||||
*
|
||||
* Provides composable layout primitives for app and admin sidebars with mobile
|
||||
* overlay support and optional desktop folding.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import * as SidebarLayouts from "@/layouts/sidebar-layouts";
|
||||
* import { useSidebarState, useSidebarFolded } from "@/layouts/sidebar-layouts";
|
||||
*
|
||||
* function MySidebar() {
|
||||
* const { folded, setFolded } = useSidebarState();
|
||||
* const contentFolded = useSidebarFolded();
|
||||
*
|
||||
* return (
|
||||
* <SidebarLayouts.Root folded={folded} onFoldChange={setFolded} foldable>
|
||||
* <SidebarLayouts.Header>
|
||||
* <NewSessionButton folded={contentFolded} />
|
||||
* </SidebarLayouts.Header>
|
||||
* <SidebarLayouts.Body scrollKey="my-sidebar">
|
||||
* {contentFolded ? null : <SectionContent />}
|
||||
* </SidebarLayouts.Body>
|
||||
* <SidebarLayouts.Footer>
|
||||
* <UserAvatar />
|
||||
* </SidebarLayouts.Footer>
|
||||
* </SidebarLayouts.Root>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useState,
|
||||
useEffect,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "@/components/resizable/constants";
|
||||
import SidebarWrapper from "@/sections/sidebar/SidebarWrapper";
|
||||
import OverflowDiv from "@/refresh-components/OverflowDiv";
|
||||
import useScreenSize from "@/hooks/useScreenSize";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State provider — persistent sidebar fold state with keyboard shortcut
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function setFoldedCookie(folded: boolean) {
|
||||
const foldedAsString = folded.toString();
|
||||
Cookies.set(SIDEBAR_TOGGLED_COOKIE_NAME, foldedAsString, { expires: 365 });
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(SIDEBAR_TOGGLED_COOKIE_NAME, foldedAsString);
|
||||
}
|
||||
}
|
||||
|
||||
interface SidebarStateContextType {
|
||||
folded: boolean;
|
||||
setFolded: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const SidebarStateContext = createContext<SidebarStateContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
interface SidebarStateProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SidebarStateProvider({ children }: SidebarStateProviderProps) {
|
||||
const [folded, setFoldedInternal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored =
|
||||
Cookies.get(SIDEBAR_TOGGLED_COOKIE_NAME) ??
|
||||
localStorage.getItem(SIDEBAR_TOGGLED_COOKIE_NAME);
|
||||
if (stored === "true") {
|
||||
setFoldedInternal(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setFolded: Dispatch<SetStateAction<boolean>> = (value) => {
|
||||
setFoldedInternal((prev) => {
|
||||
const newState = typeof value === "function" ? value(prev) : value;
|
||||
setFoldedCookie(newState);
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const isMac = navigator.userAgent.toLowerCase().includes("mac");
|
||||
const isModifierPressed = isMac ? event.metaKey : event.ctrlKey;
|
||||
if (!isModifierPressed || event.key !== "e") return;
|
||||
|
||||
event.preventDefault();
|
||||
setFolded((prev) => !prev);
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SidebarStateContext.Provider value={{ folded, setFolded }}>
|
||||
{children}
|
||||
</SidebarStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the global sidebar fold state and setter.
|
||||
* Must be used within a `SidebarStateProvider`.
|
||||
*/
|
||||
export function useSidebarState(): SidebarStateContextType {
|
||||
const context = useContext(SidebarStateContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useSidebarState must be used within a SidebarStateProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fold context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SidebarFoldedContext = createContext(false);
|
||||
|
||||
/**
|
||||
* Returns whether the sidebar content should render in its folded (narrow)
|
||||
* state. On mobile, this is always `false` because the overlay pattern handles
|
||||
* visibility — the sidebar content itself is always fully expanded.
|
||||
*/
|
||||
export function useSidebarFolded(): boolean {
|
||||
return useContext(SidebarFoldedContext);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Root
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SidebarRootProps {
|
||||
/**
|
||||
* Whether the sidebar is currently folded (desktop) or off-screen (mobile).
|
||||
*/
|
||||
folded: boolean;
|
||||
/** Callback to update the fold state. Compatible with `useState` setters. */
|
||||
onFoldChange: Dispatch<SetStateAction<boolean>>;
|
||||
/**
|
||||
* Whether the sidebar supports folding on desktop.
|
||||
* When `false` (the default), the sidebar is always expanded on desktop and
|
||||
* the fold button is hidden. Mobile overlay behavior is always enabled
|
||||
* regardless of this prop.
|
||||
*/
|
||||
foldable?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SidebarRoot({
|
||||
folded,
|
||||
onFoldChange,
|
||||
foldable = false,
|
||||
children,
|
||||
}: SidebarRootProps) {
|
||||
const { isMobile, isMediumScreen } = useScreenSize();
|
||||
|
||||
const close = useCallback(() => onFoldChange(true), [onFoldChange]);
|
||||
const toggle = useCallback(
|
||||
() => onFoldChange((prev) => !prev),
|
||||
[onFoldChange]
|
||||
);
|
||||
|
||||
// On mobile the sidebar content is always visually expanded — the overlay
|
||||
// transform handles visibility. On desktop, only foldable sidebars honour
|
||||
// the fold state.
|
||||
const contentFolded = !isMobile && foldable ? folded : false;
|
||||
|
||||
const inner = (
|
||||
<div className="flex flex-col min-h-0 h-full gap-3">{children}</div>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<SidebarFoldedContext.Provider value={false}>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 transition-transform duration-200",
|
||||
folded ? "-translate-x-full" : "translate-x-0"
|
||||
)}
|
||||
>
|
||||
<SidebarWrapper folded={false} onFoldClick={close}>
|
||||
{inner}
|
||||
</SidebarWrapper>
|
||||
</div>
|
||||
|
||||
{/* Backdrop — closes the sidebar when anything outside it is tapped */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-40 bg-mask-03 backdrop-blur-03 transition-opacity duration-200",
|
||||
folded
|
||||
? "opacity-0 pointer-events-none"
|
||||
: "opacity-100 pointer-events-auto"
|
||||
)}
|
||||
onClick={close}
|
||||
/>
|
||||
</SidebarFoldedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Medium screens: the folded strip stays visible in the layout flow;
|
||||
// expanding overlays content instead of pushing it.
|
||||
if (isMediumScreen) {
|
||||
return (
|
||||
<SidebarFoldedContext.Provider value={folded}>
|
||||
{/* Spacer reserves the folded sidebar width in the flex layout */}
|
||||
<div className="shrink-0 w-[3.25rem]" />
|
||||
|
||||
{/* Sidebar — fixed so it overlays content when expanded */}
|
||||
<div className="fixed inset-y-0 left-0 z-50">
|
||||
<SidebarWrapper folded={folded} onFoldClick={toggle}>
|
||||
{inner}
|
||||
</SidebarWrapper>
|
||||
</div>
|
||||
|
||||
{/* Backdrop when expanded — blur only, no tint */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-40 backdrop-blur-03 transition-opacity duration-200",
|
||||
folded
|
||||
? "opacity-0 pointer-events-none"
|
||||
: "opacity-100 pointer-events-auto"
|
||||
)}
|
||||
onClick={close}
|
||||
/>
|
||||
</SidebarFoldedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarFoldedContext.Provider value={contentFolded}>
|
||||
<SidebarWrapper
|
||||
folded={foldable ? folded : undefined}
|
||||
onFoldClick={foldable ? toggle : undefined}
|
||||
>
|
||||
{inner}
|
||||
</SidebarWrapper>
|
||||
</SidebarFoldedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header — pinned content above the scroll area
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function SidebarHeader({ children }: SidebarHeaderProps) {
|
||||
if (!children) return null;
|
||||
return <div className="px-2">{children}</div>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Body — scrollable content area
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SidebarBodyProps {
|
||||
/**
|
||||
* Unique key to enable scroll position persistence across navigation.
|
||||
* (e.g., "admin-sidebar", "app-sidebar").
|
||||
*/
|
||||
scrollKey: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function SidebarBody({ scrollKey, children }: SidebarBodyProps) {
|
||||
const folded = useSidebarFolded();
|
||||
return (
|
||||
<OverflowDiv
|
||||
className={cn("gap-3 px-2", folded && "hidden")}
|
||||
scrollKey={scrollKey}
|
||||
>
|
||||
{children}
|
||||
</OverflowDiv>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Footer — pinned content below the scroll area
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SidebarFooterProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function SidebarFooter({ children }: SidebarFooterProps) {
|
||||
if (!children) return null;
|
||||
return <div className="px-2">{children}</div>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
SidebarStateProvider as StateProvider,
|
||||
SidebarRoot as Root,
|
||||
SidebarHeader as Header,
|
||||
SidebarBody as Body,
|
||||
SidebarFooter as Footer,
|
||||
};
|
||||
@@ -122,7 +122,7 @@ export const ART_ASSISTANT_ID = -3;
|
||||
export const MAX_FILES_TO_SHOW = 3;
|
||||
|
||||
// SIZES
|
||||
export const MOBILE_SIDEBAR_BREAKPOINT_PX = 640;
|
||||
export const MOBILE_SIDEBAR_BREAKPOINT_PX = 724;
|
||||
export const DESKTOP_SMALL_BREAKPOINT_PX = 912;
|
||||
export const DESKTOP_MEDIUM_BREAKPOINT_PX = 1232;
|
||||
export const DEFAULT_AVATAR_SIZE_PX = 18;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* 3. **AppBackgroundProvider** - App background image/URL based on user preferences
|
||||
* 4. **ProviderContextProvider** - LLM provider configuration
|
||||
* 5. **ModalProvider** - Global modal state management
|
||||
* 6. **AppSidebarProvider** - Sidebar open/closed state
|
||||
* 6. **SidebarStateProvider** - Sidebar open/closed state
|
||||
* 7. **QueryControllerProvider** - Search/Chat mode + query lifecycle
|
||||
*/
|
||||
"use client";
|
||||
@@ -26,7 +26,7 @@ import { UserProvider } from "@/providers/UserProvider";
|
||||
import { ProviderContextProvider } from "@/components/chat/ProviderContext";
|
||||
import { SettingsProvider } from "@/providers/SettingsProvider";
|
||||
import { ModalProvider } from "@/components/context/ModalContext";
|
||||
import { AppSidebarProvider } from "@/providers/AppSidebarProvider";
|
||||
import { StateProvider as SidebarStateProvider } from "@/layouts/sidebar-layouts";
|
||||
import { AppBackgroundProvider } from "@/providers/AppBackgroundProvider";
|
||||
import { QueryControllerProvider } from "@/providers/QueryControllerProvider";
|
||||
import ToastProvider from "@/providers/ToastProvider";
|
||||
@@ -42,11 +42,11 @@ export default function AppProvider({ children }: AppProviderProps) {
|
||||
<AppBackgroundProvider>
|
||||
<ProviderContextProvider>
|
||||
<ModalProvider>
|
||||
<AppSidebarProvider>
|
||||
<SidebarStateProvider>
|
||||
<QueryControllerProvider>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</QueryControllerProvider>
|
||||
</AppSidebarProvider>
|
||||
</SidebarStateProvider>
|
||||
</ModalProvider>
|
||||
</ProviderContextProvider>
|
||||
</AppBackgroundProvider>
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
ReactNode,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "@/components/resizable/constants";
|
||||
|
||||
function setFoldedCookie(folded: boolean) {
|
||||
const foldedAsString = folded.toString();
|
||||
Cookies.set(SIDEBAR_TOGGLED_COOKIE_NAME, foldedAsString, { expires: 365 });
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(SIDEBAR_TOGGLED_COOKIE_NAME, foldedAsString);
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppSidebarProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AppSidebarProvider({ children }: AppSidebarProviderProps) {
|
||||
const [folded, setFoldedInternal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored =
|
||||
Cookies.get(SIDEBAR_TOGGLED_COOKIE_NAME) ??
|
||||
localStorage.getItem(SIDEBAR_TOGGLED_COOKIE_NAME);
|
||||
if (stored === "true") {
|
||||
setFoldedInternal(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setFolded: Dispatch<SetStateAction<boolean>> = (value) => {
|
||||
setFoldedInternal((prev) => {
|
||||
const newState = typeof value === "function" ? value(prev) : value;
|
||||
setFoldedCookie(newState);
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const isMac = navigator.userAgent.toLowerCase().includes("mac");
|
||||
const isModifierPressed = isMac ? event.metaKey : event.ctrlKey;
|
||||
if (!isModifierPressed || event.key !== "e") return;
|
||||
|
||||
event.preventDefault();
|
||||
setFolded((prev) => !prev);
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppSidebarContext.Provider
|
||||
value={{
|
||||
folded,
|
||||
setFolded,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AppSidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AppSidebarContextType {
|
||||
folded: boolean;
|
||||
setFolded: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const AppSidebarContext = createContext<AppSidebarContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
export function useAppSidebarContext() {
|
||||
const context = useContext(AppSidebarContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useAppSidebarContext must be used within an AppSidebarProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -141,7 +141,7 @@ export interface SettingsProps {
|
||||
onShowBuildIntro?: () => void;
|
||||
}
|
||||
|
||||
export default function UserAvatarPopover({
|
||||
export default function AccountPopover({
|
||||
folded,
|
||||
onShowBuildIntro,
|
||||
}: SettingsProps) {
|
||||
@@ -1,10 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import SidebarSection from "@/sections/sidebar/SidebarSection";
|
||||
import SidebarWrapper from "@/sections/sidebar/SidebarWrapper";
|
||||
import * as SidebarLayouts from "@/layouts/sidebar-layouts";
|
||||
import { useSidebarFolded } from "@/layouts/sidebar-layouts";
|
||||
import { useIsKGExposed } from "@/app/admin/kg/utils";
|
||||
import { useCustomAnalyticsEnabled } from "@/lib/hooks/useCustomAnalyticsEnabled";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
@@ -12,23 +20,19 @@ import { UserRole } from "@/lib/types";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { CombinedSettings } from "@/interfaces/settings";
|
||||
import { SidebarTab } from "@opal/components";
|
||||
import SidebarBody from "@/sections/sidebar/SidebarBody";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import { SvgArrowUpCircle, SvgUserManage, SvgX } from "@opal/icons";
|
||||
import Spacer from "@/refresh-components/Spacer";
|
||||
import { SvgArrowUpCircle, SvgSearch, SvgX } from "@opal/icons";
|
||||
import {
|
||||
useBillingInformation,
|
||||
useLicense,
|
||||
hasActiveSubscription,
|
||||
} from "@/lib/billing";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { ADMIN_ROUTES, sidebarItem } from "@/lib/admin-routes";
|
||||
import useFilter from "@/hooks/useFilter";
|
||||
import { IconFunctionComponent } from "@opal/types";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { getUserDisplayName } from "@/lib/user";
|
||||
import { APP_SLOGAN } from "@/lib/constants";
|
||||
import AccountPopover from "@/sections/sidebar/AccountPopover";
|
||||
|
||||
const SECTIONS = {
|
||||
UNLABELED: "",
|
||||
@@ -191,9 +195,29 @@ function groupBySection(items: SidebarItemEntry[]) {
|
||||
|
||||
interface AdminSidebarProps {
|
||||
enableCloudSS: boolean;
|
||||
folded: boolean;
|
||||
onFoldChange: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
|
||||
interface AdminSidebarInnerProps {
|
||||
enableCloudSS: boolean;
|
||||
onFoldChange: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
function AdminSidebarInner({
|
||||
enableCloudSS,
|
||||
onFoldChange,
|
||||
}: AdminSidebarInnerProps) {
|
||||
const folded = useSidebarFolded();
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const [focusSearch, setFocusSearch] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusSearch && !folded && searchRef.current) {
|
||||
searchRef.current.focus();
|
||||
setFocusSearch(false);
|
||||
}
|
||||
}, [focusSearch, folded]);
|
||||
const { kgExposed } = useIsKGExposed();
|
||||
const pathname = usePathname();
|
||||
const { customAnalyticsEnabled } = useCustomAnalyticsEnabled();
|
||||
@@ -237,65 +261,32 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
|
||||
const disabledGroups = groupBySection(disabled);
|
||||
|
||||
return (
|
||||
<SidebarWrapper>
|
||||
<SidebarBody
|
||||
scrollKey="admin-sidebar"
|
||||
pinnedContent={
|
||||
<div className="flex flex-col w-full">
|
||||
<SidebarTab
|
||||
icon={({ className }) => <SvgX className={className} size={16} />}
|
||||
href="/app"
|
||||
variant="sidebar-light"
|
||||
>
|
||||
Exit Admin Panel
|
||||
</SidebarTab>
|
||||
<InputTypeIn
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<Section gap={0} height="fit" alignItems="start">
|
||||
<div className="p-[0.38rem] w-full">
|
||||
<Content
|
||||
icon={SvgUserManage}
|
||||
title={getUserDisplayName(user)}
|
||||
sizePreset="main-ui"
|
||||
variant="body"
|
||||
prominence="muted"
|
||||
widthVariant="full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row gap-1 p-[0.38rem] w-full">
|
||||
<Text text03 secondaryAction>
|
||||
<a
|
||||
className="underline"
|
||||
href="https://onyx.app"
|
||||
target="_blank"
|
||||
>
|
||||
Onyx
|
||||
</a>
|
||||
</Text>
|
||||
<Text text03 secondaryBody>
|
||||
|
|
||||
</Text>
|
||||
{settings.webVersion ? (
|
||||
<Text text03 secondaryBody>
|
||||
{settings.webVersion}
|
||||
</Text>
|
||||
) : (
|
||||
<Text text03 secondaryBody>
|
||||
{APP_SLOGAN}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<SidebarLayouts.Header>
|
||||
{folded ? (
|
||||
<SidebarTab
|
||||
icon={SvgSearch}
|
||||
folded
|
||||
onClick={() => {
|
||||
onFoldChange(false);
|
||||
setFocusSearch(true);
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</SidebarTab>
|
||||
) : (
|
||||
<InputTypeIn
|
||||
ref={searchRef}
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</SidebarLayouts.Header>
|
||||
|
||||
<SidebarLayouts.Body scrollKey="admin-sidebar">
|
||||
{enabledGroups.map((group, groupIndex) => {
|
||||
const tabs = group.items.map(({ link, icon, name }) => (
|
||||
<SidebarTab
|
||||
@@ -334,7 +325,36 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
|
||||
))}
|
||||
</SidebarSection>
|
||||
))}
|
||||
</SidebarBody>
|
||||
</SidebarWrapper>
|
||||
</SidebarLayouts.Body>
|
||||
|
||||
<SidebarLayouts.Footer>
|
||||
<Separator noPadding className="px-2" />
|
||||
<Spacer rem={0.5} />
|
||||
<SidebarTab
|
||||
icon={SvgX}
|
||||
href="/app"
|
||||
variant="sidebar-light"
|
||||
folded={folded}
|
||||
>
|
||||
Exit Admin Panel
|
||||
</SidebarTab>
|
||||
<AccountPopover />
|
||||
</SidebarLayouts.Footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminSidebar({
|
||||
enableCloudSS,
|
||||
folded,
|
||||
onFoldChange,
|
||||
}: AdminSidebarProps) {
|
||||
return (
|
||||
<SidebarLayouts.Root folded={folded} onFoldChange={onFoldChange}>
|
||||
<AdminSidebarInner
|
||||
enableCloudSS={enableCloudSS}
|
||||
onFoldChange={onFoldChange}
|
||||
/>
|
||||
</SidebarLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,13 +28,14 @@ function LogoSection({ folded, onFoldClick }: LogoSectionProps) {
|
||||
<Button
|
||||
icon={SvgSidebar}
|
||||
prominence="tertiary"
|
||||
tooltip="Close Sidebar"
|
||||
tooltip={folded ? "Open Sidebar" : "Close Sidebar"}
|
||||
tooltipSide={folded ? "right" : "bottom"}
|
||||
size="md"
|
||||
onClick={onFoldClick}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[onFoldClick]
|
||||
[folded, onFoldClick]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -65,11 +65,13 @@ module.exports = {
|
||||
"neutral-10": "var(--neutral-10) 5%",
|
||||
},
|
||||
screens: {
|
||||
sm: "724px",
|
||||
md: "912px",
|
||||
lg: "1232px",
|
||||
"2xl": "1420px",
|
||||
"3xl": "1700px",
|
||||
"4xl": "2000px",
|
||||
mobile: { max: "767px" },
|
||||
desktop: "768px",
|
||||
mobile: { max: "724px" },
|
||||
tall: { raw: "(min-height: 800px)" },
|
||||
short: { raw: "(max-height: 799px)" },
|
||||
"very-short": { raw: "(max-height: 600px)" },
|
||||
|
||||
Reference in New Issue
Block a user