mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-06 15:32:43 +00:00
Compare commits
15 Commits
jamison/wo
...
jamison/cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77ce667b21 | ||
|
|
1e0a8afc72 | ||
|
|
85302a1cde | ||
|
|
abc2cd5572 | ||
|
|
a704acbf73 | ||
|
|
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()
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/api"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/config"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -25,7 +24,7 @@ Use --json for machine-readable output.`,
|
||||
onyx-cli agents --json
|
||||
onyx-cli agents --json | jq '.[].name'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg := config.Load()
|
||||
cfg := loadConfig(cmd)
|
||||
if !cfg.IsConfigured() {
|
||||
return exitcodes.New(exitcodes.NotConfigured, "onyx CLI is not configured\n Run: onyx-cli configure")
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/api"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/config"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/models"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/overflow"
|
||||
@@ -49,7 +48,7 @@ to a temp file. Set --max-output 0 to disable truncation.`,
|
||||
cat error.log | onyx-cli ask --prompt "Find the root cause"
|
||||
echo "what is onyx?" | onyx-cli ask`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg := config.Load()
|
||||
cfg := loadConfig(cmd)
|
||||
if !cfg.IsConfigured() {
|
||||
return exitcodes.New(exitcodes.NotConfigured, "onyx CLI is not configured\n Run: onyx-cli configure")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -19,7 +21,7 @@ an interactive setup wizard will guide you through configuration.`,
|
||||
Example: ` onyx-cli chat
|
||||
onyx-cli`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg := config.Load()
|
||||
cfg := loadConfig(cmd)
|
||||
|
||||
// First-run: onboarding
|
||||
if !config.ConfigExists() || !cfg.IsConfigured() {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ Use --dry-run to test the connection without saving the configuration.`,
|
||||
return exitcodes.New(exitcodes.BadRequest, "both --server-url and --api-key are required for non-interactive setup\n Run 'onyx-cli configure' without flags for interactive setup")
|
||||
}
|
||||
|
||||
cfg := config.Load()
|
||||
cfg := loadConfig(cmd)
|
||||
onboarding.Run(&cfg)
|
||||
return nil
|
||||
},
|
||||
|
||||
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 := loadConfig(cmd)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), config.ExperimentsText(cfg.Features))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,20 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// loadConfig loads the CLI config, using the --config-file persistent flag if set.
|
||||
func loadConfig(cmd *cobra.Command) config.OnyxCliConfig {
|
||||
cf, _ := cmd.Flags().GetString("config-file")
|
||||
return config.Load(cf)
|
||||
}
|
||||
|
||||
// effectiveConfigPath returns the config file path, respecting --config-file.
|
||||
func effectiveConfigPath(cmd *cobra.Command) string {
|
||||
if cf, _ := cmd.Flags().GetString("config-file"); cf != "" {
|
||||
return cf
|
||||
}
|
||||
return config.ConfigFilePath()
|
||||
}
|
||||
|
||||
// Version and Commit are set via ldflags at build time.
|
||||
var (
|
||||
Version string
|
||||
@@ -29,7 +43,7 @@ func fullVersion() string {
|
||||
func printVersion(cmd *cobra.Command) {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Client version: %s\n", fullVersion())
|
||||
|
||||
cfg := config.Load()
|
||||
cfg := loadConfig(cmd)
|
||||
if !cfg.IsConfigured() {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Server version: unknown (not configured)\n")
|
||||
return
|
||||
@@ -84,6 +98,8 @@ func Execute() error {
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().BoolVar(&opts.Debug, "debug", false, "run in debug mode")
|
||||
rootCmd.PersistentFlags().String("config-file", "",
|
||||
"Path to config file (default: "+config.ConfigFilePath()+")")
|
||||
|
||||
// Custom --version flag instead of Cobra's built-in (which only shows one version string)
|
||||
var showVersion bool
|
||||
@@ -98,6 +114,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 {
|
||||
|
||||
@@ -50,16 +50,14 @@ func sessionEnv(s ssh.Session, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func validateAPIKey(serverURL string, apiKey string) error {
|
||||
func validateAPIKey(serverCfg config.OnyxCliConfig, apiKey string) error {
|
||||
trimmedKey := strings.TrimSpace(apiKey)
|
||||
if len(trimmedKey) > maxAPIKeyLength {
|
||||
return fmt.Errorf("API key is too long (max %d characters)", maxAPIKeyLength)
|
||||
}
|
||||
|
||||
cfg := config.OnyxCliConfig{
|
||||
ServerURL: serverURL,
|
||||
APIKey: trimmedKey,
|
||||
}
|
||||
cfg := serverCfg
|
||||
cfg.APIKey = trimmedKey
|
||||
client := api.NewClient(cfg)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), apiKeyValidationTimeout)
|
||||
defer cancel()
|
||||
@@ -83,7 +81,7 @@ type authValidatedMsg struct {
|
||||
|
||||
type authModel struct {
|
||||
input textinput.Model
|
||||
serverURL string
|
||||
serverCfg config.OnyxCliConfig
|
||||
state authState
|
||||
apiKey string // set on successful validation
|
||||
errMsg string
|
||||
@@ -91,7 +89,7 @@ type authModel struct {
|
||||
aborted bool
|
||||
}
|
||||
|
||||
func newAuthModel(serverURL, initialErr string) authModel {
|
||||
func newAuthModel(serverCfg config.OnyxCliConfig, initialErr string) authModel {
|
||||
ti := textinput.New()
|
||||
ti.Prompt = " API Key: "
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
@@ -102,7 +100,7 @@ func newAuthModel(serverURL, initialErr string) authModel {
|
||||
|
||||
return authModel{
|
||||
input: ti,
|
||||
serverURL: serverURL,
|
||||
serverCfg: serverCfg,
|
||||
errMsg: initialErr,
|
||||
}
|
||||
}
|
||||
@@ -138,9 +136,9 @@ func (m authModel) Update(msg tea.Msg) (authModel, tea.Cmd) {
|
||||
}
|
||||
m.state = authValidating
|
||||
m.errMsg = ""
|
||||
serverURL := m.serverURL
|
||||
serverCfg := m.serverCfg
|
||||
return m, func() tea.Msg {
|
||||
return authValidatedMsg{key: key, err: validateAPIKey(serverURL, key)}
|
||||
return authValidatedMsg{key: key, err: validateAPIKey(serverCfg, key)}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,12 +169,13 @@ func (m authModel) Update(msg tea.Msg) (authModel, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m authModel) View() string {
|
||||
settingsURL := strings.TrimRight(m.serverURL, "/") + "/app/settings/accounts-access"
|
||||
serverURL := m.serverCfg.ServerURL
|
||||
settingsURL := strings.TrimRight(serverURL, "/") + "/app/settings/accounts-access"
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("\n")
|
||||
b.WriteString(" \x1b[1;35mOnyx CLI\x1b[0m\n")
|
||||
b.WriteString(" \x1b[90m" + m.serverURL + "\x1b[0m\n")
|
||||
b.WriteString(" \x1b[90m" + serverURL + "\x1b[0m\n")
|
||||
b.WriteString("\n")
|
||||
b.WriteString(" Generate an API key at:\n")
|
||||
b.WriteString(" \x1b[4;34m" + settingsURL + "\x1b[0m\n")
|
||||
@@ -215,7 +214,7 @@ type serveModel struct {
|
||||
|
||||
func newServeModel(serverCfg config.OnyxCliConfig, initialErr string) serveModel {
|
||||
return serveModel{
|
||||
auth: newAuthModel(serverCfg.ServerURL, initialErr),
|
||||
auth: newAuthModel(serverCfg, initialErr),
|
||||
serverCfg: serverCfg,
|
||||
}
|
||||
}
|
||||
@@ -238,11 +237,8 @@ func (m serveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
if m.auth.apiKey != "" {
|
||||
cfg := config.OnyxCliConfig{
|
||||
ServerURL: m.serverCfg.ServerURL,
|
||||
APIKey: m.auth.apiKey,
|
||||
DefaultAgentID: m.serverCfg.DefaultAgentID,
|
||||
}
|
||||
cfg := m.serverCfg
|
||||
cfg.APIKey = m.auth.apiKey
|
||||
m.tui = tui.NewModel(cfg)
|
||||
m.authed = true
|
||||
w, h := m.width, m.height
|
||||
@@ -280,6 +276,8 @@ func newServeCmd() *cobra.Command {
|
||||
rateLimitPerMin int
|
||||
rateLimitBurst int
|
||||
rateLimitCache int
|
||||
serverURL string
|
||||
apiServerURL string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -300,9 +298,18 @@ environment variable (the --host-key flag takes precedence).`,
|
||||
Example: ` onyx-cli serve --port 2222
|
||||
ssh localhost -p 2222
|
||||
onyx-cli serve --host 0.0.0.0 --port 2222
|
||||
onyx-cli serve --idle-timeout 30m --max-session-timeout 2h`,
|
||||
onyx-cli serve --idle-timeout 30m --max-session-timeout 2h
|
||||
onyx-cli serve --server-url https://my-onyx.example.com
|
||||
onyx-cli serve --api-server-url http://api_server:8080 # bypass nginx
|
||||
onyx-cli serve --config-file /etc/onyx-cli/config.json # global flag`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
serverCfg := config.Load()
|
||||
serverCfg := loadConfig(cmd)
|
||||
if cmd.Flags().Changed("server-url") {
|
||||
serverCfg.ServerURL = serverURL
|
||||
}
|
||||
if cmd.Flags().Changed("api-server-url") {
|
||||
serverCfg.InternalURL = apiServerURL
|
||||
}
|
||||
if serverCfg.ServerURL == "" {
|
||||
return exitcodes.New(exitcodes.NotConfigured, "server URL is not configured\n Run: onyx-cli configure")
|
||||
}
|
||||
@@ -333,7 +340,7 @@ environment variable (the --host-key flag takes precedence).`,
|
||||
var envErr string
|
||||
|
||||
if apiKey != "" {
|
||||
if err := validateAPIKey(serverCfg.ServerURL, apiKey); err != nil {
|
||||
if err := validateAPIKey(serverCfg, apiKey); err != nil {
|
||||
envErr = fmt.Sprintf("ONYX_API_KEY from SSH environment is invalid: %s", err.Error())
|
||||
apiKey = ""
|
||||
}
|
||||
@@ -341,11 +348,8 @@ environment variable (the --host-key flag takes precedence).`,
|
||||
|
||||
if apiKey != "" {
|
||||
// Env key is valid — go straight to the TUI.
|
||||
cfg := config.OnyxCliConfig{
|
||||
ServerURL: serverCfg.ServerURL,
|
||||
APIKey: apiKey,
|
||||
DefaultAgentID: serverCfg.DefaultAgentID,
|
||||
}
|
||||
cfg := serverCfg
|
||||
cfg.APIKey = apiKey
|
||||
return tui.NewModel(cfg), []tea.ProgramOption{
|
||||
tea.WithAltScreen(),
|
||||
tea.WithMouseCellMotion(),
|
||||
@@ -446,6 +450,10 @@ environment variable (the --host-key flag takes precedence).`,
|
||||
defaultServeRateLimitCacheSize,
|
||||
"Maximum number of IP limiter entries tracked in memory",
|
||||
)
|
||||
cmd.Flags().StringVar(&serverURL, "server-url", "",
|
||||
"Onyx server URL (overrides config file and $"+config.EnvServerURL+")")
|
||||
cmd.Flags().StringVar(&apiServerURL, "api-server-url", "",
|
||||
"API server URL for direct access, bypassing nginx (overrides $"+config.EnvAPIServerURL+")")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/api"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/config"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/version"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -23,19 +23,21 @@ is valid. Also reports the server version and warns if it is below the
|
||||
minimum required.`,
|
||||
Example: ` onyx-cli validate-config`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfgPath := effectiveConfigPath(cmd)
|
||||
|
||||
// Check config file
|
||||
if !config.ConfigExists() {
|
||||
return exitcodes.Newf(exitcodes.NotConfigured, "config file not found at %s\n Run: onyx-cli configure", config.ConfigFilePath())
|
||||
if _, err := os.Stat(cfgPath); err != nil {
|
||||
return exitcodes.Newf(exitcodes.NotConfigured, "config file not found at %s\n Run: onyx-cli configure", cfgPath)
|
||||
}
|
||||
|
||||
cfg := config.Load()
|
||||
cfg := loadConfig(cmd)
|
||||
|
||||
// Check API key
|
||||
if !cfg.IsConfigured() {
|
||||
return exitcodes.New(exitcodes.NotConfigured, "API key is missing\n Run: onyx-cli configure")
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", config.ConfigFilePath())
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", cfgPath)
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Server: %s\n", cfg.ServerURL)
|
||||
|
||||
// Test connection
|
||||
|
||||
@@ -16,18 +16,30 @@ import (
|
||||
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/config"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/models"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Client is the Onyx API client.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
serverURL string // root server URL (for reachability checks)
|
||||
baseURL string // API base URL (includes /api when going through nginx)
|
||||
apiKey string
|
||||
httpClient *http.Client // default 30s timeout for quick requests
|
||||
longHTTPClient *http.Client // 5min timeout for streaming/uploads
|
||||
}
|
||||
|
||||
// NewClient creates a new API client from config.
|
||||
//
|
||||
// When InternalURL is set, requests go directly to the API server (no /api
|
||||
// prefix needed — mirrors INTERNAL_URL in the web server). Otherwise,
|
||||
// requests go through the nginx proxy at ServerURL which strips /api.
|
||||
func NewClient(cfg config.OnyxCliConfig) *Client {
|
||||
baseURL := apiBaseURL(cfg)
|
||||
log.WithFields(log.Fields{
|
||||
"server_url": cfg.ServerURL,
|
||||
"internal_url": cfg.InternalURL,
|
||||
"base_url": baseURL,
|
||||
}).Debug("creating API client")
|
||||
var transport *http.Transport
|
||||
if t, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||
transport = t.Clone()
|
||||
@@ -35,8 +47,9 @@ func NewClient(cfg config.OnyxCliConfig) *Client {
|
||||
transport = &http.Transport{}
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(cfg.ServerURL, "/"),
|
||||
apiKey: cfg.APIKey,
|
||||
serverURL: strings.TrimRight(cfg.ServerURL, "/"),
|
||||
baseURL: baseURL,
|
||||
apiKey: cfg.APIKey,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: transport,
|
||||
@@ -48,14 +61,27 @@ func NewClient(cfg config.OnyxCliConfig) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// apiBaseURL returns the base URL for API requests. When InternalURL is set,
|
||||
// it points directly at the API server. Otherwise it goes through the nginx
|
||||
// proxy at ServerURL/api.
|
||||
func apiBaseURL(cfg config.OnyxCliConfig) string {
|
||||
if cfg.InternalURL != "" {
|
||||
return strings.TrimRight(cfg.InternalURL, "/")
|
||||
}
|
||||
return strings.TrimRight(cfg.ServerURL, "/") + "/api"
|
||||
}
|
||||
|
||||
// UpdateConfig replaces the client's config.
|
||||
func (c *Client) UpdateConfig(cfg config.OnyxCliConfig) {
|
||||
c.baseURL = strings.TrimRight(cfg.ServerURL, "/")
|
||||
c.serverURL = strings.TrimRight(cfg.ServerURL, "/")
|
||||
c.baseURL = apiBaseURL(cfg)
|
||||
c.apiKey = cfg.APIKey
|
||||
}
|
||||
|
||||
func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
|
||||
url := c.baseURL + path
|
||||
log.WithFields(log.Fields{"method": method, "url": url}).Debug("API request")
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -87,12 +113,16 @@ func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, r
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
log.WithError(err).WithField("url", req.URL.String()).Debug("API request failed")
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
log.WithFields(log.Fields{"url": req.URL.String(), "status": resp.StatusCode}).Debug("API response")
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
log.WithFields(log.Fields{"status": resp.StatusCode, "body": string(respBody)}).Debug("API error response")
|
||||
return &OnyxAPIError{StatusCode: resp.StatusCode, Detail: string(respBody)}
|
||||
}
|
||||
|
||||
@@ -105,16 +135,26 @@ func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, r
|
||||
// TestConnection checks if the server is reachable and credentials are valid.
|
||||
// Returns nil on success, or an error with a descriptive message on failure.
|
||||
func (c *Client) TestConnection(ctx context.Context) error {
|
||||
// Step 1: Basic reachability
|
||||
req, err := c.newRequest(ctx, "GET", "/", nil)
|
||||
// Step 1: Basic reachability (hit the server root, not the API prefix)
|
||||
reachURL := c.serverURL
|
||||
if reachURL == "" {
|
||||
reachURL = c.baseURL
|
||||
}
|
||||
log.WithFields(log.Fields{
|
||||
"reach_url": reachURL,
|
||||
"base_url": c.baseURL,
|
||||
}).Debug("testing connection — step 1: reachability")
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", reachURL+"/", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot connect to %s: %w", c.baseURL, err)
|
||||
return fmt.Errorf("cannot connect to %s: %w", reachURL, err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot connect to %s — is the server running?", c.baseURL)
|
||||
log.WithError(err).Debug("reachability check failed")
|
||||
return fmt.Errorf("cannot connect to %s — is the server running?", reachURL)
|
||||
}
|
||||
log.WithField("status", resp.StatusCode).Debug("reachability check response")
|
||||
_ = resp.Body.Close()
|
||||
|
||||
serverHeader := strings.ToLower(resp.Header.Get("Server"))
|
||||
@@ -127,7 +167,8 @@ func (c *Client) TestConnection(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Step 2: Authenticated check
|
||||
req2, err := c.newRequest(ctx, "GET", "/api/me", nil)
|
||||
log.WithField("url", c.baseURL+"/me").Debug("testing connection — step 2: auth check")
|
||||
req2, err := c.newRequest(ctx, "GET", "/me", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server reachable but API error: %w", err)
|
||||
}
|
||||
@@ -167,7 +208,7 @@ func (c *Client) TestConnection(ctx context.Context) error {
|
||||
// ListAgents returns visible agents.
|
||||
func (c *Client) ListAgents(ctx context.Context) ([]models.AgentSummary, error) {
|
||||
var raw []models.AgentSummary
|
||||
if err := c.doJSON(ctx, "GET", "/api/persona", nil, &raw); err != nil {
|
||||
if err := c.doJSON(ctx, "GET", "/persona", nil, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []models.AgentSummary
|
||||
@@ -184,7 +225,7 @@ func (c *Client) ListChatSessions(ctx context.Context) ([]models.ChatSessionDeta
|
||||
var resp struct {
|
||||
Sessions []models.ChatSessionDetails `json:"sessions"`
|
||||
}
|
||||
if err := c.doJSON(ctx, "GET", "/api/chat/get-user-chat-sessions", nil, &resp); err != nil {
|
||||
if err := c.doJSON(ctx, "GET", "/chat/get-user-chat-sessions", nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Sessions, nil
|
||||
@@ -193,7 +234,7 @@ func (c *Client) ListChatSessions(ctx context.Context) ([]models.ChatSessionDeta
|
||||
// GetChatSession returns full details for a session.
|
||||
func (c *Client) GetChatSession(ctx context.Context, sessionID string) (*models.ChatSessionDetailResponse, error) {
|
||||
var resp models.ChatSessionDetailResponse
|
||||
if err := c.doJSON(ctx, "GET", "/api/chat/get-chat-session/"+sessionID, nil, &resp); err != nil {
|
||||
if err := c.doJSON(ctx, "GET", "/chat/get-chat-session/"+sessionID, nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
@@ -210,7 +251,7 @@ func (c *Client) RenameChatSession(ctx context.Context, sessionID string, name *
|
||||
var resp struct {
|
||||
NewName string `json:"new_name"`
|
||||
}
|
||||
if err := c.doJSON(ctx, "PUT", "/api/chat/rename-chat-session", payload, &resp); err != nil {
|
||||
if err := c.doJSON(ctx, "PUT", "/chat/rename-chat-session", payload, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.NewName, nil
|
||||
@@ -236,7 +277,7 @@ func (c *Client) UploadFile(ctx context.Context, filePath string) (*models.FileD
|
||||
}
|
||||
_ = writer.Close()
|
||||
|
||||
req, err := c.newRequest(ctx, "POST", "/api/user/projects/file/upload", &buf)
|
||||
req, err := c.newRequest(ctx, "POST", "/user/projects/file/upload", &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -275,7 +316,7 @@ func (c *Client) GetBackendVersion(ctx context.Context) (string, error) {
|
||||
var resp struct {
|
||||
BackendVersion string `json:"backend_version"`
|
||||
}
|
||||
if err := c.doJSON(ctx, "GET", "/api/version", nil, &resp); err != nil {
|
||||
if err := c.doJSON(ctx, "GET", "/version", nil, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.BackendVersion, nil
|
||||
@@ -283,7 +324,7 @@ func (c *Client) GetBackendVersion(ctx context.Context) (string, error) {
|
||||
|
||||
// StopChatSession sends a stop signal for a streaming session (best-effort).
|
||||
func (c *Client) StopChatSession(ctx context.Context, sessionID string) {
|
||||
req, err := c.newRequest(ctx, "POST", "/api/chat/stop-chat-session/"+sessionID, nil)
|
||||
req, err := c.newRequest(ctx, "POST", "/chat/stop-chat-session/"+sessionID, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func (c *Client) SendMessageStream(
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/chat/send-chat-message", nil)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/send-chat-message", nil)
|
||||
if err != nil {
|
||||
ch <- models.ErrorEvent{Error: fmt.Sprintf("request error: %v", err), IsRetryable: false}
|
||||
return
|
||||
|
||||
@@ -9,28 +9,49 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
EnvServerURL = "ONYX_SERVER_URL"
|
||||
EnvAPIKey = "ONYX_API_KEY"
|
||||
EnvAgentID = "ONYX_PERSONA_ID"
|
||||
EnvSSHHostKey = "ONYX_SSH_HOST_KEY"
|
||||
EnvServerURL = "ONYX_SERVER_URL"
|
||||
EnvAPIServerURL = "ONYX_API_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"`
|
||||
InternalURL string `json:"internal_url,omitempty"`
|
||||
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 != ""
|
||||
@@ -59,30 +80,47 @@ func ConfigExists() bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// LoadFromDisk reads config from the file only, without applying environment
|
||||
// variable overrides. Use this when you need the persisted config values
|
||||
// (e.g., to preserve them during a save operation).
|
||||
func LoadFromDisk() OnyxCliConfig {
|
||||
// LoadFromDisk reads config from the given file path without applying
|
||||
// environment variable overrides. Use this when you need the persisted
|
||||
// config values (e.g., to preserve them during a save operation).
|
||||
// If no path is provided, the default config file path is used.
|
||||
func LoadFromDisk(path ...string) OnyxCliConfig {
|
||||
p := ConfigFilePath()
|
||||
if len(path) > 0 && path[0] != "" {
|
||||
p = path[0]
|
||||
}
|
||||
|
||||
cfg := DefaultConfig()
|
||||
|
||||
data, err := os.ReadFile(ConfigFilePath())
|
||||
data, err := os.ReadFile(p)
|
||||
if err == nil {
|
||||
if jsonErr := json.Unmarshal(data, &cfg); jsonErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: config file %s is malformed: %v (using defaults)\n", ConfigFilePath(), jsonErr)
|
||||
fmt.Fprintf(os.Stderr, "warning: config file %s is malformed: %v (using defaults)\n", p, jsonErr)
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Load reads config from file and applies environment variable overrides.
|
||||
func Load() OnyxCliConfig {
|
||||
cfg := LoadFromDisk()
|
||||
// Load reads config from the given file path and applies environment variable
|
||||
// overrides. If no path is provided, the default config file path is used.
|
||||
func Load(path ...string) OnyxCliConfig {
|
||||
cfg := LoadFromDisk(path...)
|
||||
applyEnvOverrides(&cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Environment overrides
|
||||
func applyEnvOverrides(cfg *OnyxCliConfig) {
|
||||
if v := os.Getenv(EnvServerURL); v != "" {
|
||||
cfg.ServerURL = v
|
||||
}
|
||||
// ONYX_API_SERVER_URL takes precedence; fall back to INTERNAL_URL
|
||||
// (the env var used by the web server) for compatibility.
|
||||
if v := os.Getenv(EnvAPIServerURL); v != "" {
|
||||
cfg.InternalURL = v
|
||||
} else if v := os.Getenv("INTERNAL_URL"); v != "" {
|
||||
cfg.InternalURL = v
|
||||
}
|
||||
if v := os.Getenv(EnvAPIKey); v != "" {
|
||||
cfg.APIKey = v
|
||||
}
|
||||
@@ -91,8 +129,13 @@ func Load() OnyxCliConfig {
|
||||
cfg.DefaultAgentID = id
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save writes the config to disk, creating parent directories if needed.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { CardHeaderLayout } from "@opal/layouts";
|
||||
import { Card } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
@@ -18,14 +18,14 @@ const withTooltipProvider: Decorator = (Story) => (
|
||||
);
|
||||
|
||||
const meta = {
|
||||
title: "Layouts/CardHeaderLayout",
|
||||
component: CardHeaderLayout,
|
||||
title: "Layouts/Card.Header",
|
||||
component: Card.Header,
|
||||
tags: ["autodocs"],
|
||||
decorators: [withTooltipProvider],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
} satisfies Meta<typeof CardHeaderLayout>;
|
||||
} satisfies Meta<typeof Card.Header>;
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -38,7 +38,7 @@ type Story = StoryObj<typeof meta>;
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div className="w-[28rem] border rounded-16">
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
@@ -57,7 +57,7 @@ export const Default: Story = {
|
||||
export const WithBothSlots: Story = {
|
||||
render: () => (
|
||||
<div className="w-[28rem] border rounded-16">
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
@@ -92,7 +92,7 @@ export const WithBothSlots: Story = {
|
||||
export const RightChildrenOnly: Story = {
|
||||
render: () => (
|
||||
<div className="w-[28rem] border rounded-16">
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
@@ -111,7 +111,7 @@ export const RightChildrenOnly: Story = {
|
||||
export const NoRightChildren: Story = {
|
||||
render: () => (
|
||||
<div className="w-[28rem] border rounded-16">
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
@@ -125,7 +125,7 @@ export const NoRightChildren: Story = {
|
||||
export const LongContent: Story = {
|
||||
render: () => (
|
||||
<div className="w-[28rem] border rounded-16">
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
116
web/lib/opal/src/layouts/cards/README.md
Normal file
116
web/lib/opal/src/layouts/cards/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Card
|
||||
|
||||
**Import:** `import { Card } from "@opal/layouts";`
|
||||
|
||||
A namespace of card layout primitives. Each sub-component handles a specific region of a card.
|
||||
|
||||
## Card.Header
|
||||
|
||||
A card header layout that pairs a [`Content`](../content/README.md) block with a right-side column and an optional full-width children slot.
|
||||
|
||||
### Why Card.Header?
|
||||
|
||||
[`ContentAction`](../content-action/README.md) provides a single `rightChildren` slot. Card headers typically need two distinct right-side regions — a primary action on top and secondary actions on the bottom. `Card.Header` provides this with `rightChildren` and `bottomRightChildren` slots, plus a `children` slot for full-width content below the header row (e.g., search bars, expandable tool lists).
|
||||
|
||||
### Props
|
||||
|
||||
Inherits **all** props from [`Content`](../content/README.md) (icon, title, description, sizePreset, variant, editable, onTitleChange, suffix, etc.) plus:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `rightChildren` | `ReactNode` | `undefined` | Content rendered to the right of the Content block (top of right column). |
|
||||
| `bottomRightChildren` | `ReactNode` | `undefined` | Content rendered below `rightChildren` in the same column. Laid out as `flex flex-row`. |
|
||||
| `children` | `ReactNode` | `undefined` | Content rendered below the full header row, spanning the entire width. |
|
||||
|
||||
### Layout Structure
|
||||
|
||||
```
|
||||
+---------------------------------------------------------+
|
||||
| [Content (p-2, self-start)] [rightChildren] |
|
||||
| icon + title + description [bottomRightChildren] |
|
||||
+---------------------------------------------------------+
|
||||
| [children — full width] |
|
||||
+---------------------------------------------------------+
|
||||
```
|
||||
|
||||
- Outer wrapper: `flex flex-col w-full`
|
||||
- Header row: `flex flex-row items-stretch w-full`
|
||||
- Content area: `flex-1 min-w-0 self-start p-2` — top-aligned with fixed padding
|
||||
- Right column: `flex flex-col items-end shrink-0` — no padding, no gap
|
||||
- `bottomRightChildren` wrapper: `flex flex-row` — lays children out horizontally
|
||||
- `children` wrapper: `w-full` — only rendered when children are provided
|
||||
|
||||
### Usage
|
||||
|
||||
#### Card with primary and secondary actions
|
||||
|
||||
```tsx
|
||||
import { Card } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgGlobe, SvgSettings, SvgUnplug, SvgCheckSquare } from "@opal/icons";
|
||||
|
||||
<Card.Header
|
||||
icon={SvgGlobe}
|
||||
title="Google Search"
|
||||
description="Web search provider"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={
|
||||
<Button icon={SvgCheckSquare} variant="action" prominence="tertiary">
|
||||
Current Default
|
||||
</Button>
|
||||
}
|
||||
bottomRightChildren={
|
||||
<>
|
||||
<Button icon={SvgUnplug} size="sm" prominence="tertiary" tooltip="Disconnect" />
|
||||
<Button icon={SvgSettings} size="sm" prominence="tertiary" tooltip="Edit" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Card with only a connect action
|
||||
|
||||
```tsx
|
||||
<Card.Header
|
||||
icon={SvgCloud}
|
||||
title="OpenAI"
|
||||
description="Not configured"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={
|
||||
<Button rightIcon={SvgArrowExchange} prominence="tertiary">
|
||||
Connect
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Card with expandable children
|
||||
|
||||
```tsx
|
||||
<Card.Header
|
||||
icon={SvgServer}
|
||||
title="MCP Server"
|
||||
description="12 tools available"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={<Button icon={SvgSettings} prominence="tertiary" />}
|
||||
>
|
||||
<SearchBar placeholder="Search tools..." />
|
||||
</Card.Header>
|
||||
```
|
||||
|
||||
#### No right children
|
||||
|
||||
```tsx
|
||||
<Card.Header
|
||||
icon={SvgInfo}
|
||||
title="Section Header"
|
||||
description="Description text"
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
/>
|
||||
```
|
||||
|
||||
When both `rightChildren` and `bottomRightChildren` are omitted and no `children` are provided, the component renders only the padded `Content`.
|
||||
@@ -4,16 +4,23 @@ import { Content, type ContentProps } from "@opal/layouts/content/components";
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CardHeaderLayoutProps = ContentProps & {
|
||||
type CardHeaderProps = ContentProps & {
|
||||
/** Content rendered to the right of the Content block. */
|
||||
rightChildren?: React.ReactNode;
|
||||
|
||||
/** Content rendered below `rightChildren` in the same column. */
|
||||
bottomRightChildren?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Content rendered below the header row, full-width.
|
||||
* Use for expandable sections, search bars, or any content
|
||||
* that should appear beneath the icon/title/actions row.
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CardHeaderLayout
|
||||
// Card.Header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -24,9 +31,12 @@ type CardHeaderLayoutProps = ContentProps & {
|
||||
* `rightChildren` on top, `bottomRightChildren` below — with no
|
||||
* padding or gap between them.
|
||||
*
|
||||
* The optional `children` slot renders below the full header row,
|
||||
* spanning the entire width.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <CardHeaderLayout
|
||||
* <Card.Header
|
||||
* icon={SvgGlobe}
|
||||
* title="Google"
|
||||
* description="Search engine"
|
||||
@@ -42,32 +52,42 @@ type CardHeaderLayoutProps = ContentProps & {
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
function CardHeaderLayout({
|
||||
function Header({
|
||||
rightChildren,
|
||||
bottomRightChildren,
|
||||
children,
|
||||
...contentProps
|
||||
}: CardHeaderLayoutProps) {
|
||||
}: CardHeaderProps) {
|
||||
const hasRight = rightChildren || bottomRightChildren;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className="flex-1 min-w-0 self-start p-2">
|
||||
<Content {...contentProps} />
|
||||
</div>
|
||||
{hasRight && (
|
||||
<div className="flex flex-col items-end shrink-0">
|
||||
{rightChildren && <div className="flex-1">{rightChildren}</div>}
|
||||
{bottomRightChildren && (
|
||||
<div className="flex flex-row">{bottomRightChildren}</div>
|
||||
)}
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className="flex-1 min-w-0 self-start p-2">
|
||||
<Content {...contentProps} />
|
||||
</div>
|
||||
)}
|
||||
{hasRight && (
|
||||
<div className="flex flex-col items-end shrink-0">
|
||||
{rightChildren && <div className="flex-1">{rightChildren}</div>}
|
||||
{bottomRightChildren && (
|
||||
<div className="flex flex-row">{bottomRightChildren}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{children && <div className="w-full">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card namespace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Card = { Header };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { CardHeaderLayout, type CardHeaderLayoutProps };
|
||||
export { Card, type CardHeaderProps };
|
||||
@@ -1,94 +0,0 @@
|
||||
# CardHeaderLayout
|
||||
|
||||
**Import:** `import { CardHeaderLayout, type CardHeaderLayoutProps } from "@opal/layouts";`
|
||||
|
||||
A card header layout that pairs a [`Content`](../../content/README.md) block with a right-side column of vertically stacked children.
|
||||
|
||||
## Why CardHeaderLayout?
|
||||
|
||||
[`ContentAction`](../../content-action/README.md) provides a single `rightChildren` slot. Card headers typically need two distinct right-side regions — a primary action on top and secondary actions on the bottom. `CardHeaderLayout` provides this with `rightChildren` and `bottomRightChildren` slots, with no padding or gap between them so the caller has full control over spacing.
|
||||
|
||||
## Props
|
||||
|
||||
Inherits **all** props from [`Content`](../../content/README.md) (icon, title, description, sizePreset, variant, etc.) plus:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `rightChildren` | `ReactNode` | `undefined` | Content rendered to the right of the Content block (top of right column). |
|
||||
| `bottomRightChildren` | `ReactNode` | `undefined` | Content rendered below `rightChildren` in the same column. Laid out as `flex flex-row`. |
|
||||
|
||||
## Layout Structure
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ [Content (p-2, self-start)] [rightChildren] │
|
||||
│ icon + title + description [bottomRightChildren] │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Outer wrapper: `flex flex-row items-stretch w-full`
|
||||
- Content area: `flex-1 min-w-0 self-start p-2` — top-aligned with fixed padding
|
||||
- Right column: `flex flex-col items-end justify-between shrink-0` — no padding, no gap
|
||||
- `bottomRightChildren` wrapper: `flex flex-row` — lays children out horizontally
|
||||
|
||||
The right column uses `justify-between` so when both slots are present, `rightChildren` sits at the top and `bottomRightChildren` at the bottom.
|
||||
|
||||
## Usage
|
||||
|
||||
### Card with primary and secondary actions
|
||||
|
||||
```tsx
|
||||
import { CardHeaderLayout } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgGlobe, SvgSettings, SvgUnplug, SvgCheckSquare } from "@opal/icons";
|
||||
|
||||
<CardHeaderLayout
|
||||
icon={SvgGlobe}
|
||||
title="Google Search"
|
||||
description="Web search provider"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={
|
||||
<Button icon={SvgCheckSquare} variant="action" prominence="tertiary">
|
||||
Current Default
|
||||
</Button>
|
||||
}
|
||||
bottomRightChildren={
|
||||
<>
|
||||
<Button icon={SvgUnplug} size="sm" prominence="tertiary" tooltip="Disconnect" />
|
||||
<Button icon={SvgSettings} size="sm" prominence="tertiary" tooltip="Edit" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
### Card with only a connect action
|
||||
|
||||
```tsx
|
||||
<CardHeaderLayout
|
||||
icon={SvgCloud}
|
||||
title="OpenAI"
|
||||
description="Not configured"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={
|
||||
<Button rightIcon={SvgArrowExchange} prominence="tertiary">
|
||||
Connect
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
### No right children
|
||||
|
||||
```tsx
|
||||
<CardHeaderLayout
|
||||
icon={SvgInfo}
|
||||
title="Section Header"
|
||||
description="Description text"
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
/>
|
||||
```
|
||||
|
||||
When both `rightChildren` and `bottomRightChildren` are omitted, the component renders only the padded `Content`.
|
||||
@@ -12,11 +12,8 @@ export {
|
||||
type ContentActionProps,
|
||||
} from "@opal/layouts/content-action/components";
|
||||
|
||||
/* CardHeaderLayout */
|
||||
export {
|
||||
CardHeaderLayout,
|
||||
type CardHeaderLayoutProps,
|
||||
} from "@opal/layouts/cards/header-layout/components";
|
||||
/* Card */
|
||||
export { Card, type CardHeaderProps } from "@opal/layouts/cards/components";
|
||||
|
||||
/* IllustrationContent */
|
||||
export {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { ADMIN_ROUTES } from "@/lib/admin-routes";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { Button, SelectCard } from "@opal/components";
|
||||
import { CardHeaderLayout } from "@opal/layouts";
|
||||
import { Card } from "@opal/layouts";
|
||||
import { Disabled, Hoverable } from "@opal/core";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
@@ -113,7 +113,7 @@ export default function CodeInterpreterPage() {
|
||||
{isEnabled || isLoading ? (
|
||||
<Hoverable.Root group="code-interpreter/Card">
|
||||
<SelectCard state="filled" padding="sm" rounding="lg">
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgTerminal}
|
||||
@@ -161,7 +161,7 @@ export default function CodeInterpreterPage() {
|
||||
rounding="lg"
|
||||
onClick={() => handleToggle(true)}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgTerminal}
|
||||
|
||||
@@ -23,7 +23,7 @@ import Message from "@/refresh-components/messages/Message";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import { Button, SelectCard, Text } from "@opal/components";
|
||||
import { Content, CardHeaderLayout } from "@opal/layouts";
|
||||
import { Content, Card } from "@opal/layouts";
|
||||
import { Hoverable } from "@opal/core";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
@@ -260,7 +260,7 @@ export default function ImageGenerationContent() {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={() => (
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useWellKnownLLMProviders,
|
||||
} from "@/hooks/useLLMProviders";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { Content, CardHeaderLayout } from "@opal/layouts";
|
||||
import { Content, Card } from "@opal/layouts";
|
||||
import { Button, SelectCard } from "@opal/components";
|
||||
import { Hoverable } from "@opal/core";
|
||||
import { SvgArrowExchange, SvgSettings, SvgTrash } from "@opal/icons";
|
||||
@@ -24,7 +24,7 @@ import { refreshLlmProviderCaches } from "@/lib/llmConfig/cache";
|
||||
import { deleteLlmProvider, setDefaultLlmModel } from "@/lib/llmConfig/svc";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Horizontal as HorizontalInput } from "@/layouts/input-layouts";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import LegacyCard from "@/refresh-components/cards/Card";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import Message from "@/refresh-components/messages/Message";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
@@ -217,7 +217,7 @@ function ExistingProviderCard({
|
||||
rounding="lg"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
icon={getProviderIcon(provider.provider)}
|
||||
title={provider.name}
|
||||
description={getProviderDisplayName(provider.provider)}
|
||||
@@ -292,7 +292,7 @@ function NewProviderCard({
|
||||
rounding="lg"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
icon={getProviderIcon(provider.name)}
|
||||
title={getProviderProductName(provider.name)}
|
||||
description={getProviderDisplayName(provider.name)}
|
||||
@@ -336,7 +336,7 @@ function NewCustomProviderCard({
|
||||
rounding="lg"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
icon={getProviderIcon("custom")}
|
||||
title={getProviderProductName("custom")}
|
||||
description={getProviderDisplayName("custom")}
|
||||
@@ -424,7 +424,7 @@ export default function LLMConfigurationPage() {
|
||||
|
||||
<SettingsLayouts.Body>
|
||||
{hasProviders ? (
|
||||
<Card>
|
||||
<LegacyCard>
|
||||
<HorizontalInput
|
||||
title="Default Model"
|
||||
description="This model will be used by Onyx by default in your chats."
|
||||
@@ -455,7 +455,7 @@ export default function LLMConfigurationPage() {
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
</HorizontalInput>
|
||||
</Card>
|
||||
</LegacyCard>
|
||||
) : (
|
||||
<Message
|
||||
info
|
||||
|
||||
@@ -6,7 +6,7 @@ import { InfoIcon } from "@/components/icons/icons";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { Content, CardHeaderLayout } from "@opal/layouts";
|
||||
import { Content, Card } from "@opal/layouts";
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher, FetchError } from "@/lib/fetcher";
|
||||
import { SWR_KEYS } from "@/lib/swr-keys";
|
||||
@@ -275,7 +275,7 @@ function ProviderCard({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={icon}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import { Button, SelectCard } from "@opal/components";
|
||||
import { Content, CardHeaderLayout } from "@opal/layouts";
|
||||
import { Content, Card } from "@opal/layouts";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
SvgArrowRightCircle,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
* ProviderCard — a stateful card for selecting / connecting / disconnecting
|
||||
* an external service provider (LLM, search engine, voice model, etc.).
|
||||
*
|
||||
* Built on opal `SelectCard` + `CardHeaderLayout`. Maps a three-state
|
||||
* Built on opal `SelectCard` + `Card.Header`. Maps a three-state
|
||||
* status model to the `SelectCard` state system:
|
||||
*
|
||||
* | Status | SelectCard state | Right action |
|
||||
@@ -92,7 +92,7 @@ export default function ProviderCard({
|
||||
aria-label={ariaLabel}
|
||||
onClick={isDisconnected && onConnect ? onConnect : undefined}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={icon}
|
||||
|
||||
@@ -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,40 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
|
||||
))}
|
||||
</SidebarSection>
|
||||
))}
|
||||
</SidebarBody>
|
||||
</SidebarWrapper>
|
||||
</SidebarLayouts.Body>
|
||||
|
||||
<SidebarLayouts.Footer>
|
||||
{!folded && (
|
||||
<>
|
||||
<Separator noPadding className="px-2" />
|
||||
<Spacer rem={0.5} />
|
||||
</>
|
||||
)}
|
||||
<SidebarTab
|
||||
icon={SvgX}
|
||||
href="/app"
|
||||
variant="sidebar-light"
|
||||
folded={folded}
|
||||
>
|
||||
Exit Admin Panel
|
||||
</SidebarTab>
|
||||
<AccountPopover folded={folded} />
|
||||
</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