Compare commits

...

23 Commits

Author SHA1 Message Date
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
Jamison Lahman
b59f8cf453 feat(cli): onyx install-skill (#9889) 2026-04-03 12:41:39 -07:00
Bo-Onyx
456ecc7b9a feat(hook): UI improve disconnect error popover (#9877) 2026-04-03 19:15:19 +00:00
Jamison Lahman
fdc2bc9ee2 fix(fe): closed sidebar button tooltip text color (#9876) 2026-04-03 18:57:48 +00:00
Jamison Lahman
1c3f371549 fix(fe): projects buttons transition in like other sidebar items (#9875) 2026-04-03 18:50:14 +00:00
Evan Lohn
a120add37b feat: filestore delete missing error (#9878) 2026-04-03 18:19:41 +00:00
Raunak Bhagat
757e4e979b feat: cluster disabled admin sidebar tabs at the bottom (#9867) 2026-04-03 18:01:03 +00:00
Wenxi
cbcdfee56e fix(mcp server): propagate detailed error messages to mcp client instead of generic message and migrate to OnyxError (#9880) 2026-04-03 16:29:22 +00:00
Jamison Lahman
b06700314b fix(fe): fix sticky sidebar headers overlapping scrollbars (#9884) 2026-04-03 16:16:10 +00:00
roshan
01f573cdcb feat(cli): make onyx-cli agent-friendly (#9874)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:08:57 +00:00
Jamison Lahman
d4a96d70f3 fix(desktop): prefer native scrollbar styling (#9879) 2026-04-03 00:33:18 +00:00
Evan Lohn
5b000c2173 chore: remove unused db rows (#9869) 2026-04-02 22:17:10 +00:00
79 changed files with 3560 additions and 1434 deletions

View File

@@ -1,186 +0,0 @@
---
name: onyx-cli
description: Query the Onyx knowledge base using the onyx-cli command. Use when the user wants to search company documents, ask questions about internal knowledge, query connected data sources, or look up information stored in Onyx.
---
# Onyx CLI — Agent Tool
Onyx is an enterprise search and Gen-AI platform that connects to company documents, apps, and people. The `onyx-cli` CLI provides non-interactive commands to query the Onyx knowledge base and list available agents.
## Prerequisites
### 1. Check if installed
```bash
which onyx-cli
```
### 2. Install (if needed)
**Primary — pip:**
```bash
pip install onyx-cli
```
**From source (Go):**
```bash
cd cli && go build -o onyx-cli . && sudo mv onyx-cli /usr/local/bin/
```
### 3. Check if configured
```bash
onyx-cli validate-config
```
This checks the config file exists, API key is present, and tests the server connection via `/api/me`. Exit code 0 on success, non-zero with a descriptive error on failure.
If unconfigured, you have two options:
**Option A — Interactive setup (requires user input):**
```bash
onyx-cli configure
```
This prompts for the Onyx server URL and API key, tests the connection, and saves config.
**Option B — Environment variables (non-interactive, preferred for agents):**
```bash
export ONYX_SERVER_URL="https://your-onyx-server.com" # default: https://cloud.onyx.app
export ONYX_API_KEY="your-api-key"
```
Environment variables override the config file. If these are set, no config file is needed.
| Variable | Required | Description |
|----------|----------|-------------|
| `ONYX_SERVER_URL` | No | Onyx server base URL (default: `https://cloud.onyx.app`) |
| `ONYX_API_KEY` | Yes | API key for authentication |
| `ONYX_PERSONA_ID` | No | Default agent/persona ID |
If neither the config file nor environment variables are set, tell the user that `onyx-cli` needs to be configured and ask them to either:
- Run `onyx-cli configure` interactively, or
- Set `ONYX_SERVER_URL` and `ONYX_API_KEY` environment variables
## Commands
### Validate configuration
```bash
onyx-cli validate-config
```
Checks config file exists, API key is present, and tests the server connection. Use this before `ask` or `agents` to confirm the CLI is properly set up.
### List available agents
```bash
onyx-cli agents
```
Prints a table of agent IDs, names, and descriptions. Use `--json` for structured output:
```bash
onyx-cli agents --json
```
Use agent IDs with `ask --agent-id` to query a specific agent.
### Basic query (plain text output)
```bash
onyx-cli ask "What is our company's PTO policy?"
```
Streams the answer as plain text to stdout. Exit code 0 on success, non-zero on error.
### JSON output (structured events)
```bash
onyx-cli ask --json "What authentication methods do we support?"
```
Outputs JSON-encoded parsed stream events (one object per line). Key event objects include message deltas, stop, errors, search-start, and citation payloads.
Each line is a JSON object with this envelope:
```json
{"type": "<event_type>", "event": { ... }}
```
| Event Type | Description |
|------------|-------------|
| `message_delta` | Content token — concatenate all `content` fields for the full answer |
| `stop` | Stream complete |
| `error` | Error with `error` message field |
| `search_tool_start` | Onyx started searching documents |
| `citation_info` | Source citation — see shape below |
`citation_info` event shape:
```json
{
"type": "citation_info",
"event": {
"citation_number": 1,
"document_id": "abc123def456",
"placement": {"turn_index": 0, "tab_index": 0, "sub_turn_index": null}
}
}
```
`placement` is metadata about where in the conversation the citation appeared and can be ignored for most use cases.
### Specify an agent
```bash
onyx-cli ask --agent-id 5 "Summarize our Q4 roadmap"
```
Uses a specific Onyx agent/persona instead of the default.
### All flags
| Flag | Type | Description |
|------|------|-------------|
| `--agent-id` | int | Agent ID to use (overrides default) |
| `--json` | bool | Output raw NDJSON events instead of plain text |
## Statelessness
Each `onyx-cli ask` call creates an independent chat session. There is no built-in way to chain context across multiple `ask` invocations — every call starts fresh. If you need multi-turn conversation with memory, use the interactive TUI (`onyx-cli` or `onyx-cli chat`) instead.
## When to Use
Use `onyx-cli ask` when:
- The user asks about company-specific information (policies, docs, processes)
- You need to search internal knowledge bases or connected data sources
- The user references Onyx, asks you to "search Onyx", or wants to query their documents
- You need context from company wikis, Confluence, Google Drive, Slack, or other connected sources
Do NOT use when:
- The question is about general programming knowledge (use your own knowledge)
- The user is asking about code in the current repository (use grep/read tools)
- The user hasn't mentioned Onyx and the question doesn't require internal company data
## Examples
```bash
# Simple question
onyx-cli ask "What are the steps to deploy to production?"
# Get structured output for parsing
onyx-cli ask --json "List all active API integrations"
# Use a specialized agent
onyx-cli ask --agent-id 3 "What were the action items from last week's standup?"
# Pipe the answer into another command
onyx-cli ask "What is the database schema for users?" | head -20
```

View File

@@ -0,0 +1 @@
../../../cli/internal/embedded/SKILL.md

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

@@ -1,20 +1,14 @@
from datetime import datetime
from datetime import timezone
from uuid import UUID
from celery import shared_task
from celery import Task
from ee.onyx.background.celery_utils import should_perform_chat_ttl_check
from ee.onyx.background.task_name_builders import name_chat_ttl_task
from onyx.configs.app_configs import JOB_TIMEOUT
from onyx.configs.constants import OnyxCeleryTask
from onyx.db.chat import delete_chat_session
from onyx.db.chat import get_chat_sessions_older_than
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.enums import TaskStatus
from onyx.db.tasks import mark_task_as_finished_with_id
from onyx.db.tasks import register_task
from onyx.server.settings.store import load_settings
from onyx.utils.logger import setup_logger
@@ -29,59 +23,42 @@ logger = setup_logger()
trail=False,
)
def perform_ttl_management_task(
self: Task, retention_limit_days: int, *, tenant_id: str
self: Task, retention_limit_days: int, *, tenant_id: str # noqa: ARG001
) -> None:
task_id = self.request.id
if not task_id:
raise RuntimeError("No task id defined for this task; cannot identify it")
start_time = datetime.now(tz=timezone.utc)
user_id: UUID | None = None
session_id: UUID | None = None
try:
with get_session_with_current_tenant() as db_session:
# we generally want to move off this, but keeping for now
register_task(
db_session=db_session,
task_name=name_chat_ttl_task(retention_limit_days, tenant_id),
task_id=task_id,
status=TaskStatus.STARTED,
start_time=start_time,
)
old_chat_sessions = get_chat_sessions_older_than(
retention_limit_days, db_session
)
for user_id, session_id in old_chat_sessions:
# one session per delete so that we don't blow up if a deletion fails.
with get_session_with_current_tenant() as db_session:
delete_chat_session(
user_id,
session_id,
db_session,
include_deleted=True,
hard_delete=True,
try:
with get_session_with_current_tenant() as db_session:
delete_chat_session(
user_id,
session_id,
db_session,
include_deleted=True,
hard_delete=True,
)
except Exception:
logger.exception(
"Failed to delete chat session "
f"user_id={user_id} session_id={session_id}, "
"continuing with remaining sessions"
)
with get_session_with_current_tenant() as db_session:
mark_task_as_finished_with_id(
db_session=db_session,
task_id=task_id,
success=True,
)
except Exception:
logger.exception(
f"delete_chat_session exceptioned. user_id={user_id} session_id={session_id}"
)
with get_session_with_current_tenant() as db_session:
mark_task_as_finished_with_id(
db_session=db_session,
task_id=task_id,
success=False,
)
raise

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

@@ -136,12 +136,14 @@ class FileStore(ABC):
"""
@abstractmethod
def delete_file(self, file_id: str) -> None:
def delete_file(self, file_id: str, error_on_missing: bool = True) -> None:
"""
Delete a file by its ID.
Parameters:
- file_name: Name of file to delete
- file_id: ID of file to delete
- error_on_missing: If False, silently return when the file record
does not exist instead of raising.
"""
@abstractmethod
@@ -452,12 +454,23 @@ class S3BackedFileStore(FileStore):
logger.warning(f"Error getting file size for {file_id}: {e}")
return None
def delete_file(self, file_id: str, db_session: Session | None = None) -> None:
def delete_file(
self,
file_id: str,
error_on_missing: bool = True,
db_session: Session | None = None,
) -> None:
with get_session_with_current_tenant_if_none(db_session) as db_session:
try:
file_record = get_filerecord_by_file_id(
file_record = get_filerecord_by_file_id_optional(
file_id=file_id, db_session=db_session
)
if file_record is None:
if error_on_missing:
raise RuntimeError(
f"File by id {file_id} does not exist or was deleted"
)
return
if not file_record.bucket_name:
logger.error(
f"File record {file_id} with key {file_record.object_key} "

View File

@@ -222,12 +222,23 @@ class PostgresBackedFileStore(FileStore):
logger.warning(f"Error getting file size for {file_id}: {e}")
return None
def delete_file(self, file_id: str, db_session: Session | None = None) -> None:
def delete_file(
self,
file_id: str,
error_on_missing: bool = True,
db_session: Session | None = None,
) -> None:
with get_session_with_current_tenant_if_none(db_session) as session:
try:
file_content = get_file_content_by_file_id(
file_content = get_file_content_by_file_id_optional(
file_id=file_id, db_session=session
)
if file_content is None:
if error_on_missing:
raise RuntimeError(
f"File content for file_id {file_id} does not exist or was deleted"
)
return
raw_conn = _get_raw_connection(session)
try:

View File

@@ -3,6 +3,8 @@
from datetime import datetime
from typing import Any
import httpx
from onyx.configs.constants import DocumentSource
from onyx.mcp_server.api import mcp_server
from onyx.mcp_server.utils import get_http_client
@@ -15,6 +17,21 @@ from onyx.utils.variable_functionality import global_version
logger = setup_logger()
def _extract_error_detail(response: httpx.Response) -> str:
"""Extract a human-readable error message from a failed backend response.
The backend returns OnyxError responses as
``{"error_code": "...", "detail": "..."}``.
"""
try:
body = response.json()
if detail := body.get("detail"):
return str(detail)
except Exception:
pass
return f"Request failed with status {response.status_code}"
@mcp_server.tool()
async def search_indexed_documents(
query: str,
@@ -158,7 +175,14 @@ async def search_indexed_documents(
json=search_request,
headers=auth_headers,
)
response.raise_for_status()
if not response.is_success:
error_detail = _extract_error_detail(response)
return {
"documents": [],
"total_results": 0,
"query": query,
"error": error_detail,
}
result = response.json()
# Check for error in response
@@ -234,7 +258,13 @@ async def search_web(
json=request_payload,
headers={"Authorization": f"Bearer {access_token.token}"},
)
response.raise_for_status()
if not response.is_success:
error_detail = _extract_error_detail(response)
return {
"error": error_detail,
"results": [],
"query": query,
}
response_payload = response.json()
results = response_payload.get("results", [])
return {
@@ -280,7 +310,12 @@ async def open_urls(
json={"urls": urls},
headers={"Authorization": f"Bearer {access_token.token}"},
)
response.raise_for_status()
if not response.is_success:
error_detail = _extract_error_detail(response)
return {
"error": error_detail,
"results": [],
}
response_payload = response.json()
results = response_payload.get("results", [])
return {

View File

@@ -1,6 +1,5 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
@@ -9,6 +8,8 @@ from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.db.web_search import fetch_active_web_content_provider
from onyx.db.web_search import fetch_active_web_search_provider
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.web_search.models import OpenUrlsToolRequest
from onyx.server.features.web_search.models import OpenUrlsToolResponse
from onyx.server.features.web_search.models import WebSearchToolRequest
@@ -61,9 +62,10 @@ def _get_active_search_provider(
) -> tuple[WebSearchProviderView, WebSearchProvider]:
provider_model = fetch_active_web_search_provider(db_session)
if provider_model is None:
raise HTTPException(
status_code=400,
detail="No web search provider configured.",
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"No web search provider configured. Please configure one in "
"Admin > Web Search settings.",
)
provider_view = WebSearchProviderView(
@@ -76,9 +78,10 @@ def _get_active_search_provider(
)
if provider_model.api_key is None:
raise HTTPException(
status_code=400,
detail="Web search provider requires an API key.",
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"Web search provider requires an API key. Please configure one in "
"Admin > Web Search settings.",
)
try:
@@ -88,7 +91,7 @@ def _get_active_search_provider(
config=provider_model.config or {},
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
raise OnyxError(OnyxErrorCode.INVALID_INPUT, str(exc)) from exc
return provider_view, provider
@@ -110,9 +113,9 @@ def _get_active_content_provider(
if provider_model.api_key is None:
# TODO - this is not a great error, in fact, this key should not be nullable.
raise HTTPException(
status_code=400,
detail="Web content provider requires an API key.",
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"Web content provider requires an API key.",
)
try:
@@ -125,12 +128,12 @@ def _get_active_content_provider(
config=config,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
raise OnyxError(OnyxErrorCode.INVALID_INPUT, str(exc)) from exc
if provider is None:
raise HTTPException(
status_code=400,
detail="Unable to initialize the configured web content provider.",
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"Unable to initialize the configured web content provider.",
)
provider_view = WebContentProviderView(
@@ -154,12 +157,13 @@ def _run_web_search(
for query in request.queries:
try:
search_results = provider.search(query)
except HTTPException:
except OnyxError:
raise
except Exception as exc:
logger.exception("Web search provider failed for query '%s'", query)
raise HTTPException(
status_code=502, detail="Web search provider failed to execute query."
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
"Web search provider failed to execute query.",
) from exc
filtered_results = filter_web_search_results_with_no_title_or_snippet(
@@ -192,12 +196,13 @@ def _open_urls(
docs = filter_web_contents_with_no_title_or_content(
list(provider.contents(urls))
)
except HTTPException:
except OnyxError:
raise
except Exception as exc:
logger.exception("Web content provider failed to fetch URLs")
raise HTTPException(
status_code=502, detail="Web content provider failed to fetch URLs."
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
"Web content provider failed to fetch URLs.",
) from exc
results: list[LlmOpenUrlResult] = []

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

@@ -0,0 +1,91 @@
"""Tests for FileStore.delete_file error_on_missing behavior."""
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
_S3_MODULE = "onyx.file_store.file_store"
_PG_MODULE = "onyx.file_store.postgres_file_store"
def _mock_db_session() -> MagicMock:
session = MagicMock()
session.__enter__ = MagicMock(return_value=session)
session.__exit__ = MagicMock(return_value=False)
return session
# ── S3BackedFileStore ────────────────────────────────────────────────
@patch(f"{_S3_MODULE}.get_session_with_current_tenant_if_none")
@patch(f"{_S3_MODULE}.get_filerecord_by_file_id_optional", return_value=None)
def test_s3_delete_missing_file_raises_by_default(
_mock_get_record: MagicMock,
mock_ctx: MagicMock,
) -> None:
from onyx.file_store.file_store import S3BackedFileStore
mock_ctx.return_value = _mock_db_session()
store = S3BackedFileStore(bucket_name="b")
with pytest.raises(RuntimeError, match="does not exist"):
store.delete_file("nonexistent")
@patch(f"{_S3_MODULE}.get_session_with_current_tenant_if_none")
@patch(f"{_S3_MODULE}.get_filerecord_by_file_id_optional", return_value=None)
@patch(f"{_S3_MODULE}.delete_filerecord_by_file_id")
def test_s3_delete_missing_file_silent_when_error_on_missing_false(
mock_delete_record: MagicMock,
_mock_get_record: MagicMock,
mock_ctx: MagicMock,
) -> None:
from onyx.file_store.file_store import S3BackedFileStore
mock_ctx.return_value = _mock_db_session()
store = S3BackedFileStore(bucket_name="b")
store.delete_file("nonexistent", error_on_missing=False)
mock_delete_record.assert_not_called()
# ── PostgresBackedFileStore ──────────────────────────────────────────
@patch(f"{_PG_MODULE}.get_session_with_current_tenant_if_none")
@patch(f"{_PG_MODULE}.get_file_content_by_file_id_optional", return_value=None)
def test_pg_delete_missing_file_raises_by_default(
_mock_get_content: MagicMock,
mock_ctx: MagicMock,
) -> None:
from onyx.file_store.postgres_file_store import PostgresBackedFileStore
mock_ctx.return_value = _mock_db_session()
store = PostgresBackedFileStore()
with pytest.raises(RuntimeError, match="does not exist"):
store.delete_file("nonexistent")
@patch(f"{_PG_MODULE}.get_session_with_current_tenant_if_none")
@patch(f"{_PG_MODULE}.get_file_content_by_file_id_optional", return_value=None)
@patch(f"{_PG_MODULE}.delete_file_content_by_file_id")
@patch(f"{_PG_MODULE}.delete_filerecord_by_file_id")
def test_pg_delete_missing_file_silent_when_error_on_missing_false(
mock_delete_record: MagicMock,
mock_delete_content: MagicMock,
_mock_get_content: MagicMock,
mock_ctx: MagicMock,
) -> None:
from onyx.file_store.postgres_file_store import PostgresBackedFileStore
mock_ctx.return_value = _mock_db_session()
store = PostgresBackedFileStore()
store.delete_file("nonexistent", error_on_missing=False)
mock_delete_record.assert_not_called()
mock_delete_content.assert_not_called()

View File

@@ -98,6 +98,7 @@ Useful hardening flags:
| `serve` | Serve the interactive chat TUI over SSH |
| `configure` | Configure server URL and API key |
| `validate-config` | Validate configuration and test connection |
| `install-skill` | Install the agent skill file into a project |
## Slash Commands (in TUI)

View File

@@ -7,6 +7,7 @@ import (
"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"
)
@@ -16,16 +17,23 @@ func newAgentsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "agents",
Short: "List available agents",
Long: `List all visible agents configured on the Onyx server.
By default, output is a human-readable table with ID, name, and description.
Use --json for machine-readable output.`,
Example: ` onyx-cli agents
onyx-cli agents --json
onyx-cli agents --json | jq '.[].name'`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
if !cfg.IsConfigured() {
return fmt.Errorf("onyx CLI is not configured — run 'onyx-cli configure' first")
return exitcodes.New(exitcodes.NotConfigured, "onyx CLI is not configured\n Run: onyx-cli configure")
}
client := api.NewClient(cfg)
agents, err := client.ListAgents(cmd.Context())
if err != nil {
return fmt.Errorf("failed to list agents: %w", err)
return fmt.Errorf("failed to list agents: %w\n Check your connection with: onyx-cli validate-config", err)
}
if agentsJSON {

View File

@@ -4,33 +4,65 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/signal"
"strings"
"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"
"github.com/spf13/cobra"
"golang.org/x/term"
)
const defaultMaxOutputBytes = 4096
func newAskCmd() *cobra.Command {
var (
askAgentID int
askJSON bool
askQuiet bool
askPrompt string
maxOutput int
)
cmd := &cobra.Command{
Use: "ask [question]",
Short: "Ask a one-shot question (non-interactive)",
Args: cobra.ExactArgs(1),
Long: `Send a one-shot question to an Onyx agent and print the response.
The question can be provided as a positional argument, via --prompt, or piped
through stdin. When stdin contains piped data, it is sent as context along
with the question from --prompt (or used as the question itself).
When stdout is not a TTY (e.g., called by a script or AI agent), output is
automatically truncated to --max-output bytes and the full response is saved
to a temp file. Set --max-output 0 to disable truncation.`,
Args: cobra.MaximumNArgs(1),
Example: ` onyx-cli ask "What connectors are available?"
onyx-cli ask --agent-id 3 "Summarize our Q4 revenue"
onyx-cli ask --json "List all users" | jq '.event.content'
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()
if !cfg.IsConfigured() {
return fmt.Errorf("onyx CLI is not configured — run 'onyx-cli configure' first")
return exitcodes.New(exitcodes.NotConfigured, "onyx CLI is not configured\n Run: onyx-cli configure")
}
if askJSON && askQuiet {
return exitcodes.New(exitcodes.BadRequest, "--json and --quiet cannot be used together")
}
question, err := resolveQuestion(args, askPrompt)
if err != nil {
return err
}
question := args[0]
agentID := cfg.DefaultAgentID
if cmd.Flags().Changed("agent-id") {
agentID = askAgentID
@@ -50,9 +82,23 @@ func newAskCmd() *cobra.Command {
nil,
)
// Determine truncation threshold.
isTTY := term.IsTerminal(int(os.Stdout.Fd()))
truncateAt := 0 // 0 means no truncation
if cmd.Flags().Changed("max-output") {
truncateAt = maxOutput
} else if !isTTY {
truncateAt = defaultMaxOutputBytes
}
var sessionID string
var lastErr error
gotStop := false
// Overflow writer: tees to stdout and optionally to a temp file.
// In quiet mode, buffer everything and print once at the end.
ow := &overflow.Writer{Limit: truncateAt, Quiet: askQuiet}
for event := range ch {
if e, ok := event.(models.SessionCreatedEvent); ok {
sessionID = e.ChatSessionID
@@ -82,22 +128,50 @@ func newAskCmd() *cobra.Command {
switch e := event.(type) {
case models.MessageDeltaEvent:
fmt.Print(e.Content)
ow.Write(e.Content)
case models.SearchStartEvent:
if isTTY && !askQuiet {
if e.IsInternetSearch {
fmt.Fprintf(os.Stderr, "\033[2mSearching the web...\033[0m\n")
} else {
fmt.Fprintf(os.Stderr, "\033[2mSearching documents...\033[0m\n")
}
}
case models.SearchQueriesEvent:
if isTTY && !askQuiet {
for _, q := range e.Queries {
fmt.Fprintf(os.Stderr, "\033[2m → %s\033[0m\n", q)
}
}
case models.SearchDocumentsEvent:
if isTTY && !askQuiet && len(e.Documents) > 0 {
fmt.Fprintf(os.Stderr, "\033[2mFound %d documents\033[0m\n", len(e.Documents))
}
case models.ReasoningStartEvent:
if isTTY && !askQuiet {
fmt.Fprintf(os.Stderr, "\033[2mThinking...\033[0m\n")
}
case models.ToolStartEvent:
if isTTY && !askQuiet && e.ToolName != "" {
fmt.Fprintf(os.Stderr, "\033[2mUsing %s...\033[0m\n", e.ToolName)
}
case models.ErrorEvent:
ow.Finish()
return fmt.Errorf("%s", e.Error)
case models.StopEvent:
fmt.Println()
ow.Finish()
return nil
}
}
if !askJSON {
ow.Finish()
}
if ctx.Err() != nil {
if sessionID != "" {
client.StopChatSession(context.Background(), sessionID)
}
if !askJSON {
fmt.Println()
}
return nil
}
@@ -105,20 +179,56 @@ func newAskCmd() *cobra.Command {
return lastErr
}
if !gotStop {
if !askJSON {
fmt.Println()
}
return fmt.Errorf("stream ended unexpectedly")
}
if !askJSON {
fmt.Println()
}
return nil
},
}
cmd.Flags().IntVar(&askAgentID, "agent-id", 0, "Agent ID to use")
cmd.Flags().BoolVar(&askJSON, "json", false, "Output raw JSON events")
// Suppress cobra's default error/usage on RunE errors
cmd.Flags().BoolVarP(&askQuiet, "quiet", "q", false, "Buffer output and print once at end (no streaming)")
cmd.Flags().StringVar(&askPrompt, "prompt", "", "Question text (use with piped stdin context)")
cmd.Flags().IntVar(&maxOutput, "max-output", defaultMaxOutputBytes,
"Max bytes to print before truncating (0 to disable, auto-enabled for non-TTY)")
return cmd
}
// resolveQuestion builds the final question string from args, --prompt, and stdin.
func resolveQuestion(args []string, prompt string) (string, error) {
hasArg := len(args) > 0
hasPrompt := prompt != ""
hasStdin := !term.IsTerminal(int(os.Stdin.Fd()))
if hasArg && hasPrompt {
return "", exitcodes.New(exitcodes.BadRequest, "specify the question as an argument or --prompt, not both")
}
var stdinContent string
if hasStdin {
const maxStdinBytes = 10 * 1024 * 1024 // 10MB
data, err := io.ReadAll(io.LimitReader(os.Stdin, maxStdinBytes))
if err != nil {
return "", fmt.Errorf("failed to read stdin: %w", err)
}
stdinContent = strings.TrimSpace(string(data))
}
switch {
case hasArg && stdinContent != "":
// arg is the question, stdin is context
return args[0] + "\n\n" + stdinContent, nil
case hasArg:
return args[0], nil
case hasPrompt && stdinContent != "":
// --prompt is the question, stdin is context
return prompt + "\n\n" + stdinContent, nil
case hasPrompt:
return prompt, nil
case stdinContent != "":
return stdinContent, nil
default:
return "", exitcodes.New(exitcodes.BadRequest, "no question provided\n Usage: onyx-cli ask \"your question\"\n Or: echo \"context\" | onyx-cli ask --prompt \"your question\"")
}
}

View File

@@ -10,9 +10,16 @@ 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.
This is the default command when no subcommand is specified. On first run,
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()
@@ -25,6 +32,12 @@ func newChatCmd() *cobra.Command {
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)
@@ -33,4 +46,8 @@ func newChatCmd() *cobra.Command {
return err
},
}
cmd.Flags().BoolVar(&noStreamMarkdown, "no-stream-markdown", false, "Disable progressive markdown rendering during streaming")
return cmd
}

View File

@@ -1,19 +1,126 @@
package cmd
import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"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/onboarding"
"github.com/spf13/cobra"
"golang.org/x/term"
)
func newConfigureCmd() *cobra.Command {
return &cobra.Command{
var (
serverURL string
apiKey string
apiKeyStdin bool
dryRun bool
)
cmd := &cobra.Command{
Use: "configure",
Short: "Configure server URL and API key",
Long: `Set up the Onyx CLI with your server URL and API key.
When --server-url and --api-key are both provided, the configuration is saved
non-interactively (useful for scripts and AI agents). Otherwise, an interactive
setup wizard is launched.
If --api-key is omitted but stdin has piped data, the API key is read from
stdin automatically. You can also use --api-key-stdin to make this explicit.
This avoids leaking the key in shell history.
Use --dry-run to test the connection without saving the configuration.`,
Example: ` onyx-cli configure
onyx-cli configure --server-url https://my-onyx.com --api-key sk-...
echo "$ONYX_API_KEY" | onyx-cli configure --server-url https://my-onyx.com
echo "$ONYX_API_KEY" | onyx-cli configure --server-url https://my-onyx.com --api-key-stdin
onyx-cli configure --server-url https://my-onyx.com --api-key sk-... --dry-run`,
RunE: func(cmd *cobra.Command, args []string) error {
// Read API key from stdin if piped (implicit) or --api-key-stdin (explicit)
if apiKeyStdin && apiKey != "" {
return exitcodes.New(exitcodes.BadRequest, "--api-key and --api-key-stdin cannot be used together")
}
if (apiKey == "" && !term.IsTerminal(int(os.Stdin.Fd()))) || apiKeyStdin {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read API key from stdin: %w", err)
}
apiKey = strings.TrimSpace(string(data))
}
if serverURL != "" && apiKey != "" {
return configureNonInteractive(serverURL, apiKey, dryRun)
}
if dryRun {
return exitcodes.New(exitcodes.BadRequest, "--dry-run requires --server-url and --api-key")
}
if serverURL != "" || apiKey != "" {
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()
onboarding.Run(&cfg)
return nil
},
}
cmd.Flags().StringVar(&serverURL, "server-url", "", "Onyx server URL (e.g., https://cloud.onyx.app)")
cmd.Flags().StringVar(&apiKey, "api-key", "", "API key for authentication (or pipe via stdin)")
cmd.Flags().BoolVar(&apiKeyStdin, "api-key-stdin", false, "Read API key from stdin (explicit; also happens automatically when stdin is piped)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Test connection without saving config (requires --server-url and --api-key)")
return cmd
}
func configureNonInteractive(serverURL, apiKey string, dryRun bool) error {
cfg := config.OnyxCliConfig{
ServerURL: serverURL,
APIKey: apiKey,
DefaultAgentID: 0,
}
// Preserve existing default agent ID from disk (not env overrides)
if existing := config.LoadFromDisk(); existing.DefaultAgentID != 0 {
cfg.DefaultAgentID = existing.DefaultAgentID
}
// Test connection
client := api.NewClient(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := client.TestConnection(ctx); err != nil {
var authErr *api.AuthError
if errors.As(err, &authErr) {
return exitcodes.Newf(exitcodes.AuthFailure, "authentication failed: %v\n Check your API key", err)
}
return exitcodes.Newf(exitcodes.Unreachable, "connection failed: %v\n Check your server URL", err)
}
if dryRun {
fmt.Printf("Server: %s\n", serverURL)
fmt.Println("Status: connected and authenticated")
fmt.Println("Dry run: config was NOT saved")
return nil
}
if err := config.Save(cfg); err != nil {
return fmt.Errorf("could not save config: %w", err)
}
fmt.Printf("Config: %s\n", config.ConfigFilePath())
fmt.Printf("Server: %s\n", serverURL)
fmt.Println("Status: connected and authenticated")
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 := config.Load()
_, _ = fmt.Fprintln(cmd.OutOrStdout(), config.ExperimentsText(cfg.Features))
return nil
},
}
}

176
cli/cmd/install_skill.go Normal file
View File

@@ -0,0 +1,176 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/onyx-dot-app/onyx/cli/internal/embedded"
"github.com/onyx-dot-app/onyx/cli/internal/fsutil"
"github.com/spf13/cobra"
)
// agentSkillDirs maps agent names to their skill directory paths (relative to
// the project or home root). "Universal" agents like Cursor and Codex read
// from .agents/skills directly, so they don't need their own entry here.
var agentSkillDirs = map[string]string{
"claude-code": filepath.Join(".claude", "skills"),
}
const (
canonicalDir = ".agents/skills"
skillName = "onyx-cli"
)
func newInstallSkillCmd() *cobra.Command {
var (
global bool
copyMode bool
agents []string
)
cmd := &cobra.Command{
Use: "install-skill",
Short: "Install the Onyx CLI agent skill file",
Long: `Install the bundled SKILL.md so that AI coding agents can discover and use
the Onyx CLI as a tool.
Files are written to the canonical .agents/skills/onyx-cli/ directory. For
agents that use their own skill directory (e.g. Claude Code uses .claude/skills/),
a symlink is created pointing back to the canonical copy.
By default the skill is installed at the project level (current directory).
Use --global to install under your home directory instead.
Use --copy to write independent copies instead of symlinks.
Use --agent to target specific agents (can be repeated).`,
Example: ` onyx-cli install-skill
onyx-cli install-skill --global
onyx-cli install-skill --agent claude-code
onyx-cli install-skill --copy`,
RunE: func(cmd *cobra.Command, args []string) error {
base, err := installBase(global)
if err != nil {
return err
}
// Write the canonical copy.
canonicalSkillDir := filepath.Join(base, canonicalDir, skillName)
dest := filepath.Join(canonicalSkillDir, "SKILL.md")
content := []byte(embedded.SkillMD)
status, err := fsutil.CompareFile(dest, content)
if err != nil {
return err
}
switch status {
case fsutil.StatusUpToDate:
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Up to date %s\n", dest)
case fsutil.StatusDiffers:
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: overwriting modified %s\n", dest)
if err := os.WriteFile(dest, content, 0o644); err != nil {
return fmt.Errorf("could not write skill file: %w", err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Installed %s\n", dest)
default: // statusMissing
if err := os.MkdirAll(canonicalSkillDir, 0o755); err != nil {
return fmt.Errorf("could not create directory: %w", err)
}
if err := os.WriteFile(dest, content, 0o644); err != nil {
return fmt.Errorf("could not write skill file: %w", err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Installed %s\n", dest)
}
// Determine which agents to link.
targets := agentSkillDirs
if len(agents) > 0 {
targets = make(map[string]string)
for _, a := range agents {
dir, ok := agentSkillDirs[a]
if !ok {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Unknown agent %q (skipped) — known agents:", a)
for name := range agentSkillDirs {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), " %s", name)
}
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
continue
}
targets[a] = dir
}
}
// Create symlinks (or copies) from agent-specific dirs to canonical.
for name, skillsDir := range targets {
agentSkillDir := filepath.Join(base, skillsDir, skillName)
if copyMode {
copyDest := filepath.Join(agentSkillDir, "SKILL.md")
if err := fsutil.EnsureDirForCopy(agentSkillDir); err != nil {
return fmt.Errorf("could not prepare %s directory: %w", name, err)
}
if err := os.MkdirAll(agentSkillDir, 0o755); err != nil {
return fmt.Errorf("could not create %s directory: %w", name, err)
}
if err := os.WriteFile(copyDest, []byte(embedded.SkillMD), 0o644); err != nil {
return fmt.Errorf("could not write %s skill file: %w", name, err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Copied %s\n", copyDest)
continue
}
// Compute relative symlink target. Symlinks resolve relative to
// the parent directory of the link, not the link itself.
rel, err := filepath.Rel(filepath.Dir(agentSkillDir), canonicalSkillDir)
if err != nil {
return fmt.Errorf("could not compute relative path for %s: %w", name, err)
}
if err := os.MkdirAll(filepath.Dir(agentSkillDir), 0o755); err != nil {
return fmt.Errorf("could not create %s directory: %w", name, err)
}
// Remove existing symlink/dir before creating.
_ = os.Remove(agentSkillDir)
if err := os.Symlink(rel, agentSkillDir); err != nil {
// Fall back to copy if symlink fails (e.g. Windows without dev mode).
copyDest := filepath.Join(agentSkillDir, "SKILL.md")
if mkErr := os.MkdirAll(agentSkillDir, 0o755); mkErr != nil {
return fmt.Errorf("could not create %s directory: %w", name, mkErr)
}
if wErr := os.WriteFile(copyDest, []byte(embedded.SkillMD), 0o644); wErr != nil {
return fmt.Errorf("could not write %s skill file: %w", name, wErr)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Copied %s (symlink failed)\n", copyDest)
continue
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Linked %s -> %s\n", agentSkillDir, rel)
}
return nil
},
}
cmd.Flags().BoolVarP(&global, "global", "g", false, "Install to home directory instead of project")
cmd.Flags().BoolVar(&copyMode, "copy", false, "Copy files instead of symlinking")
cmd.Flags().StringSliceVarP(&agents, "agent", "a", nil, "Target specific agents (e.g. claude-code)")
return cmd
}
func installBase(global bool) (string, error) {
if global {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("could not determine home directory: %w", err)
}
return home, nil
}
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("could not determine working directory: %w", err)
}
return cwd, nil
}

View File

@@ -97,6 +97,8 @@ func Execute() error {
rootCmd.AddCommand(newConfigureCmd())
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

@@ -23,6 +23,7 @@ import (
"github.com/charmbracelet/wish/ratelimiter"
"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/tui"
"github.com/spf13/cobra"
"golang.org/x/time/rate"
@@ -295,15 +296,15 @@ provided via the ONYX_API_KEY environment variable to skip the prompt:
The server URL is taken from the server operator's config. The server
auto-generates an Ed25519 host key on first run if the key file does not
already exist. The host key path can also be set via the ONYX_SSH_HOST_KEY
environment variable (the --host-key flag takes precedence).
Example:
onyx-cli serve --port 2222
ssh localhost -p 2222`,
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`,
RunE: func(cmd *cobra.Command, args []string) error {
serverCfg := config.Load()
if serverCfg.ServerURL == "" {
return fmt.Errorf("server URL is not configured; run 'onyx-cli configure' first")
return exitcodes.New(exitcodes.NotConfigured, "server URL is not configured\n Run: onyx-cli configure")
}
if !cmd.Flags().Changed("host-key") {
if v := os.Getenv(config.EnvSSHHostKey); v != "" {

View File

@@ -2,11 +2,13 @@ package cmd
import (
"context"
"errors"
"fmt"
"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"
"github.com/spf13/cobra"
@@ -16,17 +18,21 @@ func newValidateConfigCmd() *cobra.Command {
return &cobra.Command{
Use: "validate-config",
Short: "Validate configuration and test server connection",
Long: `Check that the CLI is configured, the server is reachable, and the API key
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 {
// Check config file
if !config.ConfigExists() {
return fmt.Errorf("config file not found at %s\n Run 'onyx-cli configure' to set up", config.ConfigFilePath())
return exitcodes.Newf(exitcodes.NotConfigured, "config file not found at %s\n Run: onyx-cli configure", config.ConfigFilePath())
}
cfg := config.Load()
// Check API key
if !cfg.IsConfigured() {
return fmt.Errorf("API key is missing\n Run 'onyx-cli configure' to set up")
return exitcodes.New(exitcodes.NotConfigured, "API key is missing\n Run: onyx-cli configure")
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", config.ConfigFilePath())
@@ -35,7 +41,11 @@ func newValidateConfigCmd() *cobra.Command {
// Test connection
client := api.NewClient(cfg)
if err := client.TestConnection(cmd.Context()); err != nil {
return fmt.Errorf("connection failed: %w", err)
var authErr *api.AuthError
if errors.As(err, &authErr) {
return exitcodes.Newf(exitcodes.AuthFailure, "authentication failed: %v\n Reconfigure with: onyx-cli configure", err)
}
return exitcodes.Newf(exitcodes.Unreachable, "connection failed: %v\n Reconfigure with: onyx-cli configure", err)
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Status: connected and authenticated")

View File

@@ -149,12 +149,12 @@ func (c *Client) TestConnection(ctx context.Context) error {
if resp2.StatusCode == 401 || resp2.StatusCode == 403 {
if isHTML || strings.Contains(respServer, "awselb") {
return fmt.Errorf("HTTP %d from a reverse proxy (not the Onyx backend).\n Check your deployment's ingress / proxy configuration", resp2.StatusCode)
return &AuthError{Message: fmt.Sprintf("HTTP %d from a reverse proxy (not the Onyx backend).\n Check your deployment's ingress / proxy configuration", resp2.StatusCode)}
}
if resp2.StatusCode == 401 {
return fmt.Errorf("invalid API key or token.\n %s", body)
return &AuthError{Message: fmt.Sprintf("invalid API key or token.\n %s", body)}
}
return fmt.Errorf("access denied — check that the API key is valid.\n %s", body)
return &AuthError{Message: fmt.Sprintf("access denied — check that the API key is valid.\n %s", body)}
}
detail := fmt.Sprintf("HTTP %d", resp2.StatusCode)

View File

@@ -11,3 +11,12 @@ type OnyxAPIError struct {
func (e *OnyxAPIError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Detail)
}
// AuthError is returned when authentication or authorization fails.
type AuthError struct {
Message string
}
func (e *AuthError) Error() string {
return e.Message
}

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 != ""
@@ -59,8 +78,10 @@ func ConfigExists() bool {
return err == nil
}
// Load reads config from file and applies environment variable overrides.
func Load() OnyxCliConfig {
// 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 {
cfg := DefaultConfig()
data, err := os.ReadFile(ConfigFilePath())
@@ -70,6 +91,13 @@ func Load() OnyxCliConfig {
}
}
return cfg
}
// Load reads config from file and applies environment variable overrides.
func Load() OnyxCliConfig {
cfg := LoadFromDisk()
// Environment overrides
if v := os.Getenv(EnvServerURL); v != "" {
cfg.ServerURL = v
@@ -82,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

@@ -0,0 +1,187 @@
---
name: onyx-cli
description: Query the Onyx knowledge base using the onyx-cli command. Use when the user wants to search company documents, ask questions about internal knowledge, query connected data sources, or look up information stored in Onyx.
---
# Onyx CLI — Agent Tool
Onyx is an enterprise search and Gen-AI platform that connects to company documents, apps, and people. The `onyx-cli` CLI provides non-interactive commands to query the Onyx knowledge base and list available agents.
## Prerequisites
### 1. Check if installed
```bash
which onyx-cli
```
### 2. Install (if needed)
**Primary — pip:**
```bash
pip install onyx-cli
```
**From source (Go):**
```bash
go build -o onyx-cli github.com/onyx-dot-app/onyx/cli && sudo mv onyx-cli /usr/local/bin/
```
### 3. Check if configured
```bash
onyx-cli validate-config
```
This checks the config file exists, API key is present, and tests the server connection via `/api/me`. Exit code 0 on success, non-zero with a descriptive error on failure.
If unconfigured, you have two options:
**Option A — Interactive setup (requires user input):**
```bash
onyx-cli configure
```
This prompts for the Onyx server URL and API key, tests the connection, and saves config.
**Option B — Environment variables (non-interactive, preferred for agents):**
```bash
export ONYX_SERVER_URL="https://your-onyx-server.com" # default: https://cloud.onyx.app
export ONYX_API_KEY="your-api-key"
```
Environment variables override the config file. If these are set, no config file is needed.
| Variable | Required | Description |
| ----------------- | -------- | -------------------------------------------------------- |
| `ONYX_SERVER_URL` | No | Onyx server base URL (default: `https://cloud.onyx.app`) |
| `ONYX_API_KEY` | Yes | API key for authentication |
| `ONYX_PERSONA_ID` | No | Default agent/persona ID |
If neither the config file nor environment variables are set, tell the user that `onyx-cli` needs to be configured and ask them to either:
- Run `onyx-cli configure` interactively, or
- Set `ONYX_SERVER_URL` and `ONYX_API_KEY` environment variables
## Commands
### Validate configuration
```bash
onyx-cli validate-config
```
Checks config file exists, API key is present, and tests the server connection. Use this before `ask` or `agents` to confirm the CLI is properly set up.
### List available agents
```bash
onyx-cli agents
```
Prints a table of agent IDs, names, and descriptions. Use `--json` for structured output:
```bash
onyx-cli agents --json
```
Use agent IDs with `ask --agent-id` to query a specific agent.
### Basic query (plain text output)
```bash
onyx-cli ask "What is our company's PTO policy?"
```
Streams the answer as plain text to stdout. Exit code 0 on success, non-zero on error.
### JSON output (structured events)
```bash
onyx-cli ask --json "What authentication methods do we support?"
```
Outputs JSON-encoded parsed stream events (one object per line). Key event objects include message deltas, stop, errors, search-start, and citation payloads.
Each line is a JSON object with this envelope:
```json
{"type": "<event_type>", "event": { ... }}
```
| Event Type | Description |
| ------------------- | -------------------------------------------------------------------- |
| `message_delta` | Content token — concatenate all `content` fields for the full answer |
| `stop` | Stream complete |
| `error` | Error with `error` message field |
| `search_tool_start` | Onyx started searching documents |
| `citation_info` | Source citation — see shape below |
`citation_info` event shape:
```json
{
"type": "citation_info",
"event": {
"citation_number": 1,
"document_id": "abc123def456",
"placement": { "turn_index": 0, "tab_index": 0, "sub_turn_index": null }
}
}
```
`placement` is metadata about where in the conversation the citation appeared and can be ignored for most use cases.
### Specify an agent
```bash
onyx-cli ask --agent-id 5 "Summarize our Q4 roadmap"
```
Uses a specific Onyx agent/persona instead of the default.
### All flags
| Flag | Type | Description |
| ------------ | ---- | ---------------------------------------------- |
| `--agent-id` | int | Agent ID to use (overrides default) |
| `--json` | bool | Output raw NDJSON events instead of plain text |
## Statelessness
Each `onyx-cli ask` call creates an independent chat session. There is no built-in way to chain context across multiple `ask` invocations — every call starts fresh. If you need multi-turn conversation with memory, use the interactive TUI (`onyx-cli` or `onyx-cli chat`) instead.
## When to Use
Use `onyx-cli ask` when:
- The user asks about company-specific information (policies, docs, processes)
- You need to search internal knowledge bases or connected data sources
- The user references Onyx, asks you to "search Onyx", or wants to query their documents
- You need context from company wikis, Confluence, Google Drive, Slack, or other connected sources
Do NOT use when:
- The question is about general programming knowledge (use your own knowledge)
- The user is asking about code in the current repository (use grep/read tools)
- The user hasn't mentioned Onyx and the question doesn't require internal company data
## Examples
```bash
# Simple question
onyx-cli ask "What are the steps to deploy to production?"
# Get structured output for parsing
onyx-cli ask --json "List all active API integrations"
# Use a specialized agent
onyx-cli ask --agent-id 3 "What were the action items from last week's standup?"
# Pipe the answer into another command
onyx-cli ask "What is the database schema for users?" | head -20
```

View File

@@ -0,0 +1,7 @@
// Package embedded holds files that are compiled into the onyx-cli binary.
package embedded
import _ "embed"
//go:embed SKILL.md
var SkillMD string

View File

@@ -0,0 +1,33 @@
// Package exitcodes defines semantic exit codes for the Onyx CLI.
package exitcodes
import "fmt"
const (
Success = 0
General = 1
BadRequest = 2 // invalid args / command-line errors (convention)
NotConfigured = 3
AuthFailure = 4
Unreachable = 5
)
// ExitError wraps an error with a specific exit code.
type ExitError struct {
Code int
Err error
}
func (e *ExitError) Error() string {
return e.Err.Error()
}
// New creates an ExitError with the given code and message.
func New(code int, msg string) *ExitError {
return &ExitError{Code: code, Err: fmt.Errorf("%s", msg)}
}
// Newf creates an ExitError with a formatted message.
func Newf(code int, format string, args ...any) *ExitError {
return &ExitError{Code: code, Err: fmt.Errorf(format, args...)}
}

View File

@@ -0,0 +1,40 @@
package exitcodes
import (
"errors"
"fmt"
"testing"
)
func TestExitError_Error(t *testing.T) {
e := New(NotConfigured, "not configured")
if e.Error() != "not configured" {
t.Fatalf("expected 'not configured', got %q", e.Error())
}
if e.Code != NotConfigured {
t.Fatalf("expected code %d, got %d", NotConfigured, e.Code)
}
}
func TestExitError_Newf(t *testing.T) {
e := Newf(Unreachable, "cannot reach %s", "server")
if e.Error() != "cannot reach server" {
t.Fatalf("expected 'cannot reach server', got %q", e.Error())
}
if e.Code != Unreachable {
t.Fatalf("expected code %d, got %d", Unreachable, e.Code)
}
}
func TestExitError_ErrorsAs(t *testing.T) {
e := New(BadRequest, "bad input")
wrapped := fmt.Errorf("wrapper: %w", e)
var exitErr *ExitError
if !errors.As(wrapped, &exitErr) {
t.Fatal("errors.As should find ExitError")
}
if exitErr.Code != BadRequest {
t.Fatalf("expected code %d, got %d", BadRequest, exitErr.Code)
}
}

View File

@@ -0,0 +1,50 @@
// Package fsutil provides filesystem helper functions.
package fsutil
import (
"bytes"
"errors"
"fmt"
"os"
)
// FileStatus describes how an on-disk file compares to expected content.
type FileStatus int
const (
StatusMissing FileStatus = iota
StatusUpToDate // file exists with identical content
StatusDiffers // file exists with different content
)
// CompareFile checks whether the file at path matches the expected content.
func CompareFile(path string, expected []byte) (FileStatus, error) {
existing, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return StatusMissing, nil
}
return 0, fmt.Errorf("could not read %s: %w", path, err)
}
if bytes.Equal(existing, expected) {
return StatusUpToDate, nil
}
return StatusDiffers, nil
}
// EnsureDirForCopy makes sure path is a real directory, not a symlink or
// regular file. If a symlink or file exists at path it is removed so the
// caller can create a directory with independent content.
func EnsureDirForCopy(path string) error {
info, err := os.Lstat(path)
if err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
if err := os.Remove(path); err != nil {
return err
}
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}

View File

@@ -0,0 +1,116 @@
package fsutil
import (
"os"
"path/filepath"
"testing"
)
// TestCompareFile verifies that CompareFile correctly distinguishes between a
// missing file, a file with matching content, and a file with different content.
func TestCompareFile(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "skill.md")
expected := []byte("expected content")
status, err := CompareFile(path, expected)
if err != nil {
t.Fatalf("CompareFile on missing file failed: %v", err)
}
if status != StatusMissing {
t.Fatalf("expected StatusMissing, got %v", status)
}
if err := os.WriteFile(path, expected, 0o644); err != nil {
t.Fatalf("write expected file failed: %v", err)
}
status, err = CompareFile(path, expected)
if err != nil {
t.Fatalf("CompareFile on matching file failed: %v", err)
}
if status != StatusUpToDate {
t.Fatalf("expected StatusUpToDate, got %v", status)
}
if err := os.WriteFile(path, []byte("different content"), 0o644); err != nil {
t.Fatalf("write different file failed: %v", err)
}
status, err = CompareFile(path, expected)
if err != nil {
t.Fatalf("CompareFile on different file failed: %v", err)
}
if status != StatusDiffers {
t.Fatalf("expected StatusDiffers, got %v", status)
}
}
// TestEnsureDirForCopy verifies that EnsureDirForCopy clears symlinks and
// regular files so --copy can write a real directory, while leaving existing
// directories and missing paths untouched.
func TestEnsureDirForCopy(t *testing.T) {
t.Run("removes symlink", func(t *testing.T) {
tmpDir := t.TempDir()
targetDir := filepath.Join(tmpDir, "target")
linkPath := filepath.Join(tmpDir, "link")
if err := os.MkdirAll(targetDir, 0o755); err != nil {
t.Fatalf("mkdir target failed: %v", err)
}
if err := os.Symlink(targetDir, linkPath); err != nil {
t.Fatalf("create symlink failed: %v", err)
}
if err := EnsureDirForCopy(linkPath); err != nil {
t.Fatalf("EnsureDirForCopy failed: %v", err)
}
if _, err := os.Lstat(linkPath); !os.IsNotExist(err) {
t.Fatalf("expected symlink path to be removed, got err=%v", err)
}
})
t.Run("removes regular file", func(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "onyx-cli")
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatalf("write file failed: %v", err)
}
if err := EnsureDirForCopy(filePath); err != nil {
t.Fatalf("EnsureDirForCopy failed: %v", err)
}
if _, err := os.Lstat(filePath); !os.IsNotExist(err) {
t.Fatalf("expected file path to be removed, got err=%v", err)
}
})
t.Run("keeps existing directory", func(t *testing.T) {
tmpDir := t.TempDir()
dirPath := filepath.Join(tmpDir, "onyx-cli")
if err := os.MkdirAll(dirPath, 0o755); err != nil {
t.Fatalf("mkdir failed: %v", err)
}
if err := EnsureDirForCopy(dirPath); err != nil {
t.Fatalf("EnsureDirForCopy failed: %v", err)
}
info, err := os.Lstat(dirPath)
if err != nil {
t.Fatalf("lstat directory failed: %v", err)
}
if !info.IsDir() {
t.Fatalf("expected directory to remain, got mode %v", info.Mode())
}
})
t.Run("missing path is no-op", func(t *testing.T) {
tmpDir := t.TempDir()
missingPath := filepath.Join(tmpDir, "does-not-exist")
if err := EnsureDirForCopy(missingPath); err != nil {
t.Fatalf("EnsureDirForCopy failed: %v", err)
}
})
}

View File

@@ -0,0 +1,121 @@
// Package overflow provides a streaming writer that auto-truncates output
// for non-TTY callers (e.g., AI agents, scripts). Full content is saved to
// a temp file on disk; only the first N bytes are printed to stdout.
package overflow
import (
"fmt"
"os"
"strings"
log "github.com/sirupsen/logrus"
)
// Writer handles streaming output with optional truncation.
// When Limit > 0, it streams to a temp file on disk (not memory) and stops
// writing to stdout after Limit bytes. When Limit == 0, it writes directly
// to stdout. In Quiet mode, it buffers in memory and prints once at the end.
type Writer struct {
Limit int
Quiet bool
written int
totalBytes int
truncated bool
buf strings.Builder // used only in quiet mode
tmpFile *os.File // used only in truncation mode (Limit > 0)
}
// Write sends a chunk of content through the writer.
func (w *Writer) Write(s string) {
w.totalBytes += len(s)
// Quiet mode: buffer in memory, print nothing
if w.Quiet {
w.buf.WriteString(s)
return
}
if w.Limit <= 0 {
fmt.Print(s)
return
}
// Truncation mode: stream all content to temp file on disk
if w.tmpFile == nil {
f, err := os.CreateTemp("", "onyx-ask-*.txt")
if err != nil {
// Fall back to no-truncation if we can't create the file
fmt.Fprintf(os.Stderr, "warning: could not create temp file: %v\n", err)
w.Limit = 0
fmt.Print(s)
return
}
w.tmpFile = f
}
if _, err := w.tmpFile.WriteString(s); err != nil {
// Disk write failed — abandon truncation, stream directly to stdout
fmt.Fprintf(os.Stderr, "warning: temp file write failed: %v\n", err)
w.closeTmpFile(true)
w.Limit = 0
w.truncated = false
fmt.Print(s)
return
}
if w.truncated {
return
}
remaining := w.Limit - w.written
if len(s) <= remaining {
fmt.Print(s)
w.written += len(s)
} else {
if remaining > 0 {
fmt.Print(s[:remaining])
w.written += remaining
}
w.truncated = true
}
}
// Finish flushes remaining output. Call once after all Write calls are done.
func (w *Writer) Finish() {
// Quiet mode: print buffered content at once
if w.Quiet {
fmt.Println(w.buf.String())
return
}
if !w.truncated {
w.closeTmpFile(true) // clean up unused temp file
fmt.Println()
return
}
// Close the temp file so it's readable
tmpPath := w.tmpFile.Name()
w.closeTmpFile(false) // close but keep the file
fmt.Printf("\n\n--- response truncated (%d bytes total) ---\n", w.totalBytes)
fmt.Printf("Full response: %s\n", tmpPath)
fmt.Printf("Explore:\n")
fmt.Printf(" cat %s | grep \"<pattern>\"\n", tmpPath)
fmt.Printf(" cat %s | tail -50\n", tmpPath)
}
// closeTmpFile closes and optionally removes the temp file.
func (w *Writer) closeTmpFile(remove bool) {
if w.tmpFile == nil {
return
}
if err := w.tmpFile.Close(); err != nil {
log.Debugf("warning: failed to close temp file: %v", err)
}
if remove {
if err := os.Remove(w.tmpFile.Name()); err != nil {
log.Debugf("warning: failed to remove temp file: %v", err)
}
}
w.tmpFile = nil
}

View File

@@ -0,0 +1,95 @@
package overflow
import (
"os"
"testing"
)
func TestWriter_NoLimit(t *testing.T) {
w := &Writer{Limit: 0}
w.Write("hello world")
if w.truncated {
t.Fatal("should not be truncated with limit 0")
}
if w.totalBytes != 11 {
t.Fatalf("expected 11 total bytes, got %d", w.totalBytes)
}
}
func TestWriter_UnderLimit(t *testing.T) {
w := &Writer{Limit: 100}
w.Write("hello")
w.Write(" world")
if w.truncated {
t.Fatal("should not be truncated when under limit")
}
if w.written != 11 {
t.Fatalf("expected 11 written bytes, got %d", w.written)
}
}
func TestWriter_OverLimit(t *testing.T) {
w := &Writer{Limit: 5}
w.Write("hello world") // 11 bytes, limit 5
if !w.truncated {
t.Fatal("should be truncated")
}
if w.written != 5 {
t.Fatalf("expected 5 written bytes, got %d", w.written)
}
if w.totalBytes != 11 {
t.Fatalf("expected 11 total bytes, got %d", w.totalBytes)
}
if w.tmpFile == nil {
t.Fatal("temp file should have been created")
}
_ = w.tmpFile.Close()
data, _ := os.ReadFile(w.tmpFile.Name())
_ = os.Remove(w.tmpFile.Name())
if string(data) != "hello world" {
t.Fatalf("temp file should contain full content, got %q", string(data))
}
}
func TestWriter_MultipleChunks(t *testing.T) {
w := &Writer{Limit: 10}
w.Write("hello") // 5 bytes
w.Write(" ") // 6 bytes
w.Write("world") // 11 bytes, crosses limit
w.Write("!") // 12 bytes, already truncated
if !w.truncated {
t.Fatal("should be truncated")
}
if w.written != 10 {
t.Fatalf("expected 10 written bytes, got %d", w.written)
}
if w.totalBytes != 12 {
t.Fatalf("expected 12 total bytes, got %d", w.totalBytes)
}
if w.tmpFile == nil {
t.Fatal("temp file should have been created")
}
_ = w.tmpFile.Close()
data, _ := os.ReadFile(w.tmpFile.Name())
_ = os.Remove(w.tmpFile.Name())
if string(data) != "hello world!" {
t.Fatalf("temp file should contain full content, got %q", string(data))
}
}
func TestWriter_QuietMode(t *testing.T) {
w := &Writer{Limit: 0, Quiet: true}
w.Write("hello")
w.Write(" world")
if w.written != 0 {
t.Fatalf("quiet mode should not write to stdout, got %d written", w.written)
}
if w.totalBytes != 11 {
t.Fatalf("expected 11 total bytes, got %d", w.totalBytes)
}
if w.buf.String() != "hello world" {
t.Fatalf("buffer should contain full content, got %q", w.buf.String())
}
}

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

@@ -1,10 +1,12 @@
package main
import (
"errors"
"fmt"
"os"
"github.com/onyx-dot-app/onyx/cli/cmd"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
)
var (
@@ -18,6 +20,10 @@ func main() {
if err := cmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
var exitErr *exitcodes.ExitError
if errors.As(err, &exitErr) {
os.Exit(exitErr.Code)
}
os.Exit(1)
}
}

View File

@@ -127,7 +127,7 @@ function SidebarTab({
rightChildren={truncationSpacer}
/>
) : (
<div className="flex flex-row items-center gap-2 flex-1">
<div className="flex flex-row items-center gap-2 w-full">
{Icon && (
<div className="flex items-center justify-center p-0.5">
<Icon className="h-[1rem] w-[1rem] text-text-03" />
@@ -153,7 +153,7 @@ function SidebarTab({
side="right"
sideOffset={4}
>
<Text>{children}</Text>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>

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

@@ -467,6 +467,10 @@
/* Frost Overlay (for FrostedDiv component) - lighter in light mode */
--frost-overlay: var(--alpha-grey-00-10);
/* Scrollbar */
--scrollbar-track: transparent;
--scrollbar-thumb: var(--alpha-grey-100-20);
}
/* Dark Colors */
@@ -671,4 +675,8 @@
/* Frost Overlay (for FrostedDiv component) - darker in dark mode */
--frost-overlay: var(--alpha-grey-100-10);
/* Scrollbar */
--scrollbar-track: transparent;
--scrollbar-thumb: var(--alpha-grey-00-20);
}

View File

@@ -127,17 +127,8 @@
}
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
/* SHADOWS */
@@ -362,27 +353,9 @@
/* SCROLL BAR */
.default-scrollbar::-webkit-scrollbar {
width: 6px;
}
.default-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
}
.default-scrollbar::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.default-scrollbar::-webkit-scrollbar-thumb:hover {
background: #555;
}
.default-scrollbar {
scrollbar-width: thin;
scrollbar-color: #888 transparent;
overflow: overlay;
overflow-y: scroll;
overflow-x: hidden;
}
@@ -392,78 +365,21 @@
height: 100%;
}
.inputscroll::-webkit-scrollbar-track {
background: #e5e7eb;
.inputscroll {
scrollbar-width: none;
}
::-webkit-scrollbar {
width: 0px;
/* Vertical scrollbar width */
height: 8px;
/* Horizontal scrollbar height */
}
::-webkit-scrollbar-track {
background: transparent;
/* background: theme("colors.scrollbar.track"); */
/* Track background color */
}
/* Style the scrollbar handle */
::-webkit-scrollbar-thumb {
background: transparent;
/* background: theme("colors.scrollbar.thumb"); */
/* Handle color */
border-radius: 10px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: transparent;
/* background: theme("colors.scrollbar.thumb-hover"); */
/* Handle color on hover */
}
.dark-scrollbar::-webkit-scrollbar-thumb {
background: transparent;
/* background: theme("colors.scrollbar.dark.thumb"); */
/* Handle color */
border-radius: 10px;
}
.dark-scrollbar::-webkit-scrollbar-thumb:hover {
background: transparent;
/* background: theme("colors.scrollbar.dark.thumb-hover"); */
/* Handle color on hover */
/* Ensure native scrollbars are visible */
@layer base {
* {
scrollbar-width: auto;
}
}
/* TEXTAREA */
textarea::-webkit-scrollbar {
width: 8px;
}
textarea::-webkit-scrollbar-track {
background: var(--scrollbar-track);
border-radius: 4px;
}
textarea::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
textarea::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
textarea {
resize: vertical;
}
/* For Firefox */
textarea {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}

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

@@ -5,6 +5,7 @@ import { SvgDownload, SvgTextLines } from "@opal/icons";
import Modal from "@/refresh-components/Modal";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import { Hoverable } from "@opal/core";
import { useHookExecutionLogs } from "@/ee/hooks/useHookExecutionLogs";
import { formatDateTimeLog } from "@/lib/dateUtils";
import { downloadFile } from "@/lib/download";
@@ -40,33 +41,40 @@ function SectionHeader({ label }: { label: string }) {
);
}
function LogRow({ log }: { log: HookExecutionRecord }) {
function LogRow({ log, group }: { log: HookExecutionRecord; group: string }) {
return (
<Section
flexDirection="row"
justifyContent="start"
alignItems="start"
gap={0.5}
height="fit"
className="py-2"
>
{/* 1. Timestamp */}
<span className="shrink-0 text-code-code">
<Text font="secondary-mono-label" color="inherit" nowrap>
{formatDateTimeLog(log.created_at)}
</Text>
</span>
{/* 2. Error message */}
<span className="flex-1 min-w-0 break-all whitespace-pre-wrap text-code-code">
<Text font="secondary-mono" color="inherit">
{log.error_message ?? "Unknown error"}
</Text>
</span>
{/* 3. Copy button */}
<Section width="fit" height="fit" alignItems="center">
<CopyIconButton size="xs" getCopyText={() => log.error_message ?? ""} />
<Hoverable.Root group={group}>
<Section
flexDirection="row"
justifyContent="start"
alignItems="start"
gap={0.5}
height="fit"
className="py-2"
>
{/* 1. Timestamp */}
<span className="shrink-0 text-code-code">
<Text font="secondary-mono-label" color="inherit" nowrap>
{formatDateTimeLog(log.created_at)}
</Text>
</span>
{/* 2. Error message */}
<span className="flex-1 min-w-0 break-all whitespace-pre-wrap text-code-code">
<Text font="secondary-mono" color="inherit">
{log.error_message ?? "Unknown error"}
</Text>
</span>
{/* 3. Copy button */}
<Section width="fit" height="fit" alignItems="center">
<Hoverable.Item group={group} variant="opacity-on-hover">
<CopyIconButton
size="xs"
getCopyText={() => log.error_message ?? ""}
/>
</Hoverable.Item>
</Section>
</Section>
</Section>
</Hoverable.Root>
);
}
@@ -126,7 +134,11 @@ export default function HookLogsModal({ hook, spec }: HookLogsModalProps) {
<>
<SectionHeader label="Past Hour" />
{recentErrors.map((log, idx) => (
<LogRow key={log.created_at + String(idx)} log={log} />
<LogRow
key={log.created_at + String(idx)}
log={log}
group={log.created_at + String(idx)}
/>
))}
</>
)}
@@ -134,7 +146,11 @@ export default function HookLogsModal({ hook, spec }: HookLogsModalProps) {
<>
<SectionHeader label="Older" />
{olderErrors.map((log, idx) => (
<LogRow key={log.created_at + String(idx)} log={log} />
<LogRow
key={log.created_at + String(idx)}
log={log}
group={log.created_at + String(idx)}
/>
))}
</>
)}

View File

@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { useCreateModal } from "@/refresh-components/contexts/ModalContext";
import { noProp } from "@/lib/utils";
import { formatTimeOnly } from "@/lib/dateUtils";
import { formatDateTimeLog } from "@/lib/dateUtils";
import { Button, Text } from "@opal/components";
import { Content } from "@opal/layouts";
import LineItem from "@/refresh-components/buttons/LineItem";
@@ -18,6 +18,7 @@ import {
SvgXOctagon,
} from "@opal/icons";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import { Hoverable } from "@opal/core";
import { useHookExecutionLogs } from "@/ee/hooks/useHookExecutionLogs";
import HookLogsModal from "@/ee/refresh-pages/admin/HooksPage/HookLogsModal";
import type {
@@ -26,6 +27,52 @@ import type {
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
import { cn } from "@opal/utils";
function ErrorLogRow({
log,
group,
}: {
log: { created_at: string; error_message: string | null };
group: string;
}) {
return (
<Hoverable.Root group={group}>
<Section
flexDirection="column"
justifyContent="start"
alignItems="start"
gap={0.25}
padding={0.25}
height="fit"
>
<Section
flexDirection="row"
justifyContent="between"
alignItems="center"
gap={0}
height="fit"
>
<span className="text-code-code">
<Text font="secondary-mono-label" color="inherit">
{formatDateTimeLog(log.created_at)}
</Text>
</span>
<Hoverable.Item group={group} variant="opacity-on-hover">
<CopyIconButton
size="xs"
getCopyText={() => log.error_message ?? ""}
/>
</Hoverable.Item>
</Section>
<span className="break-all">
<Text font="secondary-mono" color="text-03">
{log.error_message ?? "Unknown error"}
</Text>
</span>
</Section>
</Hoverable.Root>
);
}
interface HookStatusPopoverProps {
hook: HookResponse;
spec: HookPointMeta | undefined;
@@ -43,9 +90,16 @@ export default function HookStatusPopover({
const [clickOpened, setClickOpened] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { hasRecentErrors, recentErrors, isLoading, error } =
const { hasRecentErrors, recentErrors, olderErrors, isLoading, error } =
useHookExecutionLogs(hook.id);
const topErrors = [...recentErrors, ...olderErrors]
.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
.slice(0, 3);
useEffect(() => {
return () => {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
@@ -162,7 +216,15 @@ export default function HookStatusPopover({
justifyContent="start"
alignItems="start"
height="fit"
width={hasRecentErrors ? 20 : 12.5}
width={
hook.is_reachable === false
? topErrors.length > 0
? 20
: 12.5
: hasRecentErrors
? 20
: 12.5
}
padding={0.125}
gap={0.25}
>
@@ -174,13 +236,70 @@ export default function HookStatusPopover({
<Text font="secondary-body" color="text-03">
Failed to load logs.
</Text>
) : hook.is_reachable === false ? (
<>
<div className="p-1">
<Content
sizePreset="secondary"
variant="section"
icon={(props) => (
<SvgXOctagon
{...props}
className="text-status-error-05"
/>
)}
title="Most Recent Errors"
/>
</div>
{topErrors.length > 0 ? (
<>
<Separator noPadding className="px-2" />
<Section
flexDirection="column"
justifyContent="start"
alignItems="start"
gap={0.25}
padding={0.25}
height="fit"
>
{topErrors.map((log, idx) => (
<ErrorLogRow
key={log.created_at + String(idx)}
log={log}
group={log.created_at + String(idx)}
/>
))}
</Section>
</>
) : (
<Separator noPadding className="px-2" />
)}
<LineItem
muted
icon={SvgMaximize2}
onClick={noProp(() => {
handleOpenChange(false);
logsModal.toggle(true);
})}
>
View More Lines
</LineItem>
</>
) : hasRecentErrors ? (
<>
<div className="p-1">
<Content
sizePreset="secondary"
variant="section"
icon={SvgXOctagon}
icon={(props) => (
<SvgXOctagon
{...props}
className="text-status-error-05"
/>
)}
title={
recentErrors.length <= 3
? `${recentErrors.length} ${
@@ -204,38 +323,11 @@ export default function HookStatusPopover({
height="fit"
>
{recentErrors.slice(0, 3).map((log, idx) => (
<Section
<ErrorLogRow
key={log.created_at + String(idx)}
flexDirection="column"
justifyContent="start"
alignItems="start"
gap={0.25}
padding={0.25}
height="fit"
>
<Section
flexDirection="row"
justifyContent="between"
alignItems="center"
gap={0}
height="fit"
>
<span className="text-code-code">
<Text font="secondary-mono-label" color="inherit">
{formatTimeOnly(log.created_at)}
</Text>
</span>
<CopyIconButton
size="xs"
getCopyText={() => log.error_message ?? ""}
/>
</Section>
<span className="break-all">
<Text font="secondary-mono" color="text-03">
{log.error_message ?? "Unknown error"}
</Text>
</span>
</Section>
log={log}
group={log.created_at + String(idx)}
/>
))}
</Section>

View File

@@ -41,6 +41,7 @@ import {
activateHook,
deactivateHook,
deleteHook,
getHook,
validateHook,
} from "@/ee/refresh-pages/admin/HooksPage/svc";
import type {
@@ -319,9 +320,16 @@ function ConnectedHookCard({
toast.error(
err instanceof Error ? err.message : "Failed to validate hook."
);
return;
} finally {
setIsBusy(false);
}
try {
const updated = await getHook(hook.id);
onToggled(updated);
} catch (err) {
console.error("Failed to refresh hook after validation:", err);
}
}
const HookIcon = getHookPointIcon(hook.hook_point);

View File

@@ -87,6 +87,14 @@ export async function deactivateHook(id: number): Promise<HookResponse> {
return res.json();
}
export async function getHook(id: number): Promise<HookResponse> {
const res = await fetch(`/api/admin/hooks/${id}`);
if (!res.ok) {
throw await parseError(res, "Failed to fetch hook");
}
return res.json();
}
export async function validateHook(id: number): Promise<HookValidateResponse> {
const res = await fetch(`/api/admin/hooks/${id}/validate`, {
method: "POST",

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 { Disabled } from "@opal/core";
import { SvgArrowUpCircle, SvgUserManage, SvgX } from "@opal/icons";
import Separator from "@/refresh-components/Separator";
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: "",
@@ -141,15 +145,12 @@ function buildItems(
if (!isCurator) {
if (hasSubscription) {
add(SECTIONS.ORGANIZATION, ADMIN_ROUTES.BILLING);
} else {
items.push({
section: SECTIONS.ORGANIZATION,
name: "Upgrade Plan",
icon: SvgArrowUpCircle,
link: ADMIN_ROUTES.BILLING.path,
});
}
add(SECTIONS.ORGANIZATION, ADMIN_ROUTES.TOKEN_RATE_LIMITS);
addDisabled(
SECTIONS.ORGANIZATION,
ADMIN_ROUTES.TOKEN_RATE_LIMITS,
!enableEnterprise
);
addDisabled(SECTIONS.ORGANIZATION, ADMIN_ROUTES.THEME, !enableEnterprise);
}
@@ -165,6 +166,16 @@ function buildItems(
}
}
// 8. Upgrade Plan (admin only, no subscription)
if (!isCurator && !hasSubscription) {
items.push({
section: SECTIONS.UNLABELED,
name: "Upgrade Plan",
icon: SvgArrowUpCircle,
link: ADMIN_ROUTES.BILLING.path,
});
}
return items;
}
@@ -184,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();
@@ -224,88 +255,48 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
const { query, setQuery, filtered } = useFilter(allItems, itemExtractor);
const groups = groupBySection(filtered);
const enabled = filtered.filter((item) => !item.disabled);
const disabled = filtered.filter((item) => item.disabled);
const enabledGroups = groupBySection(enabled);
const disabledGroups = groupBySection(disabled);
return (
<SidebarWrapper>
<SidebarBody
scrollKey="admin-sidebar"
pinnedContent={
<div className="flex flex-col w-full">
<>
<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
icon={({ className }) => <SvgX className={className} size={16} />}
href="/app"
variant="sidebar-light"
key={link}
icon={icon}
href={link}
selected={pathname.startsWith(link)}
>
Exit Admin Panel
{name}
</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>
}
>
{groups.map((group, groupIndex) => {
const tabs = group.items.map(({ link, icon, name, disabled }) => (
<Disabled key={link} disabled={disabled}>
{/*
# NOTE (@raunakab)
We intentionally add a `div` intermediary here.
Without it, the disabled styling that is default provided by the `Disabled` component (which we want here) would be overridden by the custom disabled styling provided by the `SidebarTab`.
Therefore, in order to avoid that overriding, we add a layer of indirection.
*/}
<div>
<SidebarTab
disabled={disabled}
icon={icon}
href={disabled ? undefined : link}
selected={pathname.startsWith(link)}
>
{name}
</SidebarTab>
</div>
</Disabled>
));
if (!group.section) {
@@ -318,7 +309,56 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
</SidebarSection>
);
})}
</SidebarBody>
</SidebarWrapper>
{disabledGroups.length > 0 && <Separator noPadding className="px-2" />}
{disabledGroups.map((group, groupIndex) => (
<SidebarSection
key={`disabled-${groupIndex}`}
title={group.section}
disabled
>
{group.items.map(({ link, icon, name }) => (
<SidebarTab key={link} disabled icon={icon}>
{name}
</SidebarTab>
))}
</SidebarSection>
))}
</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

@@ -2,34 +2,42 @@
import React from "react";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import { Disabled, Hoverable } from "@opal/core";
export interface SidebarSectionProps {
title: string;
children?: React.ReactNode;
action?: React.ReactNode;
className?: string;
disabled?: boolean;
}
export default function SidebarSection({
title,
children,
action,
className,
disabled,
}: SidebarSectionProps) {
return (
<div className={cn("flex flex-col group/SidebarSection", className)}>
<div className="pl-2 pr-1.5 py-1 sticky top-[0rem] bg-background-tint-02 z-10 flex flex-row items-center justify-between min-h-[2rem]">
<Text as="p" secondaryBody text02>
{title}
</Text>
{action && (
<div className="flex-shrink-0 opacity-0 group-hover/SidebarSection:opacity-100 transition-opacity">
{action}
<Hoverable.Root group="sidebar-section">
{/* Title */}
{/* NOTE: mr-1.5 is intentionally used instead of padding to avoid the background color
from overlapping with scrollbars on Safari.
*/}
<Disabled disabled={disabled}>
<div className="pl-2 mr-1.5 py-1 sticky top-0 bg-background-tint-02 z-10 flex flex-row items-center justify-between min-h-[2rem]">
<div className="p-0.5 w-full flex flex-col justify-center">
<Text secondaryBody text02>
{title}
</Text>
</div>
)}
</div>
<div>{children}</div>
</div>
{action && (
<Hoverable.Item group="sidebar-section">{action}</Hoverable.Item>
)}
</div>
</Disabled>
{/* Contents */}
{children}
</Hoverable.Root>
);
}

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