Compare commits

..

15 Commits

Author SHA1 Message Date
Jamison Lahman
77ce667b21 nit 2026-04-05 18:45:22 -07:00
Jamison Lahman
1e0a8afc72 INTERNAL_URL 2026-04-05 18:05:02 -07:00
Jamison Lahman
85302a1cde feat(cli): --config-file and --server-url 2026-04-05 17:32:48 -07:00
Raunak Bhagat
abc2cd5572 refactor: flatten opal card layouts, add children to CardHeaderLayout (#9907) 2026-04-04 02:50:55 +00:00
Raunak Bhagat
a704acbf73 fix: Edit AccountPopover + Separator's appearances when folded (#9906) 2026-04-04 01:24:59 +00:00
Jamison Lahman
8737122133 Revert "chore(deps): bump litellm from 1.81.6 to 1.83.0 (#9898)" (#9908) 2026-04-03 18:06:54 -07:00
Raunak Bhagat
c5d7cfa896 refactor: rework admin sidebar footer (#9895) 2026-04-04 00:08:42 +00:00
Jamison Lahman
297c931191 feat(cli): render markdown while streaming (experiment) (#9893)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-03 16:18:46 -07:00
dependabot[bot]
ae343c718b chore(deps): bump litellm from 1.81.6 to 1.83.0 (#9898)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-04-03 22:44:19 +00:00
Justin Tahara
ce39442478 fix(mt): Update Preprovision Workflow (#9896) 2026-04-03 22:22:55 +00:00
Raunak Bhagat
256996f27c fix: Edit bifrost colour (#9897) 2026-04-03 22:11:22 +00:00
Jamison Lahman
9dbe7acac6 fix(mobile): sidebar overlaps content on medium-sized screens (#9870) 2026-04-03 14:36:52 -07:00
Evan Lohn
8d43d73f83 fix: user files deleted by cleanup task (#9890) 2026-04-03 21:28:18 +00:00
Jessica Singh
559bac9f78 fix(notion): extract people properties and inline table content (#9891) 2026-04-03 20:39:53 +00:00
Jamison Lahman
e81bbe6f69 fix(mobile): update sidebar responsiveness (#9862) 2026-04-03 13:31:24 -07:00
53 changed files with 2117 additions and 1020 deletions

View File

@@ -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(

View File

@@ -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,

View File

@@ -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.

View File

@@ -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

View File

@@ -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"

View File

@@ -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]}"
)

View 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()

View File

@@ -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")
}

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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
View 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
},
}
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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()

View 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
}

View File

@@ -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,

View File

@@ -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

View 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)
}

View File

@@ -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

View File

@@ -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"},
}

View File

@@ -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+" ")
}

View File

@@ -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")
}
}

View File

@@ -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>

View File

@@ -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}

View 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`.

View File

@@ -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 };

View File

@@ -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`.

View File

@@ -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 {

View File

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

View File

@@ -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]

View File

@@ -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);

View File

@@ -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>
</>

View File

@@ -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]"
);
});
});

View File

@@ -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] =

View 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,
};

View File

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

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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={() => (

View File

@@ -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

View File

@@ -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}

View File

@@ -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}

View File

@@ -141,7 +141,7 @@ export interface SettingsProps {
onShowBuildIntro?: () => void;
}
export default function UserAvatarPopover({
export default function AccountPopover({
folded,
onShowBuildIntro,
}: SettingsProps) {

View File

@@ -1,10 +1,18 @@
"use client";
import { useCallback } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
type Dispatch,
type SetStateAction,
} from "react";
import { usePathname } from "next/navigation";
import { useSettingsContext } from "@/providers/SettingsProvider";
import SidebarSection from "@/sections/sidebar/SidebarSection";
import SidebarWrapper from "@/sections/sidebar/SidebarWrapper";
import * as SidebarLayouts from "@/layouts/sidebar-layouts";
import { useSidebarFolded } from "@/layouts/sidebar-layouts";
import { useIsKGExposed } from "@/app/admin/kg/utils";
import { useCustomAnalyticsEnabled } from "@/lib/hooks/useCustomAnalyticsEnabled";
import { useUser } from "@/providers/UserProvider";
@@ -12,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

View File

@@ -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 (

View File

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