Compare commits

...

10 Commits

Author SHA1 Message Date
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
36 changed files with 1782 additions and 810 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

@@ -10,7 +10,9 @@ import (
)
func newChatCmd() *cobra.Command {
return &cobra.Command{
var noStreamMarkdown bool
cmd := &cobra.Command{
Use: "chat",
Short: "Launch the interactive chat TUI (default)",
Long: `Launch the interactive terminal UI for chatting with your Onyx agent.
@@ -30,6 +32,12 @@ an interactive setup wizard will guide you through configuration.`,
cfg = *result
}
// CLI flag overrides config/env
if cmd.Flags().Changed("no-stream-markdown") {
v := !noStreamMarkdown
cfg.Features.StreamMarkdown = &v
}
starprompt.MaybePrompt()
m := tui.NewModel(cfg)
@@ -38,4 +46,8 @@ an interactive setup wizard will guide you through configuration.`,
return err
},
}
cmd.Flags().BoolVar(&noStreamMarkdown, "no-stream-markdown", false, "Disable progressive markdown rendering during streaming")
return cmd
}

20
cli/cmd/experiments.go Normal file
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 := config.Load()
_, _ = fmt.Fprintln(cmd.OutOrStdout(), config.ExperimentsText(cfg.Features))
return nil
},
}
}

View File

@@ -98,6 +98,7 @@ func Execute() error {
rootCmd.AddCommand(newValidateConfigCmd())
rootCmd.AddCommand(newServeCmd())
rootCmd.AddCommand(newInstallSkillCmd())
rootCmd.AddCommand(newExperimentsCmd())
// Default command is chat, but intercept --version first
rootCmd.RunE = func(cmd *cobra.Command, args []string) error {

View File

@@ -9,28 +9,47 @@ import (
)
const (
EnvServerURL = "ONYX_SERVER_URL"
EnvAPIKey = "ONYX_API_KEY"
EnvAgentID = "ONYX_PERSONA_ID"
EnvSSHHostKey = "ONYX_SSH_HOST_KEY"
EnvServerURL = "ONYX_SERVER_URL"
EnvAPIKey = "ONYX_API_KEY"
EnvAgentID = "ONYX_PERSONA_ID"
EnvSSHHostKey = "ONYX_SSH_HOST_KEY"
EnvStreamMarkdown = "ONYX_STREAM_MARKDOWN"
)
// Features holds experimental feature flags for the CLI.
type Features struct {
// StreamMarkdown enables progressive markdown rendering during streaming,
// so output is formatted as it arrives rather than after completion.
// nil means use the app default (true).
StreamMarkdown *bool `json:"stream_markdown,omitempty"`
}
// OnyxCliConfig holds the CLI configuration.
type OnyxCliConfig struct {
ServerURL string `json:"server_url"`
APIKey string `json:"api_key"`
DefaultAgentID int `json:"default_persona_id"`
ServerURL string `json:"server_url"`
APIKey string `json:"api_key"`
DefaultAgentID int `json:"default_persona_id"`
Features Features `json:"features,omitempty"`
}
// DefaultConfig returns a config with default values.
func DefaultConfig() OnyxCliConfig {
return OnyxCliConfig{
ServerURL: "https://cloud.onyx.app",
APIKey: "",
ServerURL: "https://cloud.onyx.app",
APIKey: "",
DefaultAgentID: 0,
}
}
// StreamMarkdownEnabled returns whether stream markdown is enabled,
// defaulting to true when the user hasn't set an explicit preference.
func (f Features) StreamMarkdownEnabled() bool {
if f.StreamMarkdown != nil {
return *f.StreamMarkdown
}
return true
}
// IsConfigured returns true if the config has an API key.
func (c OnyxCliConfig) IsConfigured() bool {
return c.APIKey != ""
@@ -91,6 +110,13 @@ func Load() OnyxCliConfig {
cfg.DefaultAgentID = id
}
}
if v := os.Getenv(EnvStreamMarkdown); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
cfg.Features.StreamMarkdown = &b
} else {
fmt.Fprintf(os.Stderr, "warning: invalid value %q for %s (expected true/false), ignoring\n", v, EnvStreamMarkdown)
}
}
return cfg
}

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

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

@@ -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,36 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
))}
</SidebarSection>
))}
</SidebarBody>
</SidebarWrapper>
</SidebarLayouts.Body>
<SidebarLayouts.Footer>
<Separator noPadding className="px-2" />
<Spacer rem={0.5} />
<SidebarTab
icon={SvgX}
href="/app"
variant="sidebar-light"
folded={folded}
>
Exit Admin Panel
</SidebarTab>
<AccountPopover />
</SidebarLayouts.Footer>
</>
);
}
export default function AdminSidebar({
enableCloudSS,
folded,
onFoldChange,
}: AdminSidebarProps) {
return (
<SidebarLayouts.Root folded={folded} onFoldChange={onFoldChange}>
<AdminSidebarInner
enableCloudSS={enableCloudSS}
onFoldChange={onFoldChange}
/>
</SidebarLayouts.Root>
);
}

File diff suppressed because it is too large Load Diff

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