Compare commits

..

1 Commits

Author SHA1 Message Date
Nik
42a7ec664c refactor(be): replace HTTPException with OnyxError in small API files 2026-03-04 20:21:12 -08:00
36 changed files with 859 additions and 1339 deletions

View File

@@ -4,6 +4,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from httpx_oauth.clients.google import GoogleOAuth2
from ee.onyx.configs.app_configs import LICENSE_ENFORCEMENT_ENABLED
from ee.onyx.server.analytics.api import router as analytics_router
from ee.onyx.server.auth_check import check_ee_router_auth
from ee.onyx.server.billing.api import router as billing_router
@@ -152,9 +153,12 @@ def get_application() -> FastAPI:
# License management
include_router_with_global_prefix_prepended(application, license_router)
# Unified billing API - always registered in EE.
# Each endpoint is protected by the `current_admin_user` dependency (admin auth).
include_router_with_global_prefix_prepended(application, billing_router)
# Unified billing API - available when license system is enabled
# Works for both self-hosted and cloud deployments
# TODO(ENG-3533): Once frontend migrates to /admin/billing/*, this becomes the
# primary billing API and /tenants/* billing endpoints can be removed
if LICENSE_ENFORCEMENT_ENABLED:
include_router_with_global_prefix_prepended(application, billing_router)
if MULTI_TENANT:
# Tenant management

View File

@@ -4,7 +4,6 @@ from typing import List
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -22,6 +21,8 @@ from onyx.auth.users import current_user
from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
router = APIRouter(prefix="/analytics", tags=PUBLIC_API_TAGS)
@@ -231,8 +232,9 @@ def get_assistant_stats(
end = end or datetime.datetime.utcnow()
if not user_can_view_assistant_stats(db_session, user, assistant_id):
raise HTTPException(
status_code=403, detail="Not allowed to access this assistant's stats."
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Not allowed to access this assistant's stats.",
)
# Pull daily usage from the DB calls

View File

@@ -4,7 +4,6 @@ from typing import Any
from typing import cast
from typing import IO
from fastapi import HTTPException
from fastapi import UploadFile
from ee.onyx.server.enterprise_settings.models import AnalyticsScriptUpload
@@ -13,6 +12,8 @@ from onyx.configs.constants import FileOrigin
from onyx.configs.constants import KV_CUSTOM_ANALYTICS_SCRIPT_KEY
from onyx.configs.constants import KV_ENTERPRISE_SETTINGS_KEY
from onyx.configs.constants import ONYX_DEFAULT_APPLICATION_NAME
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.file_store.file_store import get_default_file_store
from onyx.key_value_store.factory import get_kv_store
from onyx.key_value_store.interface import KvKeyNotFoundError
@@ -118,9 +119,9 @@ def upload_logo(file: UploadFile | str, is_logotype: bool = False) -> bool:
else:
logger.notice("Uploading logo from uploaded file")
if not file.filename or not is_valid_file_type(file.filename):
raise HTTPException(
status_code=400,
detail="Invalid file type- only .png, .jpg, and .jpeg files are allowed",
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Invalid file type- only .png, .jpg, and .jpeg files are allowed",
)
content = file.file
display_name = file.filename

View File

@@ -1,6 +1,5 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from ee.onyx.db.standard_answer import fetch_standard_answer
@@ -15,6 +14,8 @@ from ee.onyx.db.standard_answer import update_standard_answer_category
from onyx.auth.users import current_admin_user
from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.manage.models import StandardAnswer
from onyx.server.manage.models import StandardAnswerCategory
from onyx.server.manage.models import StandardAnswerCategoryCreationRequest
@@ -65,7 +66,7 @@ def patch_standard_answer(
)
if existing_standard_answer is None:
raise HTTPException(status_code=404, detail="Standard answer not found")
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Standard answer not found")
standard_answer_model = update_standard_answer(
standard_answer_id=standard_answer_id,
@@ -131,9 +132,7 @@ def patch_standard_answer_category(
)
if existing_standard_answer_category is None:
raise HTTPException(
status_code=404, detail="Standard answer category not found"
)
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Standard answer category not found")
standard_answer_category_model = update_standard_answer_category(
standard_answer_category_id=standard_answer_category_id,

View File

@@ -2,7 +2,6 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.users import current_admin_user
@@ -10,6 +9,8 @@ from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.db.persona import get_default_assistant
from onyx.db.persona import update_default_assistant_configuration
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.prompts.chat_prompts import DEFAULT_SYSTEM_PROMPT
from onyx.server.features.default_assistant.models import DefaultAssistantConfiguration
from onyx.server.features.default_assistant.models import DefaultAssistantUpdateRequest
@@ -32,7 +33,7 @@ def get_default_assistant_configuration(
"""
persona = get_default_assistant(db_session)
if not persona:
raise HTTPException(status_code=404, detail="Default assistant not found")
raise OnyxError(OnyxErrorCode.PERSONA_NOT_FOUND, "Default assistant not found")
# Extract DB tool IDs from the persona's tools
tool_ids = [tool.id for tool in persona.tools]
@@ -86,5 +87,5 @@ def update_default_assistant(
except ValueError as e:
if "Default assistant not found" in str(e):
raise HTTPException(status_code=404, detail=str(e))
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.PERSONA_NOT_FOUND, str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))

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.access.hierarchy_access import get_user_external_group_ids
@@ -12,6 +11,8 @@ from onyx.db.engine.sql_engine import get_session
from onyx.db.hierarchy import get_accessible_hierarchy_nodes_for_source
from onyx.db.models import User
from onyx.db.opensearch_migration import get_opensearch_retrieval_state
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.hierarchy.constants import DOCUMENT_PAGE_SIZE
from onyx.server.features.hierarchy.constants import HIERARCHY_NODE_DOCUMENTS_PATH
from onyx.server.features.hierarchy.constants import HIERARCHY_NODES_LIST_PATH
@@ -43,14 +44,14 @@ router = APIRouter(prefix=HIERARCHY_NODES_PREFIX)
def _require_opensearch(db_session: Session) -> None:
if not ENABLE_OPENSEARCH_INDEXING_FOR_ONYX:
raise HTTPException(
status_code=403,
detail=OPENSEARCH_NOT_ENABLED_MESSAGE,
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
OPENSEARCH_NOT_ENABLED_MESSAGE,
)
if not get_opensearch_retrieval_state(db_session):
raise HTTPException(
status_code=403,
detail=MIGRATION_STATUS_MESSAGE,
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
MIGRATION_STATUS_MESSAGE,
)

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_admin_user
@@ -15,6 +14,8 @@ from onyx.db.input_prompt import remove_public_input_prompt
from onyx.db.input_prompt import update_input_prompt
from onyx.db.models import InputPrompt__User
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.input_prompt.models import CreateInputPromptRequest
from onyx.server.features.input_prompt.models import InputPromptSnapshot
from onyx.server.features.input_prompt.models import UpdateInputPromptRequest
@@ -97,7 +98,7 @@ def patch_input_prompt(
except ValueError as e:
error_msg = "Error occurred while updated input prompt"
logger.warn(f"{error_msg}. Stack trace: {e}")
raise HTTPException(status_code=404, detail=error_msg)
raise OnyxError(OnyxErrorCode.NOT_FOUND, error_msg)
return InputPromptSnapshot.from_model(updated_input_prompt)
@@ -117,7 +118,7 @@ def delete_input_prompt(
except ValueError as e:
error_msg = "Error occurred while deleting input prompt"
logger.warn(f"{error_msg}. Stack trace: {e}")
raise HTTPException(status_code=404, detail=error_msg)
raise OnyxError(OnyxErrorCode.NOT_FOUND, error_msg)
@admin_router.delete("/{input_prompt_id}")
@@ -132,7 +133,7 @@ def delete_public_input_prompt(
except ValueError as e:
error_msg = "Error occurred while deleting input prompt"
logger.warn(f"{error_msg}. Stack trace: {e}")
raise HTTPException(status_code=404, detail=error_msg)
raise OnyxError(OnyxErrorCode.NOT_FOUND, error_msg)
@basic_router.post("/{input_prompt_id}/hide")

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.models import User
from onyx.db.notification import dismiss_notification
from onyx.db.notification import get_notification_by_id
from onyx.db.notification import get_notifications
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.build.utils import ensure_build_mode_intro_notification
from onyx.server.features.release_notes.utils import (
ensure_release_notes_fresh_and_notify,
@@ -64,10 +65,11 @@ def dismiss_notification_endpoint(
try:
notification = get_notification_by_id(notification_id, user, db_session)
except PermissionError:
raise HTTPException(
status_code=403, detail="Not authorized to dismiss this notification"
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Not authorized to dismiss this notification",
)
except ValueError:
raise HTTPException(status_code=404, detail="Notification not found")
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Notification not found")
dismiss_notification(notification, db_session)

View File

@@ -1,6 +1,5 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi_users.exceptions import InvalidPasswordException
from sqlalchemy.orm import Session
@@ -11,6 +10,8 @@ from onyx.auth.users import User
from onyx.auth.users import UserManager
from onyx.db.engine.sql_engine import get_session
from onyx.db.users import get_user_by_email
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.password.models import ChangePasswordRequest
from onyx.server.features.password.models import UserResetRequest
from onyx.server.features.password.models import UserResetResponse
@@ -34,10 +35,11 @@ async def change_my_password(
new_password=form_data.new_password,
)
except InvalidPasswordException as e:
raise HTTPException(status_code=400, detail=str(e.reason))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e.reason))
except Exception as e:
raise HTTPException(
status_code=500, detail=f"An unexpected error occurred: {str(e)}"
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
f"An unexpected error occurred: {str(e)}",
)
@@ -53,7 +55,7 @@ async def admin_reset_user_password(
"""
user = get_user_by_email(user_reset_request.user_email, db_session)
if not user:
raise HTTPException(status_code=404, detail="User not found")
raise OnyxError(OnyxErrorCode.USER_NOT_FOUND, "User not found")
new_password = await user_manager.reset_password_as_admin(user.id)
return UserResetResponse(
user_id=str(user.id),

View File

@@ -3,7 +3,6 @@ import re
import requests
from fastapi import APIRouter
from fastapi import HTTPException
from onyx import __version__
from onyx.auth.users import anonymous_user_enabled
@@ -16,6 +15,8 @@ from onyx.configs.constants import DEV_VERSION_PATTERN
from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.configs.constants import STABLE_VERSION_PATTERN
from onyx.db.auth import get_user_count
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.manage.models import AllVersions
from onyx.server.manage.models import AuthTypeResponse
from onyx.server.manage.models import ContainerVersions
@@ -104,14 +105,14 @@ def get_versions() -> AllVersions:
# Ensure we have at least one tag of each type
if not dev_tags:
raise HTTPException(
status_code=500,
detail="No valid dev versions found matching pattern v(number).(number).(number)-beta.(number)",
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"No valid dev versions found matching pattern v(number).(number).(number)-beta.(number)",
)
if not stable_tags:
raise HTTPException(
status_code=500,
detail="No valid stable versions found matching pattern v(number).(number).(number)",
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"No valid stable versions found matching pattern v(number).(number).(number)",
)
# Sort common tags and get the latest one

View File

@@ -2,7 +2,6 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
@@ -11,6 +10,8 @@ from onyx.db.models import User
from onyx.db.pat import create_pat
from onyx.db.pat import list_user_pats
from onyx.db.pat import revoke_pat
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.pat.models import CreatedTokenResponse
from onyx.server.pat.models import CreateTokenRequest
from onyx.server.pat.models import TokenResponse
@@ -57,7 +58,7 @@ def create_token(
expiration_days=request.expiration_days,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
logger.info(f"User {user.email} created PAT '{request.name}'")
@@ -81,9 +82,7 @@ def delete_token(
"""Delete (revoke) personal access token. Only owner can revoke their own tokens."""
success = revoke_pat(db_session, token_id, user.id)
if not success:
raise HTTPException(
status_code=404, detail="Token not found or not owned by user"
)
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Token not found or not owned by user")
logger.info(f"User {user.email} revoked token {token_id}")
return {"message": "Token deleted successfully"}

View File

@@ -60,11 +60,9 @@ class Settings(BaseModel):
deep_research_enabled: bool | None = None
search_ui_enabled: bool | None = None
# Whether EE features are unlocked for use.
# Depends on license status: True when the user has a valid license
# (ACTIVE, GRACE_PERIOD, PAYMENT_REMINDER), False when there's no license
# or the license is expired (GATED_ACCESS).
# This controls UI visibility of EE features (user groups, analytics, RBAC, etc.).
# Enterprise features flag - set by license enforcement at runtime
# When LICENSE_ENFORCEMENT_ENABLED=true, this reflects license status
# When LICENSE_ENFORCEMENT_ENABLED=false, defaults to False
ee_features_enabled: bool = False
temperature_override_enabled: bool | None = False

View File

@@ -2,7 +2,6 @@
from collections.abc import Callable
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.configs.app_configs import ANTHROPIC_DEFAULT_API_KEY
@@ -12,6 +11,8 @@ from onyx.configs.app_configs import OPENROUTER_DEFAULT_API_KEY
from onyx.db.usage import check_usage_limit
from onyx.db.usage import UsageLimitExceededError
from onyx.db.usage import UsageType
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.tenant_usage_limits import TenantUsageLimitKeys
from onyx.server.tenant_usage_limits import TenantUsageLimitOverrides
from onyx.utils.logger import setup_logger
@@ -267,4 +268,4 @@ def check_usage_and_raise(
"Please upgrade your plan or wait for the next billing period."
)
raise HTTPException(status_code=429, detail=detail)
raise OnyxError(OnyxErrorCode.RATE_LIMITED, detail)

View File

@@ -1,15 +1,14 @@
"""Utilities for gating endpoints that require a vector database."""
from fastapi import HTTPException
from starlette.status import HTTP_501_NOT_IMPLEMENTED
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
def require_vector_db() -> None:
"""FastAPI dependency — raises 501 when the vector DB is disabled."""
if DISABLE_VECTOR_DB:
raise HTTPException(
status_code=HTTP_501_NOT_IMPLEMENTED,
detail="This feature requires a vector database (DISABLE_VECTOR_DB is set).",
raise OnyxError(
OnyxErrorCode.NOT_IMPLEMENTED,
"This feature requires a vector database (DISABLE_VECTOR_DB is set).",
)

View File

@@ -281,10 +281,9 @@ class TestApplyLicenseStatusToSettings:
}
class TestSettingsDefaults:
"""Verify Settings model defaults for CE deployments."""
class TestSettingsDefaultEEDisabled:
"""Verify the Settings model defaults ee_features_enabled to False."""
def test_default_ee_features_disabled(self) -> None:
"""CE default: ee_features_enabled is False."""
settings = Settings()
assert settings.ee_features_enabled is False

View File

@@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
import Text from "@/refresh-components/texts/Text";
import TableQualifier from "@/refresh-components/table/TableQualifier";
import { TableSizeProvider } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import type { QualifierContentType } from "@/refresh-components/table/types";
import { SvgCheckCircle } from "@opal/icons";
// ---------------------------------------------------------------------------
// Content type configurations
// ---------------------------------------------------------------------------
interface ContentConfig {
label: string;
content: QualifierContentType;
extraProps: Record<string, unknown>;
}
const CONTENT_TYPES: ContentConfig[] = [
{
label: "Simple",
content: "simple",
extraProps: {},
},
{
label: "Icon",
content: "icon",
extraProps: { icon: SvgCheckCircle },
},
{
label: "Image",
content: "image",
extraProps: {
imageSrc: "https://picsum.photos/36",
imageAlt: "Placeholder",
},
},
{
label: "Avatar Icon",
content: "avatar-icon",
extraProps: {},
},
{
label: "Avatar User",
content: "avatar-user",
extraProps: { initials: "AJ" },
},
];
// ---------------------------------------------------------------------------
// Row of qualifier states for a single content type
// ---------------------------------------------------------------------------
interface QualifierRowProps {
config: ContentConfig;
}
function QualifierRow({ config }: QualifierRowProps) {
const [selectableSelected, setSelectableSelected] = useState(false);
const [permanentSelected, setPermanentSelected] = useState(true);
return (
<div className="space-y-2">
<Text mainUiAction text02>
{config.label}
</Text>
<div className="flex items-start gap-8">
{/* Default */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={false}
selected={false}
disabled={false}
{...config.extraProps}
/>
<Text secondaryBody text04>
Default
</Text>
</div>
{/* Selectable (hover to reveal checkbox) */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={true}
selected={selectableSelected}
disabled={false}
onSelectChange={setSelectableSelected}
{...config.extraProps}
/>
<Text secondaryBody text04>
Selectable
</Text>
</div>
{/* Selected */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={true}
selected={permanentSelected}
disabled={false}
onSelectChange={setPermanentSelected}
{...config.extraProps}
/>
<Text secondaryBody text04>
Selected
</Text>
</div>
{/* Disabled (unselected) */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={true}
selected={false}
disabled={true}
{...config.extraProps}
/>
<Text secondaryBody text04>
Disabled
</Text>
</div>
{/* Disabled (selected) */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={true}
selected={true}
disabled={true}
{...config.extraProps}
/>
<Text secondaryBody text04>
Disabled+Sel
</Text>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Size section — all content types at a given size
// ---------------------------------------------------------------------------
interface SizeSectionProps {
size: TableSize;
title: string;
}
function SizeSection({ size, title }: SizeSectionProps) {
return (
<div className="space-y-6">
<Text headingH3>{title}</Text>
<TableSizeProvider size={size}>
<div className="flex flex-col gap-8">
{CONTENT_TYPES.map((config) => (
<QualifierRow key={`${size}-${config.content}`} config={config} />
))}
</div>
</TableSizeProvider>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function TableQualifierDemoPage() {
return (
<div className="p-6 space-y-10">
<div className="space-y-4">
<Text headingH2>TableQualifier Demo</Text>
<Text mainContentMuted text03>
All content types, sizes, and interactive states. Hover selectable
variants to reveal the checkbox; click to toggle.
</Text>
</div>
<SizeSection size="regular" title="Regular (36px)" />
<SizeSection size="small" title="Small (28px)" />
</div>
);
}

View File

@@ -1,12 +1,11 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { FileDescriptor } from "@/app/app/interfaces";
import "katex/dist/katex.min.css";
import MessageSwitcher from "@/app/app/message/MessageSwitcher";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import useScreenSize from "@/hooks/useScreenSize";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import { Button } from "@opal/components";
import { SvgEdit } from "@opal/icons";
@@ -138,7 +137,6 @@ const HumanMessage = React.memo(function HumanMessage({
const [content, setContent] = useState(initialContent);
const [isEditing, setIsEditing] = useState(false);
const { isMobile } = useScreenSize();
// Use nodeId for switching (finding position in siblings)
const indexInSiblings = otherMessagesCanSwitchTo?.indexOf(nodeId);
@@ -170,104 +168,119 @@ const HumanMessage = React.memo(function HumanMessage({
return undefined;
};
const copyEditButton = useMemo(
() => (
<div className="flex flex-row flex-shrink px-1 opacity-0 group-hover:opacity-100 transition-opacity">
<CopyIconButton
getCopyText={() => content}
prominence="tertiary"
data-testid="HumanMessage/copy-button"
/>
<Button
icon={SvgEdit}
prominence="tertiary"
tooltip="Edit"
onClick={() => setIsEditing(true)}
data-testid="HumanMessage/edit-button"
/>
</div>
),
[content]
);
return (
<div
id="onyx-human-message"
className="group flex flex-col justify-end w-full relative"
>
<FileDisplay alignBubble files={files || []} />
{isEditing ? (
<MessageEditing
content={content}
onSubmitEdit={(editedContent) => {
// Don't update UI for edits that can't be persisted
if (messageId === undefined || messageId === null) {
setIsEditing(false);
return;
}
onEdit?.(editedContent, messageId);
setContent(editedContent);
setIsEditing(false);
}}
onCancelEdit={() => setIsEditing(false)}
/>
) : (
<div className="flex justify-end">
{onEdit && !isMobile && copyEditButton}
<div className="md:max-w-[37.5rem]">
<div
className={
"max-w-[30rem] md:max-w-[37.5rem] whitespace-break-spaces break-anywhere rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
<div className="md:flex md:flex-wrap relative justify-end break-words">
{isEditing ? (
<MessageEditing
content={content}
onSubmitEdit={(editedContent) => {
// Don't update UI for edits that can't be persisted
if (messageId === undefined || messageId === null) {
setIsEditing(false);
return;
}
onCopy={(e) => {
const selection = window.getSelection();
if (selection) {
e.preventDefault();
const text = selection
.toString()
.replace(/\n{2,}/g, "\n")
.trim();
e.clipboardData.setData("text/plain", text);
onEdit?.(editedContent, messageId);
setContent(editedContent);
setIsEditing(false);
}}
onCancelEdit={() => setIsEditing(false)}
/>
) : typeof content === "string" ? (
<>
<div className="md:max-w-[37.5rem] flex basis-[100%] md:basis-auto justify-end md:order-1">
<div
className={
"max-w-[30rem] md:max-w-[37.5rem] whitespace-break-spaces break-anywhere rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
}
}}
>
<Text
as="p"
className="inline-block align-middle"
mainContentBody
onCopy={(e) => {
const selection = window.getSelection();
if (selection) {
e.preventDefault();
const text = selection
.toString()
.replace(/\n{2,}/g, "\n")
.trim();
e.clipboardData.setData("text/plain", text);
}
}}
>
{content}
</Text>
<Text
as="p"
className="inline-block align-middle"
mainContentBody
>
{content}
</Text>
</div>
</div>
</div>
{onEdit && !isEditing && (
<div className="absolute md:relative right-0 z-content flex flex-row p-1 opacity-0 group-hover:opacity-100 transition-opacity">
<CopyIconButton
getCopyText={() => content}
prominence="tertiary"
data-testid="HumanMessage/copy-button"
/>
<Button
icon={SvgEdit}
prominence="tertiary"
tooltip="Edit"
onClick={() => setIsEditing(true)}
data-testid="HumanMessage/edit-button"
/>
</div>
)}
</>
) : (
<>
<div
className={cn(
"my-auto",
onEdit && !isEditing
? "opacity-0 group-hover:opacity-100 transition-opacity"
: "invisible"
)}
>
<Button
icon={SvgEdit}
onClick={() => setIsEditing(true)}
prominence="tertiary"
tooltip="Edit"
/>
</div>
<div className="ml-auto rounded-lg p-1">{content}</div>
</>
)}
<div className="md:min-w-[100%] flex justify-end order-1 mt-1">
{currentMessageInd !== undefined &&
onMessageSelection &&
otherMessagesCanSwitchTo &&
otherMessagesCanSwitchTo.length > 1 && (
<MessageSwitcher
disableForStreaming={disableSwitchingForStreaming}
currentPage={currentMessageInd + 1}
totalPages={otherMessagesCanSwitchTo.length}
handlePrevious={() => {
stopGenerating();
const prevMessage = getPreviousMessage();
if (prevMessage !== undefined) {
onMessageSelection(prevMessage);
}
}}
handleNext={() => {
stopGenerating();
const nextMessage = getNextMessage();
if (nextMessage !== undefined) {
onMessageSelection(nextMessage);
}
}}
/>
)}
</div>
)}
<div className="flex justify-end pt-1">
{!isEditing && onEdit && isMobile && copyEditButton}
{currentMessageInd !== undefined &&
onMessageSelection &&
otherMessagesCanSwitchTo &&
otherMessagesCanSwitchTo.length > 1 && (
<MessageSwitcher
disableForStreaming={disableSwitchingForStreaming}
currentPage={currentMessageInd + 1}
totalPages={otherMessagesCanSwitchTo.length}
handlePrevious={() => {
stopGenerating();
const prevMessage = getPreviousMessage();
if (prevMessage !== undefined) {
onMessageSelection(prevMessage);
}
}}
handleNext={() => {
stopGenerating();
const nextMessage = getNextMessage();
if (nextMessage !== undefined) {
onMessageSelection(nextMessage);
}
}}
/>
)}
</div>
</div>
);

View File

@@ -1,18 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { toast } from "@/hooks/useToast";
export default function EEFeatureRedirect() {
const router = useRouter();
useEffect(() => {
toast.error(
"This feature requires a license. Please upgrade your plan to access."
);
router.replace("/app");
}, [router]);
return null;
}

View File

@@ -1,6 +1,5 @@
import { SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED } from "@/lib/constants";
import { fetchStandardSettingsSS } from "@/components/settings/lib";
import EEFeatureRedirect from "@/app/ee/EEFeatureRedirect";
export default async function AdminLayout({
children,
@@ -9,7 +8,13 @@ export default async function AdminLayout({
}) {
// First check build-time constant (fast path)
if (!SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED) {
return <EEFeatureRedirect />;
return (
<div className="flex h-screen">
<div className="mx-auto my-auto text-lg font-bold text-red-500">
This functionality is only available in the Enterprise Edition :(
</div>
</div>
);
}
// Then check runtime license status (for license enforcement mode)
@@ -26,7 +31,13 @@ export default async function AdminLayout({
return children;
}
return <EEFeatureRedirect />;
return (
<div className="flex h-screen">
<div className="mx-auto my-auto text-lg font-bold text-red-500">
This functionality requires an active Enterprise license.
</div>
</div>
);
}
}
} catch (error) {

View File

@@ -484,8 +484,12 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
ref={chatInputBarRef}
deepResearchEnabled={deepResearchEnabled}
toggleDeepResearch={toggleDeepResearch}
toggleDocumentSidebar={() => {}}
filterManager={filterManager}
llmManager={llmManager}
removeDocs={() => {}}
retrievalEnabled={retrievalEnabled}
selectedDocuments={[]}
initialMessage={message}
stopGenerating={stopGenerating}
onSubmit={handleChatInputSubmit}

View File

@@ -23,7 +23,8 @@ export interface AppModeProviderProps {
export function AppModeProvider({ children }: AppModeProviderProps) {
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const { user } = useUser();
const { isSearchModeAvailable } = useSettingsContext();
const settings = useSettingsContext();
const { isSearchModeAvailable } = settings;
const persistedMode = user?.preferences?.default_app_mode;
const [appMode, setAppModeState] = useState<AppMode>("chat");

View File

@@ -11,8 +11,21 @@ import {
* Hook to fetch billing information from Stripe.
*
* Works for both cloud and self-hosted deployments:
* - Cloud: fetches from /api/tenants/billing-information
* - Cloud: fetches from /api/tenants/billing-information (legacy endpoint)
* - Self-hosted: fetches from /api/admin/billing/billing-information
*
* Returns subscription status, seats, billing period, etc.
*
* @example
* ```tsx
* const { data, isLoading, error, refresh } = useBillingInformation();
*
* if (isLoading) return <Loading />;
* if (error) return <Error />;
* if (!data || !hasActiveSubscription(data)) return <NoSubscription />;
*
* return <BillingDetails billing={data} />;
* ```
*/
export function useBillingInformation() {
const url = NEXT_PUBLIC_CLOUD_ENABLED
@@ -25,9 +38,16 @@ export function useBillingInformation() {
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 30000,
// Don't auto-retry on errors (circuit breaker will block requests anyway)
shouldRetryOnError: false,
// Keep previous data while revalidating to prevent UI flashing
keepPreviousData: true,
});
return { data, isLoading, error, refresh: mutate };
return {
data,
isLoading,
error,
refresh: mutate,
};
}

View File

@@ -7,9 +7,23 @@ import { LicenseStatus } from "@/lib/billing/interfaces";
/**
* Hook to fetch license status for self-hosted deployments.
*
* Skips the fetch on cloud deployments (uses tenant auth instead).
* Returns license information including seats, expiry, and status.
* Only fetches for self-hosted deployments (cloud uses tenant auth instead).
*
* @example
* ```tsx
* const { data, isLoading, error, refresh } = useLicense();
*
* if (isLoading) return <Loading />;
* if (error) return <Error />;
* if (!data?.has_license) return <NoLicense />;
*
* return <LicenseDetails license={data} />;
* ```
*/
export function useLicense() {
// Only fetch license for self-hosted deployments
// Cloud deployments use tenant-based auth, not license files
const url = NEXT_PUBLIC_CLOUD_ENABLED ? null : "/api/license";
const { data, error, mutate, isLoading } = useSWR<LicenseStatus>(
@@ -24,14 +38,20 @@ export function useLicense() {
}
);
if (!url) {
// Return empty state for cloud deployments
if (NEXT_PUBLIC_CLOUD_ENABLED) {
return {
data: undefined,
data: null,
isLoading: false,
error: undefined,
refresh: () => Promise.resolve(undefined),
};
}
return { data, isLoading, error, refresh: mutate };
return {
data,
isLoading,
error,
refresh: mutate,
};
}

View File

@@ -46,8 +46,8 @@ export interface Settings {
// Onyx Craft (Build Mode) feature flag
onyx_craft_enabled?: boolean;
// Whether EE features are unlocked (user has a valid enterprise license).
// Controls UI visibility of EE features like user groups, analytics, RBAC.
// Enterprise features flag - controlled by license enforcement at runtime
// True when user has a valid license, False for community edition
ee_features_enabled?: boolean;
// Seat usage - populated when seat limit is exceeded

View File

@@ -190,17 +190,14 @@ function AttachmentItemLayout({
alignItems="center"
gap={1.5}
>
<div className="flex-1 min-w-0">
<Content
title={title}
description={description}
sizePreset="main-ui"
variant="section"
widthVariant="full"
/>
</div>
<Content
title={title}
description={description}
sizePreset="main-ui"
variant="section"
/>
{middleText && (
<div className="flex-1 min-w-0">
<div className="flex-1">
<Truncated text03 secondaryBody>
{middleText}
</Truncated>

View File

@@ -42,13 +42,8 @@ export const NEXT_PUBLIC_CUSTOM_REFRESH_URL =
// NOTE: this should ONLY be used on the server-side. If used client side,
// it will not be accurate (will always be false).
// Mirrors backend logic: EE is enabled if EITHER the legacy flag OR license
// enforcement is active. LICENSE_ENFORCEMENT_ENABLED defaults to true on the
// backend, so we treat undefined as enabled here to match.
export const SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED =
process.env.ENABLE_PAID_ENTERPRISE_EDITION_FEATURES?.toLowerCase() ===
"true" ||
process.env.LICENSE_ENFORCEMENT_ENABLED?.toLowerCase() !== "false";
process.env.ENABLE_PAID_ENTERPRISE_EDITION_FEATURES?.toLowerCase() === "true";
// NOTE: since this is a `NEXT_PUBLIC_` variable, it will be set at
// build-time
// TODO: consider moving this to an API call so that the api_server

View File

@@ -51,6 +51,16 @@ function ToastContainer() {
}, ANIMATION_DURATION);
}, []);
// NOTE (@raunakab):
//
// Keep this here for debugging purposes.
// useOnMount(() => {
// toast.success("Test success toast", { duration: Infinity });
// toast.error("Test error toast", { duration: Infinity });
// toast.warning("Test warning toast", { duration: Infinity });
// toast.info("Test info toast", { duration: Infinity });
// });
if (visible.length === 0) return null;
return (

View File

@@ -1,455 +0,0 @@
"use client";
"use no memo";
import { useMemo } from "react";
import { flexRender } from "@tanstack/react-table";
import useDataTable, {
toOnyxSortDirection,
} from "@/refresh-components/table/hooks/useDataTable";
import useColumnWidths from "@/refresh-components/table/hooks/useColumnWidths";
import useDraggableRows from "@/refresh-components/table/hooks/useDraggableRows";
import Table from "@/refresh-components/table/Table";
import TableHeader from "@/refresh-components/table/TableHeader";
import TableBody from "@/refresh-components/table/TableBody";
import TableRow from "@/refresh-components/table/TableRow";
import TableHead from "@/refresh-components/table/TableHead";
import TableCell from "@/refresh-components/table/TableCell";
import TableQualifier from "@/refresh-components/table/TableQualifier";
import QualifierContainer from "@/refresh-components/table/QualifierContainer";
import ActionsContainer from "@/refresh-components/table/ActionsContainer";
import DragOverlayRow from "@/refresh-components/table/DragOverlayRow";
import Footer from "@/refresh-components/table/Footer";
import { TableSizeProvider } from "@/refresh-components/table/TableSizeContext";
import { ColumnVisibilityPopover } from "@/refresh-components/table/ColumnVisibilityPopover";
import { SortingPopover } from "@/refresh-components/table/SortingPopover";
import type { WidthConfig } from "@/refresh-components/table/hooks/useColumnWidths";
import type { ColumnDef } from "@tanstack/react-table";
import type {
DataTableProps,
DataTableFooterConfig,
OnyxColumnDef,
OnyxDataColumn,
OnyxQualifierColumn,
OnyxActionsColumn,
} from "@/refresh-components/table/types";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
const noopGetRowId = () => "";
// ---------------------------------------------------------------------------
// Internal: resolve size-dependent widths and build TanStack columns
// ---------------------------------------------------------------------------
interface ProcessedColumns<TData> {
tanstackColumns: ColumnDef<TData, any>[];
widthConfig: WidthConfig;
qualifierColumn: OnyxQualifierColumn<TData> | null;
/** Map from column ID → OnyxColumnDef for dispatch in render loops. */
columnKindMap: Map<string, OnyxColumnDef<TData>>;
}
function processColumns<TData>(
columns: OnyxColumnDef<TData>[],
size: TableSize
): ProcessedColumns<TData> {
const tanstackColumns: ColumnDef<TData, any>[] = [];
const fixedColumnIds = new Set<string>();
const columnWeights: Record<string, number> = {};
const columnMinWidths: Record<string, number> = {};
const columnKindMap = new Map<string, OnyxColumnDef<TData>>();
let qualifierColumn: OnyxQualifierColumn<TData> | null = null;
for (const col of columns) {
const resolvedWidth =
typeof col.width === "function" ? col.width(size) : col.width;
// Clone def to avoid mutating the caller's column definitions
const clonedDef: ColumnDef<TData, any> = {
...col.def,
id: col.id,
size:
"fixed" in resolvedWidth ? resolvedWidth.fixed : resolvedWidth.weight,
};
tanstackColumns.push(clonedDef);
const id = col.id;
columnKindMap.set(id, col);
if ("fixed" in resolvedWidth) {
fixedColumnIds.add(id);
} else {
columnWeights[id] = resolvedWidth.weight;
columnMinWidths[id] = resolvedWidth.minWidth ?? 50;
}
if (col.kind === "qualifier") qualifierColumn = col;
}
return {
tanstackColumns,
widthConfig: { fixedColumnIds, columnWeights, columnMinWidths },
qualifierColumn,
columnKindMap,
};
}
// ---------------------------------------------------------------------------
// DataTable component
// ---------------------------------------------------------------------------
/**
* Config-driven table component that wires together `useDataTable`,
* `useColumnWidths`, and `useDraggableRows` automatically.
*
* Full flexibility via the column definitions from `createTableColumns()`.
*
* @example
* ```tsx
* const tc = createTableColumns<TeamMember>();
* const columns = [
* tc.qualifier({ content: "avatar-user", getInitials: (r) => r.initials }),
* tc.column("name", { header: "Name", weight: 23, minWidth: 120 }),
* tc.column("email", { header: "Email", weight: 28 }),
* tc.actions(),
* ];
*
* <DataTable data={items} columns={columns} footer={{ mode: "selection" }} />
* ```
*/
export default function DataTable<TData>(props: DataTableProps<TData>) {
const {
data,
columns,
pageSize,
initialSorting,
initialColumnVisibility,
draggable,
footer,
size = "regular",
onRowClick,
height,
headerBackground,
} = props;
const effectivePageSize = pageSize ?? (footer ? 10 : data.length);
// 1. Process columns (memoized on columns + size)
const { tanstackColumns, widthConfig, qualifierColumn, columnKindMap } =
useMemo(() => processColumns(columns, size), [columns, size]);
// 2. Call useDataTable
const {
table,
currentPage,
totalPages,
totalItems,
setPage,
pageSize: resolvedPageSize,
selectionState,
selectedCount,
clearSelection,
toggleAllPageRowsSelected,
isAllPageRowsSelected,
} = useDataTable({
data,
columns: tanstackColumns,
pageSize: effectivePageSize,
initialSorting,
initialColumnVisibility,
});
// 3. Call useColumnWidths
const { containerRef, columnWidths, createResizeHandler } = useColumnWidths({
headers: table.getHeaderGroups()[0]?.headers ?? [],
...widthConfig,
});
// 4. Call useDraggableRows (conditional)
const draggableReturn = useDraggableRows({
data,
getRowId: draggable?.getRowId ?? noopGetRowId,
enabled: !!draggable && table.getState().sorting.length === 0,
onReorder: draggable?.onReorder,
});
const hasDraggable = !!draggable;
const rowVariant = hasDraggable ? "table" : "list";
const isSelectable =
qualifierColumn != null && qualifierColumn.selectable !== false;
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
function renderContent() {
return (
<div>
<div
className="overflow-x-auto"
ref={containerRef}
style={{
...(height != null
? {
maxHeight:
typeof height === "number" ? `${height}px` : height,
overflowY: "auto" as const,
}
: undefined),
...(headerBackground
? ({
"--table-header-bg": headerBackground,
} as React.CSSProperties)
: undefined),
}}
>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header, headerIndex) => {
const colDef = columnKindMap.get(header.id);
// Qualifier header
if (colDef?.kind === "qualifier") {
if (qualifierColumn?.header === false) {
return (
<QualifierContainer key={header.id} type="head" />
);
}
return (
<QualifierContainer key={header.id} type="head">
<TableQualifier
content={
qualifierColumn?.headerContentType ?? "simple"
}
selectable={isSelectable}
selected={isSelectable && isAllPageRowsSelected}
onSelectChange={
isSelectable
? (checked) =>
toggleAllPageRowsSelected(checked)
: undefined
}
/>
</QualifierContainer>
);
}
// Actions header
if (colDef?.kind === "actions") {
const actionsDef = colDef as OnyxActionsColumn<TData>;
return (
<ActionsContainer key={header.id} type="head">
{actionsDef.showColumnVisibility !== false && (
<ColumnVisibilityPopover
table={table}
columnVisibility={
table.getState().columnVisibility
}
size={size}
/>
)}
{actionsDef.showSorting !== false && (
<SortingPopover
table={table}
sorting={table.getState().sorting}
size={size}
footerText={actionsDef.sortingFooterText}
/>
)}
</ActionsContainer>
);
}
// Data / Display header
const canSort = header.column.getCanSort();
const sortDir = header.column.getIsSorted();
const nextHeader = headerGroup.headers[headerIndex + 1];
const canResize =
header.column.getCanResize() &&
!!nextHeader &&
!widthConfig.fixedColumnIds.has(nextHeader.id);
const dataCol =
colDef?.kind === "data"
? (colDef as OnyxDataColumn<TData>)
: null;
return (
<TableHead
key={header.id}
width={columnWidths[header.id]}
sorted={
canSort ? toOnyxSortDirection(sortDir) : undefined
}
onSort={
canSort
? () => header.column.toggleSorting()
: undefined
}
icon={dataCol?.icon}
resizable={canResize}
onResizeStart={
canResize
? createResizeHandler(header.id, nextHeader.id)
: undefined
}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody
dndSortable={hasDraggable ? draggableReturn : undefined}
renderDragOverlay={
hasDraggable
? (activeId) => {
const row = table
.getRowModel()
.rows.find(
(r) => draggable!.getRowId(r.original) === activeId
);
if (!row) return null;
return <DragOverlayRow row={row} variant={rowVariant} />;
}
: undefined
}
>
{table.getRowModel().rows.map((row) => {
const rowId = hasDraggable
? draggable!.getRowId(row.original)
: undefined;
return (
<TableRow
key={row.id}
variant={rowVariant}
sortableId={rowId}
selected={row.getIsSelected()}
onClick={() => {
if (onRowClick) {
onRowClick(row.original);
} else if (isSelectable) {
row.toggleSelected();
}
}}
>
{row.getVisibleCells().map((cell) => {
const cellColDef = columnKindMap.get(cell.column.id);
// Qualifier cell
if (cellColDef?.kind === "qualifier") {
const qDef = cellColDef as OnyxQualifierColumn<TData>;
return (
<QualifierContainer
key={cell.id}
type="cell"
onClick={(e) => e.stopPropagation()}
>
<TableQualifier
content={qDef.content}
initials={qDef.getInitials?.(row.original)}
icon={qDef.getIcon?.(row.original)}
imageSrc={qDef.getImageSrc?.(row.original)}
selectable={isSelectable}
selected={isSelectable && row.getIsSelected()}
onSelectChange={
isSelectable
? (checked) => {
row.toggleSelected(checked);
}
: undefined
}
/>
</QualifierContainer>
);
}
// Actions cell
if (cellColDef?.kind === "actions") {
return (
<ActionsContainer key={cell.id} type="cell">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</ActionsContainer>
);
}
// Data / Display cell
return (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{footer && renderFooter(footer)}
</div>
);
}
function renderFooter(footerConfig: DataTableFooterConfig) {
if (footerConfig.mode === "selection") {
return (
<Footer
mode="selection"
multiSelect={footerConfig.multiSelect !== false}
selectionState={selectionState}
selectedCount={selectedCount}
onClear={footerConfig.onClear ?? clearSelection}
onView={footerConfig.onView}
pageSize={resolvedPageSize}
totalItems={totalItems}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
/>
);
}
// Summary mode
const rangeStart =
totalItems === 0
? 0
: !isFinite(resolvedPageSize)
? 1
: (currentPage - 1) * resolvedPageSize + 1;
const rangeEnd = !isFinite(resolvedPageSize)
? totalItems
: Math.min(currentPage * resolvedPageSize, totalItems);
return (
<Footer
mode="summary"
rangeStart={rangeStart}
rangeEnd={rangeEnd}
totalItems={totalItems}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
/>
);
}
return <TableSizeProvider size={size}>{renderContent()}</TableSizeProvider>;
}

View File

@@ -1,317 +0,0 @@
# DataTable
Config-driven table built on [TanStack Table](https://tanstack.com/table). Handles column sizing (weight-based proportional distribution), drag-and-drop row reordering, pagination, row selection, column visibility, and sorting out of the box.
## Quick Start
```tsx
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
interface Person {
name: string;
email: string;
role: string;
}
// Define columns at module scope (stable reference, no re-renders)
const tc = createTableColumns<Person>();
const columns = [
tc.qualifier(),
tc.column("name", { header: "Name", weight: 30, minWidth: 120 }),
tc.column("email", { header: "Email", weight: 40, minWidth: 150 }),
tc.column("role", { header: "Role", weight: 30, minWidth: 80 }),
tc.actions(),
];
function PeopleTable({ data }: { data: Person[] }) {
return (
<DataTable
data={data}
columns={columns}
pageSize={10}
footer={{ mode: "selection" }}
/>
);
}
```
## Column Builder API
`createTableColumns<TData>()` returns a typed builder with four methods. Each returns an `OnyxColumnDef<TData>` that you pass to the `columns` prop.
### `tc.qualifier(config?)`
Leading column for avatars, icons, images, or checkboxes.
| Option | Type | Default | Description |
|---|---|---|---|
| `content` | `"simple" \| "icon" \| "image" \| "avatar-icon" \| "avatar-user"` | `"simple"` | Body row content type |
| `headerContentType` | same as `content` | `"simple"` | Header row content type |
| `getInitials` | `(row: TData) => string` | - | Extract initials (for `"avatar-user"`) |
| `getIcon` | `(row: TData) => IconFunctionComponent` | - | Extract icon (for `"icon"` / `"avatar-icon"`) |
| `getImageSrc` | `(row: TData) => string` | - | Extract image src (for `"image"`) |
| `selectable` | `boolean` | `true` | Show selection checkboxes |
| `header` | `boolean` | `true` | Render qualifier content in the header |
Width is fixed: 56px at `"regular"` size, 40px at `"small"`.
```ts
tc.qualifier({
content: "avatar-user",
getInitials: (row) => row.initials,
})
```
### `tc.column(accessor, config)`
Data column with sorting, resizing, and hiding. The `accessor` is a type-safe deep key into `TData`.
| Option | Type | Default | Description |
|---|---|---|---|
| `header` | `string` | **required** | Column header label |
| `cell` | `(value: TValue, row: TData) => ReactNode` | renders value as string | Custom cell renderer |
| `enableSorting` | `boolean` | `true` | Allow sorting |
| `enableResizing` | `boolean` | `true` | Allow column resize |
| `enableHiding` | `boolean` | `true` | Allow hiding via actions popover |
| `icon` | `(sorted: SortDirection) => IconFunctionComponent` | - | Override the sort indicator icon |
| `weight` | `number` | `20` | Proportional width weight |
| `minWidth` | `number` | `50` | Minimum width in pixels |
```ts
tc.column("email", {
header: "Email",
weight: 28,
minWidth: 150,
cell: (value) => <Content sizePreset="main-ui" variant="body" title={value} prominence="muted" />,
})
```
### `tc.displayColumn(config)`
Non-accessor column for custom content (e.g. computed values, action buttons per row).
| Option | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | **required** | Unique column ID |
| `header` | `string` | - | Optional header label |
| `cell` | `(row: TData) => ReactNode` | **required** | Cell renderer |
| `width` | `ColumnWidth` | **required** | `{ weight, minWidth? }` or `{ fixed }` |
| `enableHiding` | `boolean` | `true` | Allow hiding |
```ts
tc.displayColumn({
id: "fullName",
header: "Full Name",
cell: (row) => `${row.firstName} ${row.lastName}`,
width: { weight: 25, minWidth: 100 },
})
```
### `tc.actions(config?)`
Fixed-width column rendered at the trailing edge. Houses column visibility and sorting popovers in the header.
| Option | Type | Default | Description |
|---|---|---|---|
| `showColumnVisibility` | `boolean` | `true` | Show the column visibility popover |
| `showSorting` | `boolean` | `true` | Show the sorting popover |
| `sortingFooterText` | `string` | - | Footer text inside the sorting popover |
Width is fixed: 88px at `"regular"`, 20px at `"small"`.
```ts
tc.actions({
sortingFooterText: "Everyone will see agents in this order.",
})
```
## DataTable Props
`DataTableProps<TData>`:
| Prop | Type | Default | Description |
|---|---|---|---|
| `data` | `TData[]` | **required** | Row data |
| `columns` | `OnyxColumnDef<TData>[]` | **required** | Columns from `createTableColumns()` |
| `pageSize` | `number` | `10` (with footer) or `data.length` (without) | Rows per page. `Infinity` disables pagination |
| `initialSorting` | `SortingState` | `[]` | TanStack sorting state |
| `initialColumnVisibility` | `VisibilityState` | `{}` | Map of column ID to `false` to hide initially |
| `draggable` | `DataTableDraggableConfig<TData>` | - | Enable drag-and-drop (see below) |
| `footer` | `DataTableFooterConfig` | - | Footer mode (see below) |
| `size` | `"regular" \| "small"` | `"regular"` | Table density variant |
| `onRowClick` | `(row: TData) => void` | toggles selection | Called on row click, replaces default selection toggle |
| `height` | `number \| string` | - | Max height for scrollable body (header stays pinned). `300` or `"50vh"` |
| `headerBackground` | `string` | - | CSS color for the sticky header (prevents content showing through) |
## Footer Config
The `footer` prop accepts a discriminated union on `mode`.
### Selection mode
For tables with selectable rows. Shows a selection message + count pagination.
```ts
footer={{
mode: "selection",
multiSelect: true, // default true
onView: () => { ... }, // optional "View" button
onClear: () => { ... }, // optional "Clear" button (falls back to default clearSelection)
}}
```
### Summary mode
For read-only tables. Shows "Showing X~Y of Z" + list pagination.
```ts
footer={{ mode: "summary" }}
```
## Draggable Config
Enable drag-and-drop row reordering. DnD is automatically disabled when column sorting is active.
```ts
<DataTable
data={items}
columns={columns}
draggable={{
getRowId: (row) => row.id,
onReorder: (ids, changedOrders) => {
// ids: new ordered array of all row IDs
// changedOrders: { [id]: newIndex } for rows that moved
setItems(ids.map((id) => items.find((r) => r.id === id)!));
},
}}
/>
```
| Option | Type | Description |
|---|---|---|
| `getRowId` | `(row: TData) => string` | Extract a unique string ID from each row |
| `onReorder` | `(ids: string[], changedOrders: Record<string, number>) => void \| Promise<void>` | Called after a successful reorder |
## Sizing
The `size` prop (`"regular"` or `"small"`) affects:
- Qualifier column width (56px vs 40px)
- Actions column width (88px vs 20px)
- Footer text styles and pagination size
- All child components via `TableSizeContext`
Column widths can be responsive to size using a function:
```ts
// In types.ts, width accepts:
width: ColumnWidth | ((size: TableSize) => ColumnWidth)
// Example (this is what qualifier/actions use internally):
width: (size) => size === "small" ? { fixed: 40 } : { fixed: 56 }
```
### Width system
Data columns use **weight-based proportional distribution**. A column with `weight: 40` gets twice the space of one with `weight: 20`. When the container is narrower than the sum of `minWidth` values, columns clamp to their minimums.
Fixed columns (`{ fixed: N }`) take exactly N pixels and don't participate in proportional distribution.
Resizing uses **splitter semantics**: dragging a column border grows that column and shrinks its neighbor by the same amount, keeping total width constant.
## Advanced Examples
### Scrollable table with pinned header
```tsx
<DataTable
data={allRows}
columns={columns}
height={300}
headerBackground="var(--background-tint-00)"
/>
```
### Hidden columns on load
```tsx
<DataTable
data={data}
columns={columns}
initialColumnVisibility={{ department: false, joinDate: false }}
footer={{ mode: "selection" }}
/>
```
### Icon-based data column
```tsx
const STATUS_ICONS = {
active: SvgCheckCircle,
pending: SvgClock,
inactive: SvgAlertCircle,
} as const;
tc.column("status", {
header: "Status",
weight: 14,
minWidth: 80,
cell: (value) => (
<Content
sizePreset="main-ui"
variant="body"
icon={STATUS_ICONS[value]}
title={value.charAt(0).toUpperCase() + value.slice(1)}
/>
),
})
```
### Non-selectable qualifier with icons
```ts
tc.qualifier({
content: "icon",
getIcon: (row) => row.icon,
selectable: false,
header: false,
})
```
### Small variant in a bordered container
```tsx
<div className="border border-border-01 rounded-lg overflow-hidden">
<DataTable
data={data}
columns={columns}
size="small"
pageSize={10}
footer={{ mode: "selection" }}
/>
</div>
```
### Custom row click handler
```tsx
<DataTable
data={data}
columns={columns}
onRowClick={(row) => router.push(`/users/${row.id}`)}
/>
```
## Source Files
| File | Purpose |
|---|---|
| `DataTable.tsx` | Main component |
| `columns.ts` | `createTableColumns` builder |
| `types.ts` | All TypeScript interfaces |
| `hooks/useDataTable.ts` | TanStack table wrapper hook |
| `hooks/useColumnWidths.ts` | Weight-based width system |
| `hooks/useDraggableRows.ts` | DnD hook (`@dnd-kit`) |
| `Footer.tsx` | Selection / Summary footer modes |
| `TableSizeContext.tsx` | Size context provider |

View File

@@ -830,8 +830,12 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
ref={chatInputBarRef}
deepResearchEnabled={deepResearchEnabled}
toggleDeepResearch={toggleDeepResearch}
toggleDocumentSidebar={toggleDocumentSidebar}
filterManager={filterManager}
llmManager={llmManager}
removeDocs={() => setSelectedDocuments([])}
retrievalEnabled={retrievalEnabled}
selectedDocuments={selectedDocuments}
initialMessage={
searchParams?.get(SEARCH_PARAM_NAMES.USER_PROMPT) ||
""

View File

@@ -173,21 +173,19 @@ export function FileCard({
removeFile && doneUploading ? () => removeFile(file.id) : undefined
}
>
<div className="min-w-0 max-w-[12rem]">
<div className="max-w-[12rem]">
<Interactive.Container border heightVariant="fit">
<div className="[&_.opal-content-md-body]:min-w-0 [&_.opal-content-md-title]:break-all">
<AttachmentItemLayout
icon={isProcessing ? SimpleLoader : SvgFileText}
title={file.name}
description={
isProcessing
? file.status === UserFileStatus.UPLOADING
? "Uploading..."
: "Processing..."
: typeLabel
}
/>
</div>
<AttachmentItemLayout
icon={isProcessing ? SimpleLoader : SvgFileText}
title={file.name}
description={
isProcessing
? file.status === UserFileStatus.UPLOADING
? "Uploading..."
: "Processing..."
: typeLabel
}
/>
<Spacer horizontal rem={0.5} />
</Interactive.Container>
</div>

View File

@@ -16,18 +16,16 @@ import { FilterManager, LlmManager, useFederatedConnectors } from "@/lib/hooks";
import usePromptShortcuts from "@/hooks/usePromptShortcuts";
import useFilter from "@/hooks/useFilter";
import useCCPairs from "@/hooks/useCCPairs";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import { OnyxDocument, MinimalOnyxDocument } from "@/lib/search/interfaces";
import { ChatState } from "@/app/app/interfaces";
import { useForcedTools } from "@/lib/hooks/useForcedTools";
import { useAppMode } from "@/providers/AppModeProvider";
import useAppFocus from "@/hooks/useAppFocus";
import { cn, isImageFile } from "@/lib/utils";
import { getFormattedDateRangeString } from "@/lib/dateUtils";
import { truncateString, cn, isImageFile } from "@/lib/utils";
import { Disabled } from "@/refresh-components/Disabled";
import { useUser } from "@/providers/UserProvider";
import {
SettingsContext,
useVectorDbEnabled,
} from "@/providers/SettingsProvider";
import { SettingsContext } from "@/providers/SettingsProvider";
import { useProjectsContext } from "@/providers/ProjectsContext";
import { FileCard } from "@/sections/cards/FileCard";
import {
@@ -42,6 +40,9 @@ import {
} from "@/app/app/services/actionUtils";
import {
SvgArrowUp,
SvgCalendar,
SvgFiles,
SvgFileText,
SvgGlobe,
SvgHourglass,
SvgPlus,
@@ -50,22 +51,64 @@ import {
SvgStop,
SvgX,
} from "@opal/icons";
import { Button } from "@opal/components";
import { Button, OpenButton } from "@opal/components";
import Popover from "@/refresh-components/Popover";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { useQueryController } from "@/providers/QueryControllerProvider";
import { Section } from "@/layouts/general-layouts";
import Spacer from "@/refresh-components/Spacer";
const LINE_HEIGHT = 24;
const MIN_INPUT_HEIGHT = 44;
const MAX_INPUT_HEIGHT = 200;
export interface SourceChipProps {
icon?: React.ReactNode;
title: string;
onRemove?: () => void;
onClick?: () => void;
truncateTitle?: boolean;
}
export function SourceChip({
icon,
title,
onRemove,
onClick,
truncateTitle = true,
}: SourceChipProps) {
return (
<div
onClick={onClick ? onClick : undefined}
className={cn(
"flex-none flex items-center px-1 bg-background-neutral-01 text-xs text-text-04 border border-border-01 rounded-08 box-border gap-x-1 h-6",
onClick && "cursor-pointer"
)}
>
{icon}
{truncateTitle ? truncateString(title, 20) : title}
{onRemove && (
<SvgX
size={12}
className="text-text-01 ml-auto cursor-pointer"
onClick={(e: React.MouseEvent<SVGSVGElement>) => {
e.stopPropagation();
onRemove();
}}
/>
)}
</div>
);
}
export interface AppInputBarHandle {
reset: () => void;
focus: () => void;
}
export interface AppInputBarProps {
removeDocs: () => void;
selectedDocuments: OnyxDocument[];
initialMessage?: string;
stopGenerating: () => void;
onSubmit: (message: string) => void;
@@ -77,8 +120,10 @@ export interface AppInputBarProps {
// agents
selectedAgent: MinimalPersonaSnapshot | undefined;
toggleDocumentSidebar: () => void;
handleFileUpload: (files: File[]) => void;
filterManager: FilterManager;
retrievalEnabled: boolean;
deepResearchEnabled: boolean;
setPresentingDocument?: (document: MinimalOnyxDocument) => void;
toggleDeepResearch: () => void;
@@ -92,13 +137,18 @@ export interface AppInputBarProps {
const AppInputBar = React.memo(
({
retrievalEnabled,
removeDocs,
toggleDocumentSidebar,
filterManager,
selectedDocuments,
initialMessage = "",
stopGenerating,
onSubmit,
chatState,
currentSessionFileTokenCount,
availableContextTokens,
// agents
selectedAgent,
handleFileUpload,
@@ -115,9 +165,6 @@ const AppInputBar = React.memo(
// Internal message state - kept local to avoid parent re-renders on every keystroke
const [message, setMessage] = useState(initialMessage);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const textAreaWrapperRef = useRef<HTMLDivElement>(null);
const filesWrapperRef = useRef<HTMLDivElement>(null);
const filesContentRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { user } = useUser();
const { isClassifying, classification } = useQueryController();
@@ -131,16 +178,6 @@ const AppInputBar = React.memo(
textAreaRef.current?.focus();
},
}));
// Sync non-empty prop changes to internal state (e.g. NRFPage reads URL params
// after mount). Intentionally skips empty strings — clearing is handled via the
// imperative ref.reset() method, not by passing initialMessage="".
useEffect(() => {
if (initialMessage) {
setMessage(initialMessage);
}
}, [initialMessage]);
const { appMode } = useAppMode();
const appFocus = useAppFocus();
const isSearchMode =
@@ -190,39 +227,46 @@ const AppInputBar = React.memo(
const combinedSettings = useContext(SettingsContext);
// TODO(@raunakab): Replace this useEffect with CSS `field-sizing: content` once
// Firefox ships it unflagged (currently behind `layout.css.field-sizing.enabled`).
// Auto-resize textarea based on content (chat mode only).
// Reset to min-height first so scrollHeight reflects actual content size,
// then clamp between min and max. This handles both growing and shrinking.
useEffect(() => {
const wrapper = textAreaWrapperRef.current;
const textarea = textAreaRef.current;
if (!wrapper || !textarea) return;
// Track previous message to detect when lines might decrease
const prevMessageRef = useRef("");
wrapper.style.height = `${MIN_INPUT_HEIGHT}px`;
wrapper.style.height = `${Math.min(
Math.max(textarea.scrollHeight, MIN_INPUT_HEIGHT),
MAX_INPUT_HEIGHT
)}px`;
// Auto-resize textarea based on content
useEffect(() => {
if (isSearchMode) return;
const textarea = textAreaRef.current;
if (textarea) {
const prevLineCount = (prevMessageRef.current.match(/\n/g) || [])
.length;
const currLineCount = (message.match(/\n/g) || []).length;
const lineRemoved = currLineCount < prevLineCount;
prevMessageRef.current = message;
if (message.length === 0) {
textarea.style.height = `${MIN_INPUT_HEIGHT}px`;
return;
} else if (lineRemoved) {
const linesRemoved = prevLineCount - currLineCount;
textarea.style.height = `${Math.max(
MIN_INPUT_HEIGHT,
Math.min(
textarea.scrollHeight - LINE_HEIGHT * linesRemoved,
MAX_INPUT_HEIGHT
)
)}px`;
} else {
textarea.style.height = `${Math.min(
textarea.scrollHeight,
MAX_INPUT_HEIGHT
)}px`;
}
}
}, [message, isSearchMode]);
// Animate attached files wrapper to its content height so CSS transitions
// can interpolate between concrete pixel values (0px ↔ Npx).
const showFiles = !isSearchMode && currentMessageFiles.length > 0;
useEffect(() => {
const wrapper = filesWrapperRef.current;
const content = filesContentRef.current;
if (!wrapper || !content) return;
if (showFiles) {
// Measure the inner content's actual height, then add padding (p-1 = 8px total)
const PADDING = 8;
wrapper.style.height = `${content.offsetHeight + PADDING}px`;
} else {
wrapper.style.height = "0px";
if (initialMessage) {
setMessage(initialMessage);
}
}, [showFiles, currentMessageFiles]);
}, [initialMessage]);
function handlePaste(event: React.ClipboardEvent) {
const items = event.clipboardData?.items;
@@ -250,7 +294,8 @@ const AppInputBar = React.memo(
);
const { activePromptShortcuts } = usePromptShortcuts();
const vectorDbEnabled = useVectorDbEnabled();
const vectorDbEnabled =
combinedSettings?.settings.vector_db_enabled !== false;
const { ccPairs, isLoading: ccPairsLoading } = useCCPairs(vectorDbEnabled);
const { data: federatedConnectorsData, isLoading: federatedLoading } =
useFederatedConnectors();
@@ -367,9 +412,7 @@ const AppInputBar = React.memo(
combinedSettings?.settings?.deep_research_enabled,
]);
function handleKeyDownForPromptShortcuts(
e: React.KeyboardEvent<HTMLTextAreaElement>
) {
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (!user?.preferences?.shortcut_enabled || !showPrompts) return;
if (e.key === "Enter") {
@@ -404,171 +447,6 @@ const AppInputBar = React.memo(
}
}
const chatControls = (
<div
{...(isSearchMode ? { inert: true } : {})}
className={cn(
"flex justify-between items-center w-full",
isSearchMode
? "opacity-0 p-0 h-0 overflow-hidden pointer-events-none"
: "opacity-100 p-1 h-[2.75rem] pointer-events-auto",
"transition-all duration-150"
)}
>
{/* Bottom left controls */}
<div className="flex flex-row items-center">
{/* (+) button - always visible */}
<FilePickerPopover
onFileClick={handleFileClick}
onPickRecent={(file: ProjectFile) => {
// Check if file with same ID already exists
if (
!currentMessageFiles.some(
(existingFile) => existingFile.file_id === file.file_id
)
) {
setCurrentMessageFiles((prev) => [...prev, file]);
}
}}
onUnpickRecent={(file: ProjectFile) => {
setCurrentMessageFiles((prev) =>
prev.filter(
(existingFile) => existingFile.file_id !== file.file_id
)
);
}}
handleUploadChange={handleUploadChange}
trigger={(open) => (
<Button
icon={SvgPlusCircle}
tooltip="Attach Files"
transient={open}
disabled={disabled}
prominence="tertiary"
/>
)}
selectedFileIds={currentMessageFiles.map((f) => f.id)}
/>
{/* Controls that load in when data is ready */}
<div
data-testid="actions-container"
className={cn(
"flex flex-row items-center",
controlsLoading && "invisible"
)}
>
{selectedAgent && selectedAgent.tools.length > 0 && (
<ActionsPopover
selectedAgent={selectedAgent}
filterManager={filterManager}
availableSources={memoizedAvailableSources}
disabled={disabled}
/>
)}
{onToggleTabReading ? (
<Button
icon={SvgGlobe}
onClick={onToggleTabReading}
variant="select"
selected={tabReadingEnabled}
foldable={!tabReadingEnabled}
disabled={disabled}
>
{tabReadingEnabled
? currentTabUrl
? (() => {
try {
return new URL(currentTabUrl).hostname;
} catch {
return currentTabUrl;
}
})()
: "Reading tab..."
: "Read this tab"}
</Button>
) : (
showDeepResearch && (
<Button
icon={SvgHourglass}
onClick={toggleDeepResearch}
variant="select"
selected={deepResearchEnabled}
foldable={!deepResearchEnabled}
disabled={disabled}
>
Deep Research
</Button>
)
)}
{selectedAgent &&
forcedToolIds.length > 0 &&
forcedToolIds.map((toolId) => {
const tool = selectedAgent.tools.find(
(tool) => tool.id === toolId
);
if (!tool) {
return null;
}
return (
<Button
key={toolId}
icon={getIconForAction(tool)}
onClick={() => {
setForcedToolIds(
forcedToolIds.filter((id) => id !== toolId)
);
}}
variant="select"
selected
disabled={disabled}
>
{tool.display_name}
</Button>
);
})}
</div>
</div>
{/* Bottom right controls */}
<div className="flex flex-row items-center gap-1">
<div
data-testid="AppInputBar/llm-popover-trigger"
className={cn(controlsLoading && "invisible")}
>
<LLMPopover
llmManager={llmManager}
requiresImageInput={hasImageFiles}
disabled={disabled}
/>
</div>
<Button
id="onyx-chat-input-send-button"
icon={
isClassifying
? SimpleLoader
: chatState === "input"
? SvgArrowUp
: SvgStop
}
disabled={
(chatState === "input" && !message) ||
hasUploadingFiles ||
isClassifying
}
onClick={() => {
if (chatState == "streaming") {
stopGenerating();
} else if (message) {
onSubmit(message);
}
}}
/>
</div>
</div>
);
return (
<Disabled disabled={disabled} allowClick>
<div
@@ -589,17 +467,8 @@ const AppInputBar = React.memo(
)}
>
{/* Attached Files */}
<div
ref={filesWrapperRef}
{...(!showFiles ? { inert: true } : {})}
className={cn(
"transition-all duration-150",
showFiles
? "opacity-100 p-1"
: "opacity-0 p-0 overflow-hidden pointer-events-none"
)}
>
<div ref={filesContentRef} className="flex flex-wrap gap-1">
{currentMessageFiles.length > 0 && (
<div className="p-2 rounded-t-16 flex flex-wrap gap-1">
{currentMessageFiles.map((file) => (
<FileCard
key={file.id}
@@ -611,61 +480,76 @@ const AppInputBar = React.memo(
/>
))}
</div>
</div>
)}
<div className="flex flex-row items-center w-full">
{/* Input area */}
<div
className={cn(
"flex flex-row items-center w-full",
isSearchMode && "p-1"
)}
>
<Popover
open={user?.preferences?.shortcut_enabled && showPrompts}
onOpenChange={setShowPrompts}
>
<Popover.Anchor asChild>
<div
ref={textAreaWrapperRef}
className="px-3 py-2 flex-1 flex h-[2.75rem]"
>
<textarea
id="onyx-chat-input-textarea"
role="textarea"
ref={textAreaRef}
onPaste={handlePaste}
onKeyDownCapture={handleKeyDownForPromptShortcuts}
onChange={handleInputChange}
className={cn(
"p-[2px] w-full h-full outline-none bg-transparent resize-none placeholder:text-text-03 whitespace-pre-wrap break-words",
"overflow-y-auto"
)}
autoFocus
rows={1}
style={{ scrollbarWidth: "thin" }}
aria-multiline={true}
placeholder={
isSearchMode
? "Search connected sources"
: "How can I help you today?"
}
value={message}
onKeyDown={(event) => {
<textarea
onPaste={handlePaste}
onKeyDownCapture={handleKeyDown}
onChange={handleInputChange}
ref={textAreaRef}
id="onyx-chat-input-textarea"
className={cn(
"w-full",
"outline-none",
"bg-transparent",
"resize-none",
"placeholder:text-text-03",
"whitespace-pre-wrap",
"break-word",
"overscroll-contain",
"px-3",
isSearchMode
? "h-[40px] py-2.5 overflow-hidden"
: [
"h-[44px]", // Fixed initial height to prevent flash - useEffect will adjust as needed
"overflow-y-auto",
"pb-2",
"pt-3",
]
)}
autoFocus
style={{ scrollbarWidth: "thin" }}
role="textarea"
aria-multiline
placeholder={
isSearchMode
? "Search connected sources"
: "How can I help you today"
}
value={message}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!showPrompts &&
!event.shiftKey &&
!(event.nativeEvent as any).isComposing
) {
event.preventDefault();
if (
event.key === "Enter" &&
!showPrompts &&
!event.shiftKey &&
!(event.nativeEvent as any).isComposing
message &&
!disabled &&
!isClassifying &&
!hasUploadingFiles
) {
event.preventDefault();
if (
message &&
!disabled &&
!isClassifying &&
!hasUploadingFiles
) {
onSubmit(message);
}
onSubmit(message);
}
}}
suppressContentEditableWarning={true}
disabled={disabled}
/>
</div>
}
}}
suppressContentEditableWarning={true}
disabled={disabled}
/>
</Popover.Anchor>
<Popover.Content
@@ -732,7 +616,214 @@ const AppInputBar = React.memo(
)}
</div>
{chatControls}
{/* Source chips */}
{(selectedDocuments.length > 0 ||
filterManager.timeRange ||
filterManager.selectedDocumentSets.length > 0) && (
<div className="flex gap-x-.5 px-2">
<div className="flex gap-x-1 px-2 overflow-visible overflow-x-scroll items-end miniscroll">
{filterManager.timeRange && (
<SourceChip
truncateTitle={false}
key="time-range"
icon={<SvgCalendar size={12} />}
title={`${getFormattedDateRangeString(
filterManager.timeRange.from,
filterManager.timeRange.to
)}`}
onRemove={() => {
filterManager.setTimeRange(null);
}}
/>
)}
{filterManager.selectedDocumentSets.length > 0 &&
filterManager.selectedDocumentSets.map((docSet, index) => (
<SourceChip
key={`doc-set-${index}`}
icon={<SvgFiles size={16} />}
title={docSet}
onRemove={() => {
filterManager.setSelectedDocumentSets(
filterManager.selectedDocumentSets.filter(
(ds) => ds !== docSet
)
);
}}
/>
))}
{selectedDocuments.length > 0 && (
<SourceChip
key="selected-documents"
onClick={() => {
toggleDocumentSidebar();
}}
icon={<SvgFileText size={16} />}
title={`${selectedDocuments.length} selected`}
onRemove={removeDocs}
/>
)}
</div>
</div>
)}
{!isSearchMode && (
<div className="flex justify-between items-center w-full p-1 min-h-[40px]">
{/* Bottom left controls */}
<div className="flex flex-row items-center">
{/* (+) button - always visible */}
<FilePickerPopover
onFileClick={handleFileClick}
onPickRecent={(file: ProjectFile) => {
// Check if file with same ID already exists
if (
!currentMessageFiles.some(
(existingFile) => existingFile.file_id === file.file_id
)
) {
setCurrentMessageFiles((prev) => [...prev, file]);
}
}}
onUnpickRecent={(file: ProjectFile) => {
setCurrentMessageFiles((prev) =>
prev.filter(
(existingFile) => existingFile.file_id !== file.file_id
)
);
}}
handleUploadChange={handleUploadChange}
trigger={(open) => (
<Button
icon={SvgPlusCircle}
tooltip="Attach Files"
transient={open}
disabled={disabled}
prominence="tertiary"
/>
)}
selectedFileIds={currentMessageFiles.map((f) => f.id)}
/>
{/* Controls that load in when data is ready */}
<div
data-testid="actions-container"
className={cn(
"flex flex-row items-center",
controlsLoading && "invisible"
)}
>
{selectedAgent && selectedAgent.tools.length > 0 && (
<ActionsPopover
selectedAgent={selectedAgent}
filterManager={filterManager}
availableSources={memoizedAvailableSources}
disabled={disabled}
/>
)}
{onToggleTabReading ? (
<Button
icon={SvgGlobe}
onClick={onToggleTabReading}
variant="select"
selected={tabReadingEnabled}
foldable={!tabReadingEnabled}
disabled={disabled}
>
{tabReadingEnabled
? currentTabUrl
? (() => {
try {
return new URL(currentTabUrl).hostname;
} catch {
return currentTabUrl;
}
})()
: "Reading tab..."
: "Read this tab"}
</Button>
) : (
showDeepResearch && (
<Button
icon={SvgHourglass}
onClick={toggleDeepResearch}
variant="select"
selected={deepResearchEnabled}
foldable={!deepResearchEnabled}
disabled={disabled}
>
Deep Research
</Button>
)
)}
{selectedAgent &&
forcedToolIds.length > 0 &&
forcedToolIds.map((toolId) => {
const tool = selectedAgent.tools.find(
(tool) => tool.id === toolId
);
if (!tool) {
return null;
}
return (
<Button
key={toolId}
icon={getIconForAction(tool)}
onClick={() => {
setForcedToolIds(
forcedToolIds.filter((id) => id !== toolId)
);
}}
variant="select"
selected
disabled={disabled}
>
{tool.display_name}
</Button>
);
})}
</div>
</div>
{/* Bottom right controls */}
<div className="flex flex-row items-center gap-1">
{/* LLM popover - loads when ready */}
<div
data-testid="AppInputBar/llm-popover-trigger"
className={cn(controlsLoading && "invisible")}
>
<LLMPopover
llmManager={llmManager}
requiresImageInput={hasImageFiles}
disabled={disabled}
/>
</div>
{/* Submit button */}
<Button
id="onyx-chat-input-send-button"
icon={
isClassifying
? SimpleLoader
: chatState === "input"
? SvgArrowUp
: SvgStop
}
disabled={
(chatState === "input" && !message) ||
hasUploadingFiles ||
isClassifying
}
onClick={() => {
if (chatState == "streaming") {
stopGenerating();
} else if (message) {
onSubmit(message);
}
}}
/>
</div>
</div>
)}
</div>
</Disabled>
);

View File

@@ -116,6 +116,8 @@ function ViewerOpenApiToolCard({ tool }: { tool: ToolSnapshot }) {
);
}
const EMPTY_DOCS: [] = [];
/**
* Floating ChatInputBar below the AgentViewerModal.
* On submit, navigates to the agent's chat with the message pre-filled.
@@ -135,10 +137,14 @@ function AgentChatInput({ agent, onSubmit }: AgentChatInputProps) {
chatState="input"
filterManager={filterManager}
selectedAgent={agent}
selectedDocuments={EMPTY_DOCS}
removeDocs={() => {}}
stopGenerating={() => {}}
handleFileUpload={() => {}}
toggleDocumentSidebar={() => {}}
currentSessionFileTokenCount={0}
availableContextTokens={Infinity}
retrievalEnabled={false}
deepResearchEnabled={false}
toggleDeepResearch={() => {}}
disabled={false}

View File

@@ -1,20 +0,0 @@
import { test, expect } from "@tests/e2e/fixtures/eeFeatures";
test.describe("EE Feature Redirect", () => {
test("redirects to /chat with toast when EE features are not licensed", async ({
page,
eeEnabled,
}) => {
test.skip(eeEnabled, "Redirect only happens without Enterprise license");
await page.goto("/admin/theme");
await expect(page).toHaveURL(/\/chat/, { timeout: 10_000 });
const toastContainer = page.getByTestId("toast-container");
await expect(toastContainer).toBeVisible({ timeout: 5_000 });
await expect(
toastContainer.getByText(/only accessible with a paid license/i)
).toBeVisible();
});
});

View File

@@ -1,4 +1,4 @@
import { test, expect } from "@tests/e2e/fixtures/eeFeatures";
import { test, expect } from "@playwright/test";
import { loginAs } from "@tests/e2e/utils/auth";
test.describe("Appearance Theme Settings @exclusive", () => {
@@ -12,21 +12,24 @@ test.describe("Appearance Theme Settings @exclusive", () => {
consentPrompt: "I agree to the terms",
};
test.beforeEach(async ({ page, eeEnabled }) => {
test.skip(
!eeEnabled,
"Enterprise license not active — skipping theme tests"
);
// Fresh session — the eeEnabled fixture already logged in to check the
// setting, so clear cookies and re-login for a clean test state.
test.beforeEach(async ({ page }) => {
await page.context().clearCookies();
await loginAs(page, "admin");
// Navigate first so localStorage is accessible (API-based login
// doesn't navigate, leaving the page on about:blank).
await page.goto("/admin/theme");
await expect(
page.locator('[data-label="application-name-input"]')
).toBeVisible({ timeout: 10_000 });
await page.waitForLoadState("networkidle");
// Skip the entire test when Enterprise features are not licensed.
// The /admin/theme page is gated behind ee_features_enabled and
// renders a license-required message instead of the settings form.
const eeLocked = page.getByText(
"This functionality requires an active Enterprise license."
);
if (await eeLocked.isVisible({ timeout: 1000 }).catch(() => false)) {
test.skip(true, "Enterprise license not active — skipping theme tests");
}
// Clear localStorage to ensure consent modal shows
await page.evaluate(() => {

View File

@@ -1,43 +0,0 @@
/**
* Playwright fixture that detects EE (Enterprise Edition) license state.
*
* Usage:
* ```ts
* import { test, expect } from "@tests/e2e/fixtures/eeFeatures";
*
* test("my EE-gated test", async ({ page, eeEnabled }) => {
* test.skip(!eeEnabled, "Requires active Enterprise license");
* // ... rest of test
* });
* ```
*
* The fixture:
* - Authenticates as admin
* - Fetches /api/settings to check ee_features_enabled
* - Provides a boolean to the test BEFORE any navigation happens
*
* This lets tests call test.skip() synchronously at the top, which is the
* correct Playwright pattern — never navigate then decide to skip.
*/
import { test as base, expect } from "@playwright/test";
import { loginAs } from "@tests/e2e/utils/auth";
export const test = base.extend<{
/** Whether EE features are enabled (valid enterprise license). */
eeEnabled: boolean;
}>({
eeEnabled: async ({ page }, use) => {
await loginAs(page, "admin");
const res = await page.request.get("/api/settings");
if (!res.ok()) {
// Fail open — if we can't determine, assume EE is not enabled
await use(false);
return;
}
const settings = await res.json();
await use(settings.ee_features_enabled === true);
},
});
export { expect };