Compare commits

..

13 Commits

Author SHA1 Message Date
Bo-Onyx
3e25853eb8 chore(hook): Add feature control 2026-03-12 19:28:48 -07:00
Jamison Lahman
a4a664fa2c chore(fe): polish file previews more (#9259)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-12 22:37:00 +00:00
dependabot[bot]
8a6e349741 chore(deps): bump orjson from 3.11.4 to 3.11.6 (#9315)
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-03-12 22:17:56 +00:00
Nikolas Garza
11f8408558 feat(admin): add inline role editing in Users table - 7/9 (#9184) 2026-03-12 14:33:57 -07:00
Jessica Singh
24de76ad28 chore(auth): deployment helm cleanup (#8588)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-12 21:06:13 +00:00
Jamison Lahman
e264356eb5 feat(chat): support attaching more file types to chats (#9299) 2026-03-12 20:57:54 +00:00
Nikolas Garza
c5c08c5da6 feat(admin): add invite users modal - 6/9 (#9181) 2026-03-12 19:40:07 +00:00
Evan Lohn
78a9b386c7 chore: sharepoint error logs (#9309) 2026-03-12 19:07:17 +00:00
Jamison Lahman
dbcbfc1629 fix(favicon): prefer relative path to favicon (#9307) 2026-03-12 18:43:43 +00:00
Wenxi
fabbb00c49 refactor: sync craft latest builds with latest stable (#9279) 2026-03-12 18:27:25 +00:00
Nikolas Garza
809dab5746 feat(admin): add row actions with confirmation modals - 5/9 (#9180) 2026-03-12 17:46:12 +00:00
Wenxi
1649bed548 refactor: use ods latest-stable-tag to tag images in Docker Hub (#9281)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-12 17:06:58 +00:00
Jamison Lahman
dd07b3cf27 fix(fe): prevent clicking InputSelect from selecting text (#9292) 2026-03-12 09:32:06 -07:00
57 changed files with 1472 additions and 740 deletions

View File

@@ -29,20 +29,32 @@ jobs:
build-backend-craft: ${{ steps.check.outputs.build-backend-craft }}
build-model-server: ${{ steps.check.outputs.build-model-server }}
is-cloud-tag: ${{ steps.check.outputs.is-cloud-tag }}
is-stable: ${{ steps.check.outputs.is-stable }}
is-beta: ${{ steps.check.outputs.is-beta }}
is-stable-standalone: ${{ steps.check.outputs.is-stable-standalone }}
is-beta-standalone: ${{ steps.check.outputs.is-beta-standalone }}
is-craft-latest: ${{ steps.check.outputs.is-craft-latest }}
is-latest: ${{ steps.check.outputs.is-latest }}
is-test-run: ${{ steps.check.outputs.is-test-run }}
sanitized-tag: ${{ steps.check.outputs.sanitized-tag }}
short-sha: ${{ steps.check.outputs.short-sha }}
steps:
- name: Checkout (for git tags)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
with:
persist-credentials: false
fetch-depth: 0
fetch-tags: true
- name: Setup uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
with:
version: "0.9.9"
enable-cache: false
- name: Check which components to build and version info
id: check
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set -eo pipefail
TAG="${GITHUB_REF_NAME}"
# Sanitize tag name by replacing slashes with hyphens (for Docker tag compatibility)
SANITIZED_TAG=$(echo "$TAG" | tr '/' '-')
@@ -54,9 +66,8 @@ jobs:
IS_VERSION_TAG=false
IS_STABLE=false
IS_BETA=false
IS_STABLE_STANDALONE=false
IS_BETA_STANDALONE=false
IS_CRAFT_LATEST=false
IS_LATEST=false
IS_PROD_TAG=false
IS_TEST_RUN=false
BUILD_DESKTOP=false
@@ -67,9 +78,6 @@ jobs:
BUILD_MODEL_SERVER=true
# Determine tag type based on pattern matching (do regex checks once)
if [[ "$TAG" == craft-* ]]; then
IS_CRAFT_LATEST=true
fi
if [[ "$TAG" == *cloud* ]]; then
IS_CLOUD=true
fi
@@ -97,20 +105,28 @@ jobs:
fi
fi
# Craft-latest builds backend with Craft enabled
if [[ "$IS_CRAFT_LATEST" == "true" ]]; then
BUILD_BACKEND_CRAFT=true
BUILD_BACKEND=false
fi
# Standalone version checks (for backend/model-server - version excluding cloud tags)
if [[ "$IS_STABLE" == "true" ]] && [[ "$IS_CLOUD" != "true" ]]; then
IS_STABLE_STANDALONE=true
fi
if [[ "$IS_BETA" == "true" ]] && [[ "$IS_CLOUD" != "true" ]]; then
IS_BETA_STANDALONE=true
fi
# Determine if this tag should get the "latest" Docker tag.
# Only the highest semver stable tag (vX.Y.Z exactly) gets "latest".
if [[ "$IS_STABLE" == "true" ]]; then
HIGHEST_STABLE=$(uv run --no-sync --with onyx-devtools ods latest-stable-tag) || {
echo "::error::Failed to determine highest stable tag via 'ods latest-stable-tag'"
exit 1
}
if [[ "$TAG" == "$HIGHEST_STABLE" ]]; then
IS_LATEST=true
fi
fi
# Build craft-latest backend alongside the regular latest.
if [[ "$IS_LATEST" == "true" ]]; then
BUILD_BACKEND_CRAFT=true
fi
# Determine if this is a production tag
# Production tags are: version tags (v1.2.3*) or nightly tags
if [[ "$IS_VERSION_TAG" == "true" ]] || [[ "$IS_NIGHTLY" == "true" ]]; then
@@ -129,11 +145,9 @@ jobs:
echo "build-backend-craft=$BUILD_BACKEND_CRAFT"
echo "build-model-server=$BUILD_MODEL_SERVER"
echo "is-cloud-tag=$IS_CLOUD"
echo "is-stable=$IS_STABLE"
echo "is-beta=$IS_BETA"
echo "is-stable-standalone=$IS_STABLE_STANDALONE"
echo "is-beta-standalone=$IS_BETA_STANDALONE"
echo "is-craft-latest=$IS_CRAFT_LATEST"
echo "is-latest=$IS_LATEST"
echo "is-test-run=$IS_TEST_RUN"
echo "sanitized-tag=$SANITIZED_TAG"
echo "short-sha=$SHORT_SHA"
@@ -600,7 +614,7 @@ jobs:
latest=false
tags: |
type=raw,value=${{ needs.determine-builds.outputs.is-test-run == 'true' && format('web-{0}', needs.determine-builds.outputs.sanitized-tag) || github.ref_name }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-stable == 'true' && 'latest' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-latest == 'true' && 'latest' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && env.EDGE_TAG == 'true' && 'edge' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-beta == 'true' && 'beta' || '' }}
@@ -1037,7 +1051,7 @@ jobs:
latest=false
tags: |
type=raw,value=${{ needs.determine-builds.outputs.is-test-run == 'true' && format('backend-{0}', needs.determine-builds.outputs.sanitized-tag) || github.ref_name }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-stable-standalone == 'true' && 'latest' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-latest == 'true' && 'latest' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && env.EDGE_TAG == 'true' && 'edge' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-beta-standalone == 'true' && 'beta' || '' }}
@@ -1473,7 +1487,7 @@ jobs:
latest=false
tags: |
type=raw,value=${{ needs.determine-builds.outputs.is-test-run == 'true' && format('model-server-{0}', needs.determine-builds.outputs.sanitized-tag) || github.ref_name }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-stable-standalone == 'true' && 'latest' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-latest == 'true' && 'latest' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && env.EDGE_TAG == 'true' && 'edge' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-beta-standalone == 'true' && 'beta' || '' }}

View File

@@ -133,7 +133,7 @@ jobs:
echo "=== Validating chart dependencies ==="
cd deployment/helm/charts/onyx
helm dependency update
helm lint .
helm lint . --set auth.userauth.values.user_auth_secret=placeholder
- name: Run chart-testing (install) with enhanced monitoring
timeout-minutes: 25
@@ -194,6 +194,7 @@ jobs:
--set=vespa.enabled=false \
--set=opensearch.enabled=true \
--set=auth.opensearch.enabled=true \
--set=auth.userauth.values.user_auth_secret=test-secret \
--set=slackbot.enabled=false \
--set=postgresql.enabled=true \
--set=postgresql.cluster.storage.storageClass=standard \
@@ -230,6 +231,10 @@ jobs:
if: steps.list-changed.outputs.changed == 'true'
run: |
echo "=== Post-install verification ==="
if ! kubectl cluster-info >/dev/null 2>&1; then
echo "ERROR: Kubernetes cluster is not reachable after install"
exit 1
fi
kubectl get pods --all-namespaces
kubectl get services --all-namespaces
# Only show issues if they exist
@@ -239,6 +244,10 @@ jobs:
if: failure() && steps.list-changed.outputs.changed == 'true'
run: |
echo "=== Cleanup on failure ==="
if ! kubectl cluster-info >/dev/null 2>&1; then
echo "Skipping failure cleanup: Kubernetes cluster is not reachable"
exit 0
fi
echo "=== Final cluster state ==="
kubectl get pods --all-namespaces
kubectl get events --all-namespaces --sort-by=.lastTimestamp | tail -10

View File

@@ -1042,6 +1042,8 @@ POD_NAMESPACE = os.environ.get("POD_NAMESPACE")
DEV_MODE = os.environ.get("DEV_MODE", "").lower() == "true"
HOOK_ENABLED = os.environ.get("HOOK_ENABLED", "").lower() == "true"
INTEGRATION_TESTS_MODE = os.environ.get("INTEGRATION_TESTS_MODE", "").lower() == "true"
#####

View File

@@ -272,6 +272,15 @@ class SizeCapExceeded(Exception):
"""Exception raised when the size cap is exceeded."""
def _log_and_raise_for_status(response: requests.Response) -> None:
"""Log the response text and raise for status."""
try:
response.raise_for_status()
except Exception:
logger.error(f"Graph API request failed: {response.text}")
raise
def load_certificate_from_pfx(pfx_data: bytes, password: str) -> CertificateData | None:
"""Load certificate from .pfx file for MSAL authentication"""
try:
@@ -348,7 +357,7 @@ def _probe_remote_size(url: str, timeout: int) -> int | None:
"""Determine remote size using HEAD or a range GET probe. Returns None if unknown."""
try:
head_resp = requests.head(url, timeout=timeout, allow_redirects=True)
head_resp.raise_for_status()
_log_and_raise_for_status(head_resp)
cl = head_resp.headers.get("Content-Length")
if cl and cl.isdigit():
return int(cl)
@@ -363,7 +372,7 @@ def _probe_remote_size(url: str, timeout: int) -> int | None:
timeout=timeout,
stream=True,
) as range_resp:
range_resp.raise_for_status()
_log_and_raise_for_status(range_resp)
cr = range_resp.headers.get("Content-Range") # e.g., "bytes 0-0/12345"
if cr and "/" in cr:
total = cr.split("/")[-1]
@@ -388,7 +397,7 @@ def _download_with_cap(url: str, timeout: int, cap: int) -> bytes:
- Returns the full bytes if the content fits within `cap`.
"""
with requests.get(url, stream=True, timeout=timeout) as resp:
resp.raise_for_status()
_log_and_raise_for_status(resp)
# If the server provides Content-Length, prefer an early decision.
cl_header = resp.headers.get("Content-Length")
@@ -432,7 +441,7 @@ def _download_via_graph_api(
with requests.get(
url, headers=headers, stream=True, timeout=REQUEST_TIMEOUT_SECONDS
) as resp:
resp.raise_for_status()
_log_and_raise_for_status(resp)
buf = io.BytesIO()
for chunk in resp.iter_content(64 * 1024):
if not chunk:
@@ -1313,7 +1322,7 @@ class SharepointConnector(
access_token = self._get_graph_access_token()
headers = {"Authorization": f"Bearer {access_token}"}
continue
response.raise_for_status()
_log_and_raise_for_status(response)
return response.json()
except (requests.ConnectionError, requests.Timeout):
if attempt < GRAPH_API_MAX_RETRIES:

View File

View File

@@ -0,0 +1,26 @@
from onyx.configs.app_configs import HOOK_ENABLED
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from shared_configs.configs import MULTI_TENANT
def require_hook_enabled() -> None:
"""FastAPI dependency that gates all hook management endpoints.
Hooks are only available in single-tenant / self-hosted deployments with
HOOK_ENABLED=true explicitly set. Two layers of protection:
1. MULTI_TENANT check — rejects even if HOOK_ENABLED is accidentally set true
2. HOOK_ENABLED flag — explicit opt-in by the operator
Use as: Depends(require_hook_enabled)
"""
if MULTI_TENANT:
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"Custom code hooks are not available in multi-tenant deployments",
)
if not HOOK_ENABLED:
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"Custom code hooks are not enabled. Set HOOK_ENABLED=true to enable.",
)

View File

@@ -1,6 +1,3 @@
import logging
import re
from dataclasses import dataclass
from datetime import datetime
from typing import cast
@@ -49,8 +46,6 @@ from onyx.onyxbot.slack.utils import remove_slack_text_interactions
from onyx.onyxbot.slack.utils import translate_vespa_highlight_to_slack
from onyx.utils.text_processing import decode_escapes
logger = logging.getLogger(__name__)
_MAX_BLURB_LEN = 45
@@ -81,99 +76,6 @@ def get_feedback_reminder_blocks(thread_link: str, include_followup: bool) -> Bl
return SectionBlock(text=text)
@dataclass
class CodeSnippet:
"""A code block extracted from the answer to be uploaded as a Slack file."""
code: str
language: str
filename: str
_SECTION_BLOCK_LIMIT = 3000
# Matches fenced code blocks: ```lang\n...\n```
# The opening fence must start at the beginning of a line (^), may have an
# optional language specifier (\w*), followed by a newline. The closing fence
# is ``` at the beginning of a line. We also handle an optional trailing
# newline on the closing fence line to be robust against different formatting.
_CODE_FENCE_RE = re.compile(
r"^```(\w*)\n(.*?)^```\s*$",
re.MULTILINE | re.DOTALL,
)
def _extract_code_snippets(
text: str, limit: int = _SECTION_BLOCK_LIMIT
) -> tuple[str, list[CodeSnippet]]:
"""Extract code blocks that would push the text over *limit*.
Returns (cleaned_text, snippets) where *cleaned_text* has large code
blocks replaced with a placeholder and *snippets* contains the extracted
code to be uploaded as Slack file snippets.
Uses a two-pass approach: first collect all matches, then decide which
to extract based on cumulative removal so each decision accounts for
previously extracted blocks.
Pass *limit=0* to force-extract ALL code blocks unconditionally.
"""
if limit > 0 and len(text) <= limit:
return text, []
# Pass 1: collect all code-fence matches
matches = list(_CODE_FENCE_RE.finditer(text))
if not matches:
return text, []
# Pass 2: decide which blocks to extract, accounting for cumulative removal.
# Only extract if the text is still over the limit OR the block is very large.
# With limit=0, extract everything unconditionally.
extract_indices: set[int] = set()
removed_chars = 0
for i, match in enumerate(matches):
full_block = match.group(0)
if limit == 0:
extract_indices.add(i)
removed_chars += len(full_block)
else:
current_len = len(text) - removed_chars
if current_len > limit and current_len - len(full_block) <= limit:
extract_indices.add(i)
removed_chars += len(full_block)
elif len(full_block) > limit // 2:
extract_indices.add(i)
removed_chars += len(full_block)
if not extract_indices:
return text, []
# Build cleaned text and snippets by processing matches in reverse
# so character offsets remain valid.
snippets: list[CodeSnippet] = []
cleaned = text
for i in sorted(extract_indices, reverse=True):
match = matches[i]
lang = match.group(1) or ""
code = match.group(2)
ext = lang if lang else "txt"
snippets.append(
CodeSnippet(
code=code.strip(),
language=lang or "text",
filename=f"code_{len(extract_indices) - len(snippets)}.{ext}",
)
)
cleaned = cleaned[: match.start()] + cleaned[match.end() :]
# Snippets were appended in reverse order — flip to match document order
snippets.reverse()
# Clean up any triple+ blank lines left by extraction
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
return cleaned, snippets
def _split_text(text: str, limit: int = 3000) -> list[str]:
if len(text) <= limit:
return [text]
@@ -515,7 +417,7 @@ def _build_citations_blocks(
def _build_main_response_blocks(
answer: ChatBasicResponse,
) -> tuple[list[Block], list[CodeSnippet]]:
) -> list[Block]:
# TODO: add back in later when auto-filtering is implemented
# if (
# retrieval_info.applied_time_cutoff
@@ -546,45 +448,9 @@ def _build_main_response_blocks(
# replaces markdown links with slack format links
formatted_answer = format_slack_message(answer.answer)
answer_processed = decode_escapes(remove_slack_text_interactions(formatted_answer))
answer_blocks = [SectionBlock(text=text) for text in _split_text(answer_processed)]
# Extract large code blocks as snippets to upload separately,
# avoiding broken code fences when splitting across SectionBlocks.
cleaned_text, code_snippets = _extract_code_snippets(answer_processed)
logger.info(
"Code extraction: input=%d chars, cleaned=%d chars, snippets=%d",
len(answer_processed),
len(cleaned_text),
len(code_snippets),
)
if len(cleaned_text) <= _SECTION_BLOCK_LIMIT:
answer_blocks = [SectionBlock(text=cleaned_text)]
elif "```" not in cleaned_text:
# No code fences — safe to split at word boundaries.
answer_blocks = [
SectionBlock(text=text)
for text in _split_text(cleaned_text, limit=_SECTION_BLOCK_LIMIT)
]
else:
# Text still has code fences after extraction and exceeds the
# SectionBlock limit. Splitting would break the fences, so fall
# back to uploading the entire remaining code as another snippet
# and keeping only the prose in the blocks.
logger.warning(
"Cleaned text still has code fences (%d chars); "
"force-extracting remaining code blocks",
len(cleaned_text),
)
remaining_cleaned, remaining_snippets = _extract_code_snippets(
cleaned_text, limit=0
)
code_snippets.extend(remaining_snippets)
answer_blocks = [
SectionBlock(text=text)
for text in _split_text(remaining_cleaned, limit=_SECTION_BLOCK_LIMIT)
]
return cast(list[Block], answer_blocks), code_snippets
return cast(list[Block], answer_blocks)
def _build_continue_in_web_ui_block(
@@ -665,13 +531,10 @@ def build_slack_response_blocks(
skip_ai_feedback: bool = False,
offer_ephemeral_publication: bool = False,
skip_restated_question: bool = False,
) -> tuple[list[Block], list[CodeSnippet]]:
) -> list[Block]:
"""
This function is a top level function that builds all the blocks for the Slack response.
It also handles combining all the blocks together.
Returns (blocks, code_snippets) where code_snippets should be uploaded
as Slack file snippets in the same thread.
"""
# If called with the OnyxBot slash command, the question is lost so we have to reshow it
if not skip_restated_question:
@@ -681,7 +544,7 @@ def build_slack_response_blocks(
else:
restate_question_block = []
answer_blocks, code_snippets = _build_main_response_blocks(answer)
answer_blocks = _build_main_response_blocks(answer)
web_follow_up_block = []
if channel_conf and channel_conf.get("show_continue_in_web_ui"):
@@ -747,4 +610,4 @@ def build_slack_response_blocks(
+ follow_up_block
)
return all_blocks, code_snippets
return all_blocks

View File

@@ -282,7 +282,7 @@ def handle_publish_ephemeral_message_button(
logger.error(f"Failed to send webhook: {e}")
# remove handling of empheremal block and add AI feedback.
all_blocks, _ = build_slack_response_blocks(
all_blocks = build_slack_response_blocks(
answer=onyx_bot_answer,
message_info=slack_message_info,
channel_conf=channel_conf,
@@ -311,7 +311,7 @@ def handle_publish_ephemeral_message_button(
elif action_id == KEEP_TO_YOURSELF_ACTION_ID:
# Keep as ephemeral message in channel or thread, but remove the publish button and add feedback button
changed_blocks, _ = build_slack_response_blocks(
changed_blocks = build_slack_response_blocks(
answer=onyx_bot_answer,
message_info=slack_message_info,
channel_conf=channel_conf,

View File

@@ -25,7 +25,6 @@ from onyx.db.models import User
from onyx.db.persona import get_persona_by_id
from onyx.db.users import get_user_by_email
from onyx.onyxbot.slack.blocks import build_slack_response_blocks
from onyx.onyxbot.slack.blocks import CodeSnippet
from onyx.onyxbot.slack.constants import SLACK_CHANNEL_REF_PATTERN
from onyx.onyxbot.slack.handlers.utils import send_team_member_message
from onyx.onyxbot.slack.models import SlackMessageInfo
@@ -135,65 +134,6 @@ def build_slack_context_str(
return slack_context_str + "\n\n".join(message_strs)
# Normalize common LLM language aliases to Slack's expected snippet_type values.
# Slack silently falls back to plain text for unrecognized types, so this map
# only needs to cover the most common mismatches.
_SNIPPET_TYPE_MAP: dict[str, str] = {
"py": "python",
"js": "javascript",
"ts": "typescript",
"tsx": "typescript",
"jsx": "javascript",
"sh": "shell",
"bash": "shell",
"zsh": "shell",
"yml": "yaml",
"rb": "ruby",
"rs": "rust",
"cs": "csharp",
"md": "markdown",
"txt": "text",
"text": "plain_text",
}
def _upload_code_snippets(
client: WebClient,
channel: str,
thread_ts: str,
snippets: list[CodeSnippet],
logger: OnyxLoggingAdapter,
receiver_ids: list[str] | None = None,
send_as_ephemeral: bool | None = None,
) -> None:
"""Upload extracted code blocks as Slack file snippets in the thread."""
for snippet in snippets:
try:
snippet_type = _SNIPPET_TYPE_MAP.get(snippet.language, snippet.language)
client.files_upload_v2(
channel=channel,
thread_ts=thread_ts,
content=snippet.code,
filename=snippet.filename,
snippet_type=snippet_type,
)
except Exception:
logger.warning(
f"Failed to upload code snippet {snippet.filename}, "
"falling back to inline code block"
)
# Fall back to posting as a regular message with code fences,
# preserving the same visibility as the primary response.
respond_in_thread_or_channel(
client=client,
channel=channel,
receiver_ids=receiver_ids,
text=f"```{snippet.language}\n{snippet.code}\n```",
thread_ts=thread_ts,
send_as_ephemeral=send_as_ephemeral,
)
def handle_regular_answer(
message_info: SlackMessageInfo,
slack_channel_config: SlackChannelConfig,
@@ -447,7 +387,7 @@ def handle_regular_answer(
offer_ephemeral_publication = False
skip_ai_feedback = False
all_blocks, code_snippets = build_slack_response_blocks(
all_blocks = build_slack_response_blocks(
message_info=message_info,
answer=answer,
channel_conf=channel_conf,
@@ -474,20 +414,6 @@ def handle_regular_answer(
send_as_ephemeral=send_as_ephemeral,
)
# Upload extracted code blocks as Slack file snippets so they
# render as collapsible, syntax-highlighted blocks in the thread.
snippet_thread_ts = target_thread_ts or message_ts_to_respond_to
if code_snippets and snippet_thread_ts:
_upload_code_snippets(
client=client,
channel=channel,
thread_ts=snippet_thread_ts,
snippets=code_snippets,
logger=logger,
receiver_ids=target_receiver_ids,
send_as_ephemeral=send_as_ephemeral,
)
# For DM (ephemeral message), we need to create a thread via a normal message so the user can see
# the ephemeral message. This also will give the user a notification which ephemeral message does not.
# if there is no message_ts_to_respond_to, and we have made it this far, then this is a /onyx message

View File

@@ -157,10 +157,13 @@ def categorize_uploaded_files(
"""
Categorize uploaded files based on text extractability and tokenized length.
- Extracts text using extract_file_text for supported plain/document extensions.
- Images are estimated for token cost via a patch-based heuristic.
- All other files are run through extract_file_text, which handles known
document formats (.pdf, .docx, …) and falls back to a text-detection
heuristic for unknown extensions (.py, .js, .rs, …).
- Uses default tokenizer to compute token length.
- If token length > 100,000, reject file (unless threshold skip is enabled).
- If extension unsupported or text cannot be extracted, reject file.
- If token length > threshold, reject file (unless threshold skip is enabled).
- If text cannot be extracted, reject file.
- Otherwise marked as acceptable.
"""
@@ -217,8 +220,7 @@ def categorize_uploaded_files(
)
results.rejected.append(
RejectedFile(
filename=filename,
reason=f"Unsupported file type: {extension}",
filename=filename, reason="Unsupported file contents"
)
)
continue
@@ -235,8 +237,10 @@ def categorize_uploaded_files(
results.acceptable_file_to_token_count[filename] = token_count
continue
# Otherwise, handle as text/document: extract text and count tokens
elif extension in OnyxFileExtensions.ALL_ALLOWED_EXTENSIONS:
# Handle as text/document: attempt text extraction and count tokens.
# This accepts any file that extract_file_text can handle, including
# code files (.py, .js, .rs, etc.) via its is_text_file() fallback.
else:
if is_file_password_protected(
file=upload.file,
file_name=filename,
@@ -259,7 +263,10 @@ def categorize_uploaded_files(
if not text_content:
logger.warning(f"No text content extracted from '{filename}'")
results.rejected.append(
RejectedFile(filename=filename, reason="Could not read file")
RejectedFile(
filename=filename,
reason=f"Unsupported file type: {extension}",
)
)
continue
@@ -282,17 +289,6 @@ def categorize_uploaded_files(
logger.warning(
f"Failed to reset file pointer for '{filename}': {str(e)}"
)
continue
# If not recognized as supported types above, mark unsupported
logger.warning(
f"Unsupported file extension '{extension}' for file '{filename}'"
)
results.rejected.append(
RejectedFile(
filename=filename, reason=f"Unsupported file type: {extension}"
)
)
except Exception as e:
logger.warning(
f"Failed to process uploaded file '{get_safe_filename(upload)}' (error_type={type(e).__name__}, error={str(e)})"

View File

@@ -614,7 +614,7 @@ opentelemetry-sdk==1.39.1
# opentelemetry-exporter-otlp-proto-http
opentelemetry-semantic-conventions==0.60b1
# via opentelemetry-sdk
orjson==3.11.4 ; platform_python_implementation != 'PyPy'
orjson==3.11.6 ; platform_python_implementation != 'PyPy'
# via langsmith
packaging==24.2
# via

View File

@@ -0,0 +1,40 @@
"""Unit tests for the hooks feature gate."""
from unittest.mock import patch
import pytest
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.hooks.api_dependencies import require_hook_enabled
class TestRequireHookEnabled:
def test_raises_when_multi_tenant(self) -> None:
with (
patch("onyx.hooks.api_dependencies.MULTI_TENANT", True),
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", True),
):
with pytest.raises(OnyxError) as exc_info:
require_hook_enabled()
assert exc_info.value.error_code is OnyxErrorCode.NOT_FOUND
assert exc_info.value.status_code == 404
assert "multi-tenant" in exc_info.value.detail
def test_raises_when_flag_disabled(self) -> None:
with (
patch("onyx.hooks.api_dependencies.MULTI_TENANT", False),
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", False),
):
with pytest.raises(OnyxError) as exc_info:
require_hook_enabled()
assert exc_info.value.error_code is OnyxErrorCode.NOT_FOUND
assert exc_info.value.status_code == 404
assert "HOOK_ENABLED" in exc_info.value.detail
def test_passes_when_enabled_single_tenant(self) -> None:
with (
patch("onyx.hooks.api_dependencies.MULTI_TENANT", False),
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", True),
):
require_hook_enabled() # must not raise

View File

@@ -7,9 +7,6 @@ import timeago # type: ignore
from onyx.configs.constants import DocumentSource
from onyx.context.search.models import SavedSearchDoc
from onyx.onyxbot.slack.blocks import _build_documents_blocks
from onyx.onyxbot.slack.blocks import _extract_code_snippets
from onyx.onyxbot.slack.blocks import _split_text
from onyx.onyxbot.slack.handlers.handle_regular_answer import _SNIPPET_TYPE_MAP
def _make_saved_doc(updated_at: datetime | None) -> SavedSearchDoc:
@@ -72,148 +69,3 @@ def test_build_documents_blocks_formats_naive_timestamp(
formatted_timestamp: datetime = captured["doc"]
expected_timestamp: datetime = naive_timestamp.replace(tzinfo=pytz.utc)
assert formatted_timestamp == expected_timestamp
# ---------------------------------------------------------------------------
# _split_text tests
# ---------------------------------------------------------------------------
class TestSplitText:
def test_short_text_returns_single_chunk(self) -> None:
result = _split_text("hello world", limit=100)
assert result == ["hello world"]
def test_splits_at_space_boundary(self) -> None:
text = "aaa bbb ccc ddd"
result = _split_text(text, limit=8)
assert len(result) >= 2
def test_no_code_fences_splits_normally(self) -> None:
text = "word " * 100 # 500 chars
result = _split_text(text, limit=100)
assert len(result) >= 5
for chunk in result:
assert "```" not in chunk
# ---------------------------------------------------------------------------
# _extract_code_snippets tests
# ---------------------------------------------------------------------------
class TestExtractCodeSnippets:
def test_short_text_no_extraction(self) -> None:
text = "short answer with ```python\nprint('hi')\n``` inline"
cleaned, snippets = _extract_code_snippets(text, limit=3000)
assert cleaned == text
assert snippets == []
def test_large_code_block_extracted(self) -> None:
code = "x = 1\n" * 200 # ~1200 chars of code
text = f"Here is the solution:\n```python\n{code}```\nHope that helps!"
cleaned, snippets = _extract_code_snippets(text, limit=200)
assert len(snippets) == 1
assert snippets[0].language == "python"
assert snippets[0].filename == "code_1.python"
assert "x = 1" in snippets[0].code
# Code block should be removed from cleaned text
assert "```" not in cleaned
assert "Here is the solution" in cleaned
assert "Hope that helps!" in cleaned
def test_multiple_code_blocks_only_large_ones_extracted(self) -> None:
small_code = "print('hi')"
large_code = "x = 1\n" * 300
text = (
f"First:\n```python\n{small_code}\n```\n"
f"Second:\n```javascript\n{large_code}\n```\n"
"Done!"
)
cleaned, snippets = _extract_code_snippets(text, limit=500)
# The large block should be extracted
assert len(snippets) >= 1
langs = [s.language for s in snippets]
assert "javascript" in langs
def test_language_specifier_captured(self) -> None:
code = "fn main() {}\n" * 100
text = f"```rust\n{code}```"
_, snippets = _extract_code_snippets(text, limit=100)
assert len(snippets) == 1
assert snippets[0].language == "rust"
assert snippets[0].filename == "code_1.rust"
def test_no_language_defaults_to_text(self) -> None:
code = "some output\n" * 100
text = f"```\n{code}```"
_, snippets = _extract_code_snippets(text, limit=100)
assert len(snippets) == 1
assert snippets[0].language == "text"
assert snippets[0].filename == "code_1.txt"
def test_cleaned_text_has_no_triple_blank_lines(self) -> None:
code = "x = 1\n" * 200
text = f"Before\n\n```python\n{code}```\n\nAfter"
cleaned, _ = _extract_code_snippets(text, limit=100)
assert "\n\n\n" not in cleaned
def test_multiple_blocks_cumulative_removal(self) -> None:
"""When multiple code blocks exist, extraction decisions should
account for previously extracted blocks (two-pass logic).
Blocks must be smaller than limit//2 so the 'very large block'
override doesn't trigger — we're testing the cumulative logic only."""
# Each fenced block is ~103 chars (```python\n + 15*6 + ```)
block_a = "a = 1\n" * 15 # 90 chars of code
block_b = "b = 2\n" * 15 # 90 chars of code
prose = "x" * 200
# Total: ~200 + 103 + 103 + overhead ≈ 420 chars
# limit=300, limit//2=150. Each block (~103) < 150, so only
# the cumulative check applies. Removing block_a (~103 chars)
# brings us to ~317 > 300, so block_b should also be extracted.
# But with limit=350: removing block_a → ~317 ≤ 350, stop.
text = f"{prose}\n```python\n{block_a}```\n```python\n{block_b}```\nEnd"
cleaned, snippets = _extract_code_snippets(text, limit=350)
# After extracting block_a the text is ≤ 350, so block_b stays.
assert len(snippets) == 1
assert snippets[0].filename == "code_1.python"
# block_b should still be in the cleaned text
assert "b = 2" in cleaned
# ---------------------------------------------------------------------------
# _SNIPPET_TYPE_MAP tests
# ---------------------------------------------------------------------------
class TestSnippetTypeMap:
@pytest.mark.parametrize(
"alias,expected",
[
("py", "python"),
("js", "javascript"),
("ts", "typescript"),
("tsx", "typescript"),
("jsx", "javascript"),
("sh", "shell"),
("bash", "shell"),
("yml", "yaml"),
("rb", "ruby"),
("rs", "rust"),
("cs", "csharp"),
("md", "markdown"),
("text", "plain_text"),
],
)
def test_common_aliases_normalized(self, alias: str, expected: str) -> None:
assert _SNIPPET_TYPE_MAP[alias] == expected
def test_unknown_language_passes_through(self) -> None:
unknown = "haskell"
assert _SNIPPET_TYPE_MAP.get(unknown, unknown) == "haskell"

View File

@@ -186,3 +186,42 @@ def test_categorize_uploaded_files_checks_size_before_text_extraction(
assert len(result.acceptable) == 0
assert len(result.rejected) == 1
assert result.rejected[0].reason == "Exceeds 1 MB file size limit"
def test_categorize_uploaded_files_accepts_python_file(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_common_dependencies(monkeypatch)
monkeypatch.setattr(utils, "USER_FILE_MAX_UPLOAD_SIZE_BYTES", 10_000)
monkeypatch.setattr(utils, "USER_FILE_MAX_UPLOAD_SIZE_MB", 1)
py_source = b'def hello():\n print("world")\n'
monkeypatch.setattr(
utils, "extract_file_text", lambda **_kwargs: py_source.decode()
)
upload = _make_upload("script.py", size=len(py_source), content=py_source)
result = utils.categorize_uploaded_files([upload], MagicMock())
assert len(result.acceptable) == 1
assert result.acceptable[0].filename == "script.py"
assert len(result.rejected) == 0
def test_categorize_uploaded_files_rejects_binary_file(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_common_dependencies(monkeypatch)
monkeypatch.setattr(utils, "USER_FILE_MAX_UPLOAD_SIZE_BYTES", 10_000)
monkeypatch.setattr(utils, "USER_FILE_MAX_UPLOAD_SIZE_MB", 1)
monkeypatch.setattr(utils, "extract_file_text", lambda **_kwargs: "")
binary_content = bytes(range(256)) * 4
upload = _make_upload("data.bin", size=len(binary_content), content=binary_content)
result = utils.categorize_uploaded_files([upload], MagicMock())
assert len(result.acceptable) == 0
assert len(result.rejected) == 1
assert result.rejected[0].filename == "data.bin"
assert "Unsupported file type" in result.rejected[0].reason

View File

@@ -5,7 +5,7 @@ home: https://www.onyx.app/
sources:
- "https://github.com/onyx-dot-app/onyx"
type: application
version: 0.4.33
version: 0.4.34
appVersion: latest
annotations:
category: Productivity

View File

@@ -0,0 +1,6 @@
# Values for chart-testing (ct lint/install)
# This file is automatically used by ct when running lint and install commands
auth:
userauth:
values:
user_auth_secret: "placeholder-for-ci-testing"

View File

@@ -1,17 +1,29 @@
{{- if hasKey .Values.auth "secretKeys" }}
{{- fail "ERROR: Secrets handling has been refactored under 'auth' and must be updated before upgrading to this chart version." }}
{{- end }}
{{- range $secretContent := .Values.auth }}
{{- if and (empty $secretContent.existingSecret) (ne ($secretContent.enabled | default true) false) }}
{{- range $secretKey, $secretContent := .Values.auth }}
{{- if and (empty $secretContent.existingSecret) (or (not (hasKey $secretContent "enabled")) $secretContent.enabled) }}
{{- $secretName := include "onyx.secretName" $secretContent }}
{{- $existingSecret := lookup "v1" "Secret" $.Release.Namespace $secretName }}
{{- /* Pre-validate: fail before emitting YAML if any required value is missing */ -}}
{{- range $name, $value := $secretContent.values }}
{{- if and (empty $value) (not (and $existingSecret (hasKey $existingSecret.data $name))) }}
{{- fail (printf "Secret value for '%s' is required but not set and no existing secret found. Please set auth.%s.values.%s in values.yaml" $name $secretKey $name) }}
{{- end }}
{{- end }}
---
apiVersion: v1
kind: Secret
metadata:
name: {{ include "onyx.secretName" $secretContent }}
name: {{ $secretName }}
type: Opaque
stringData:
{{- range $name, $value := $secretContent.values }}
{{- range $name, $value := $secretContent.values }}
{{- if not (empty $value) }}
{{ $name }}: {{ $value | quote }}
{{- end }}
{{- else if and $existingSecret (hasKey $existingSecret.data $name) }}
{{ $name }}: {{ index $existingSecret.data $name | b64dec | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -1183,10 +1183,26 @@ auth:
values:
opensearch_admin_username: "admin"
opensearch_admin_password: "OnyxDev1!"
userauth:
# -- Used for signing password reset tokens, email verification tokens, and JWT tokens.
enabled: true
# -- Overwrite the default secret name, ignored if existingSecret is defined
secretName: 'onyx-userauth'
# -- Use a secret specified elsewhere
existingSecret: ""
# -- This defines the env var to secret map
secretKeys:
USER_AUTH_SECRET: user_auth_secret
# -- Secret value. Required - generate with: openssl rand -hex 32
# If not set, helm install/upgrade will fail.
values:
user_auth_secret: ""
configMap:
# Change this for production uses unless Onyx is only accessible behind VPN
AUTH_TYPE: "disabled"
# Auth type: "basic" (default), "google_oauth", "oidc", or "saml"
# UPGRADE NOTE: Default changed from "disabled" to "basic" in 0.4.34.
# You must also set auth.userauth.values.user_auth_secret.
AUTH_TYPE: "basic"
# 1 Day Default
SESSION_EXPIRE_TIME_SECONDS: "86400"
# Can be something like onyx.app, as an extra double-check

View File

@@ -2,7 +2,6 @@ package cmd
import (
"fmt"
"github.com/jmelahman/tag/git"
"github.com/spf13/cobra"
)

124
uv.lock generated
View File

@@ -4739,70 +4739,70 @@ wheels = [
[[package]]
name = "orjson"
version = "3.11.4"
version = "3.11.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c6/fe/ed708782d6709cc60eb4c2d8a361a440661f74134675c72990f2c48c785f/orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d", size = 5945188, upload-time = "2025-10-24T15:50:38.027Z" }
sdist = { url = "https://files.pythonhosted.org/packages/70/a3/4e09c61a5f0c521cba0bb433639610ae037437669f1a4cbc93799e731d78/orjson-3.11.6.tar.gz", hash = "sha256:0a54c72259f35299fd033042367df781c2f66d10252955ca1efb7db309b954cb", size = 6175856, upload-time = "2026-01-29T15:13:07.942Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/1d/1ea6005fffb56715fd48f632611e163d1604e8316a5bad2288bee9a1c9eb/orjson-3.11.4-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5e59d23cd93ada23ec59a96f215139753fbfe3a4d989549bcb390f8c00370b39", size = 243498, upload-time = "2025-10-24T15:48:48.101Z" },
{ url = "https://files.pythonhosted.org/packages/37/d7/ffed10c7da677f2a9da307d491b9eb1d0125b0307019c4ad3d665fd31f4f/orjson-3.11.4-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5c3aedecfc1beb988c27c79d52ebefab93b6c3921dbec361167e6559aba2d36d", size = 128961, upload-time = "2025-10-24T15:48:49.571Z" },
{ url = "https://files.pythonhosted.org/packages/a2/96/3e4d10a18866d1368f73c8c44b7fe37cc8a15c32f2a7620be3877d4c55a3/orjson-3.11.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da9e5301f1c2caa2a9a4a303480d79c9ad73560b2e7761de742ab39fe59d9175", size = 130321, upload-time = "2025-10-24T15:48:50.713Z" },
{ url = "https://files.pythonhosted.org/packages/eb/1f/465f66e93f434f968dd74d5b623eb62c657bdba2332f5a8be9f118bb74c7/orjson-3.11.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8873812c164a90a79f65368f8f96817e59e35d0cc02786a5356f0e2abed78040", size = 129207, upload-time = "2025-10-24T15:48:52.193Z" },
{ url = "https://files.pythonhosted.org/packages/28/43/d1e94837543321c119dff277ae8e348562fe8c0fafbb648ef7cb0c67e521/orjson-3.11.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d7feb0741ebb15204e748f26c9638e6665a5fa93c37a2c73d64f1669b0ddc63", size = 136323, upload-time = "2025-10-24T15:48:54.806Z" },
{ url = "https://files.pythonhosted.org/packages/bf/04/93303776c8890e422a5847dd012b4853cdd88206b8bbd3edc292c90102d1/orjson-3.11.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ee5487fefee21e6910da4c2ee9eef005bee568a0879834df86f888d2ffbdd9", size = 137440, upload-time = "2025-10-24T15:48:56.326Z" },
{ url = "https://files.pythonhosted.org/packages/1e/ef/75519d039e5ae6b0f34d0336854d55544ba903e21bf56c83adc51cd8bf82/orjson-3.11.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d40d46f348c0321df01507f92b95a377240c4ec31985225a6668f10e2676f9a", size = 136680, upload-time = "2025-10-24T15:48:57.476Z" },
{ url = "https://files.pythonhosted.org/packages/b5/18/bf8581eaae0b941b44efe14fee7b7862c3382fbc9a0842132cfc7cf5ecf4/orjson-3.11.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95713e5fc8af84d8edc75b785d2386f653b63d62b16d681687746734b4dfc0be", size = 136160, upload-time = "2025-10-24T15:48:59.631Z" },
{ url = "https://files.pythonhosted.org/packages/c4/35/a6d582766d351f87fc0a22ad740a641b0a8e6fc47515e8614d2e4790ae10/orjson-3.11.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad73ede24f9083614d6c4ca9a85fe70e33be7bf047ec586ee2363bc7418fe4d7", size = 140318, upload-time = "2025-10-24T15:49:00.834Z" },
{ url = "https://files.pythonhosted.org/packages/76/b3/5a4801803ab2e2e2d703bce1a56540d9f99a9143fbec7bf63d225044fef8/orjson-3.11.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:842289889de515421f3f224ef9c1f1efb199a32d76d8d2ca2706fa8afe749549", size = 406330, upload-time = "2025-10-24T15:49:02.327Z" },
{ url = "https://files.pythonhosted.org/packages/80/55/a8f682f64833e3a649f620eafefee175cbfeb9854fc5b710b90c3bca45df/orjson-3.11.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3b2427ed5791619851c52a1261b45c233930977e7de8cf36de05636c708fa905", size = 149580, upload-time = "2025-10-24T15:49:03.517Z" },
{ url = "https://files.pythonhosted.org/packages/ad/e4/c132fa0c67afbb3eb88274fa98df9ac1f631a675e7877037c611805a4413/orjson-3.11.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c36e524af1d29982e9b190573677ea02781456b2e537d5840e4538a5ec41907", size = 139846, upload-time = "2025-10-24T15:49:04.761Z" },
{ url = "https://files.pythonhosted.org/packages/54/06/dc3491489efd651fef99c5908e13951abd1aead1257c67f16135f95ce209/orjson-3.11.4-cp311-cp311-win32.whl", hash = "sha256:87255b88756eab4a68ec61837ca754e5d10fa8bc47dc57f75cedfeaec358d54c", size = 135781, upload-time = "2025-10-24T15:49:05.969Z" },
{ url = "https://files.pythonhosted.org/packages/79/b7/5e5e8d77bd4ea02a6ac54c42c818afb01dd31961be8a574eb79f1d2cfb1e/orjson-3.11.4-cp311-cp311-win_amd64.whl", hash = "sha256:e2d5d5d798aba9a0e1fede8d853fa899ce2cb930ec0857365f700dffc2c7af6a", size = 131391, upload-time = "2025-10-24T15:49:07.355Z" },
{ url = "https://files.pythonhosted.org/packages/0f/dc/9484127cc1aa213be398ed735f5f270eedcb0c0977303a6f6ddc46b60204/orjson-3.11.4-cp311-cp311-win_arm64.whl", hash = "sha256:6bb6bb41b14c95d4f2702bce9975fda4516f1db48e500102fc4d8119032ff045", size = 126252, upload-time = "2025-10-24T15:49:08.869Z" },
{ url = "https://files.pythonhosted.org/packages/63/51/6b556192a04595b93e277a9ff71cd0cc06c21a7df98bcce5963fa0f5e36f/orjson-3.11.4-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d4371de39319d05d3f482f372720b841c841b52f5385bd99c61ed69d55d9ab50", size = 243571, upload-time = "2025-10-24T15:49:10.008Z" },
{ url = "https://files.pythonhosted.org/packages/1c/2c/2602392ddf2601d538ff11848b98621cd465d1a1ceb9db9e8043181f2f7b/orjson-3.11.4-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e41fd3b3cac850eaae78232f37325ed7d7436e11c471246b87b2cd294ec94853", size = 128891, upload-time = "2025-10-24T15:49:11.297Z" },
{ url = "https://files.pythonhosted.org/packages/4e/47/bf85dcf95f7a3a12bf223394a4f849430acd82633848d52def09fa3f46ad/orjson-3.11.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600e0e9ca042878c7fdf189cf1b028fe2c1418cc9195f6cb9824eb6ed99cb938", size = 130137, upload-time = "2025-10-24T15:49:12.544Z" },
{ url = "https://files.pythonhosted.org/packages/b4/4d/a0cb31007f3ab6f1fd2a1b17057c7c349bc2baf8921a85c0180cc7be8011/orjson-3.11.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7bbf9b333f1568ef5da42bc96e18bf30fd7f8d54e9ae066d711056add508e415", size = 129152, upload-time = "2025-10-24T15:49:13.754Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ef/2811def7ce3d8576b19e3929fff8f8f0d44bc5eb2e0fdecb2e6e6cc6c720/orjson-3.11.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806363144bb6e7297b8e95870e78d30a649fdc4e23fc84daa80c8ebd366ce44", size = 136834, upload-time = "2025-10-24T15:49:15.307Z" },
{ url = "https://files.pythonhosted.org/packages/00/d4/9aee9e54f1809cec8ed5abd9bc31e8a9631d19460e3b8470145d25140106/orjson-3.11.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad355e8308493f527d41154e9053b86a5be892b3b359a5c6d5d95cda23601cb2", size = 137519, upload-time = "2025-10-24T15:49:16.557Z" },
{ url = "https://files.pythonhosted.org/packages/db/ea/67bfdb5465d5679e8ae8d68c11753aaf4f47e3e7264bad66dc2f2249e643/orjson-3.11.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a7517482667fb9f0ff1b2f16fe5829296ed7a655d04d68cd9711a4d8a4e708", size = 136749, upload-time = "2025-10-24T15:49:17.796Z" },
{ url = "https://files.pythonhosted.org/packages/01/7e/62517dddcfce6d53a39543cd74d0dccfcbdf53967017c58af68822100272/orjson-3.11.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97eb5942c7395a171cbfecc4ef6701fc3c403e762194683772df4c54cfbb2210", size = 136325, upload-time = "2025-10-24T15:49:19.347Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/40516739f99ab4c7ec3aaa5cc242d341fcb03a45d89edeeaabc5f69cb2cf/orjson-3.11.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:149d95d5e018bdd822e3f38c103b1a7c91f88d38a88aada5c4e9b3a73a244241", size = 140204, upload-time = "2025-10-24T15:49:20.545Z" },
{ url = "https://files.pythonhosted.org/packages/82/18/ff5734365623a8916e3a4037fcef1cd1782bfc14cf0992afe7940c5320bf/orjson-3.11.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:624f3951181eb46fc47dea3d221554e98784c823e7069edb5dbd0dc826ac909b", size = 406242, upload-time = "2025-10-24T15:49:21.884Z" },
{ url = "https://files.pythonhosted.org/packages/e1/43/96436041f0a0c8c8deca6a05ebeaf529bf1de04839f93ac5e7c479807aec/orjson-3.11.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:03bfa548cf35e3f8b3a96c4e8e41f753c686ff3d8e182ce275b1751deddab58c", size = 150013, upload-time = "2025-10-24T15:49:23.185Z" },
{ url = "https://files.pythonhosted.org/packages/1b/48/78302d98423ed8780479a1e682b9aecb869e8404545d999d34fa486e573e/orjson-3.11.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:525021896afef44a68148f6ed8a8bf8375553d6066c7f48537657f64823565b9", size = 139951, upload-time = "2025-10-24T15:49:24.428Z" },
{ url = "https://files.pythonhosted.org/packages/4a/7b/ad613fdcdaa812f075ec0875143c3d37f8654457d2af17703905425981bf/orjson-3.11.4-cp312-cp312-win32.whl", hash = "sha256:b58430396687ce0f7d9eeb3dd47761ca7d8fda8e9eb92b3077a7a353a75efefa", size = 136049, upload-time = "2025-10-24T15:49:25.973Z" },
{ url = "https://files.pythonhosted.org/packages/b9/3c/9cf47c3ff5f39b8350fb21ba65d789b6a1129d4cbb3033ba36c8a9023520/orjson-3.11.4-cp312-cp312-win_amd64.whl", hash = "sha256:c6dbf422894e1e3c80a177133c0dda260f81428f9de16d61041949f6a2e5c140", size = 131461, upload-time = "2025-10-24T15:49:27.259Z" },
{ url = "https://files.pythonhosted.org/packages/c6/3b/e2425f61e5825dc5b08c2a5a2b3af387eaaca22a12b9c8c01504f8614c36/orjson-3.11.4-cp312-cp312-win_arm64.whl", hash = "sha256:d38d2bc06d6415852224fcc9c0bfa834c25431e466dc319f0edd56cca81aa96e", size = 126167, upload-time = "2025-10-24T15:49:28.511Z" },
{ url = "https://files.pythonhosted.org/packages/23/15/c52aa7112006b0f3d6180386c3a46ae057f932ab3425bc6f6ac50431cca1/orjson-3.11.4-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2d6737d0e616a6e053c8b4acc9eccea6b6cce078533666f32d140e4f85002534", size = 243525, upload-time = "2025-10-24T15:49:29.737Z" },
{ url = "https://files.pythonhosted.org/packages/ec/38/05340734c33b933fd114f161f25a04e651b0c7c33ab95e9416ade5cb44b8/orjson-3.11.4-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:afb14052690aa328cc118a8e09f07c651d301a72e44920b887c519b313d892ff", size = 128871, upload-time = "2025-10-24T15:49:31.109Z" },
{ url = "https://files.pythonhosted.org/packages/55/b9/ae8d34899ff0c012039b5a7cb96a389b2476e917733294e498586b45472d/orjson-3.11.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38aa9e65c591febb1b0aed8da4d469eba239d434c218562df179885c94e1a3ad", size = 130055, upload-time = "2025-10-24T15:49:33.382Z" },
{ url = "https://files.pythonhosted.org/packages/33/aa/6346dd5073730451bee3681d901e3c337e7ec17342fb79659ec9794fc023/orjson-3.11.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2cf4dfaf9163b0728d061bebc1e08631875c51cd30bf47cb9e3293bfbd7dcd5", size = 129061, upload-time = "2025-10-24T15:49:34.935Z" },
{ url = "https://files.pythonhosted.org/packages/39/e4/8eea51598f66a6c853c380979912d17ec510e8e66b280d968602e680b942/orjson-3.11.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89216ff3dfdde0e4070932e126320a1752c9d9a758d6a32ec54b3b9334991a6a", size = 136541, upload-time = "2025-10-24T15:49:36.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/47/cb8c654fa9adcc60e99580e17c32b9e633290e6239a99efa6b885aba9dbc/orjson-3.11.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9daa26ca8e97fae0ce8aa5d80606ef8f7914e9b129b6b5df9104266f764ce436", size = 137535, upload-time = "2025-10-24T15:49:38.307Z" },
{ url = "https://files.pythonhosted.org/packages/43/92/04b8cc5c2b729f3437ee013ce14a60ab3d3001465d95c184758f19362f23/orjson-3.11.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c8b2769dc31883c44a9cd126560327767f848eb95f99c36c9932f51090bfce9", size = 136703, upload-time = "2025-10-24T15:49:40.795Z" },
{ url = "https://files.pythonhosted.org/packages/aa/fd/d0733fcb9086b8be4ebcfcda2d0312865d17d0d9884378b7cffb29d0763f/orjson-3.11.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1469d254b9884f984026bd9b0fa5bbab477a4bfe558bba6848086f6d43eb5e73", size = 136293, upload-time = "2025-10-24T15:49:42.347Z" },
{ url = "https://files.pythonhosted.org/packages/c2/d7/3c5514e806837c210492d72ae30ccf050ce3f940f45bf085bab272699ef4/orjson-3.11.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:68e44722541983614e37117209a194e8c3ad07838ccb3127d96863c95ec7f1e0", size = 140131, upload-time = "2025-10-24T15:49:43.638Z" },
{ url = "https://files.pythonhosted.org/packages/9c/dd/ba9d32a53207babf65bd510ac4d0faaa818bd0df9a9c6f472fe7c254f2e3/orjson-3.11.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e7805fda9672c12be2f22ae124dcd7b03928d6c197544fe12174b86553f3196", size = 406164, upload-time = "2025-10-24T15:49:45.498Z" },
{ url = "https://files.pythonhosted.org/packages/8e/f9/f68ad68f4af7c7bde57cd514eaa2c785e500477a8bc8f834838eb696a685/orjson-3.11.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:04b69c14615fb4434ab867bf6f38b2d649f6f300af30a6705397e895f7aec67a", size = 149859, upload-time = "2025-10-24T15:49:46.981Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d2/7f847761d0c26818395b3d6b21fb6bc2305d94612a35b0a30eae65a22728/orjson-3.11.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:639c3735b8ae7f970066930e58cf0ed39a852d417c24acd4a25fc0b3da3c39a6", size = 139926, upload-time = "2025-10-24T15:49:48.321Z" },
{ url = "https://files.pythonhosted.org/packages/9f/37/acd14b12dc62db9a0e1d12386271b8661faae270b22492580d5258808975/orjson-3.11.4-cp313-cp313-win32.whl", hash = "sha256:6c13879c0d2964335491463302a6ca5ad98105fc5db3565499dcb80b1b4bd839", size = 136007, upload-time = "2025-10-24T15:49:49.938Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a9/967be009ddf0a1fffd7a67de9c36656b28c763659ef91352acc02cbe364c/orjson-3.11.4-cp313-cp313-win_amd64.whl", hash = "sha256:09bf242a4af98732db9f9a1ec57ca2604848e16f132e3f72edfd3c5c96de009a", size = 131314, upload-time = "2025-10-24T15:49:51.248Z" },
{ url = "https://files.pythonhosted.org/packages/cb/db/399abd6950fbd94ce125cb8cd1a968def95174792e127b0642781e040ed4/orjson-3.11.4-cp313-cp313-win_arm64.whl", hash = "sha256:a85f0adf63319d6c1ba06fb0dbf997fced64a01179cf17939a6caca662bf92de", size = 126152, upload-time = "2025-10-24T15:49:52.922Z" },
{ url = "https://files.pythonhosted.org/packages/25/e3/54ff63c093cc1697e758e4fceb53164dd2661a7d1bcd522260ba09f54533/orjson-3.11.4-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:42d43a1f552be1a112af0b21c10a5f553983c2a0938d2bbb8ecd8bc9fb572803", size = 243501, upload-time = "2025-10-24T15:49:54.288Z" },
{ url = "https://files.pythonhosted.org/packages/ac/7d/e2d1076ed2e8e0ae9badca65bf7ef22710f93887b29eaa37f09850604e09/orjson-3.11.4-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:26a20f3fbc6c7ff2cb8e89c4c5897762c9d88cf37330c6a117312365d6781d54", size = 128862, upload-time = "2025-10-24T15:49:55.961Z" },
{ url = "https://files.pythonhosted.org/packages/9f/37/ca2eb40b90621faddfa9517dfe96e25f5ae4d8057a7c0cdd613c17e07b2c/orjson-3.11.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e3f20be9048941c7ffa8fc523ccbd17f82e24df1549d1d1fe9317712d19938e", size = 130047, upload-time = "2025-10-24T15:49:57.406Z" },
{ url = "https://files.pythonhosted.org/packages/c7/62/1021ed35a1f2bad9040f05fa4cc4f9893410df0ba3eaa323ccf899b1c90a/orjson-3.11.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aac364c758dc87a52e68e349924d7e4ded348dedff553889e4d9f22f74785316", size = 129073, upload-time = "2025-10-24T15:49:58.782Z" },
{ url = "https://files.pythonhosted.org/packages/e8/3f/f84d966ec2a6fd5f73b1a707e7cd876813422ae4bf9f0145c55c9c6a0f57/orjson-3.11.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5c54a6d76e3d741dcc3f2707f8eeb9ba2a791d3adbf18f900219b62942803b1", size = 136597, upload-time = "2025-10-24T15:50:00.12Z" },
{ url = "https://files.pythonhosted.org/packages/32/78/4fa0aeca65ee82bbabb49e055bd03fa4edea33f7c080c5c7b9601661ef72/orjson-3.11.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f28485bdca8617b79d44627f5fb04336897041dfd9fa66d383a49d09d86798bc", size = 137515, upload-time = "2025-10-24T15:50:01.57Z" },
{ url = "https://files.pythonhosted.org/packages/c1/9d/0c102e26e7fde40c4c98470796d050a2ec1953897e2c8ab0cb95b0759fa2/orjson-3.11.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bfc2a484cad3585e4ba61985a6062a4c2ed5c7925db6d39f1fa267c9d166487f", size = 136703, upload-time = "2025-10-24T15:50:02.944Z" },
{ url = "https://files.pythonhosted.org/packages/df/ac/2de7188705b4cdfaf0b6c97d2f7849c17d2003232f6e70df98602173f788/orjson-3.11.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e34dbd508cb91c54f9c9788923daca129fe5b55c5b4eebe713bf5ed3791280cf", size = 136311, upload-time = "2025-10-24T15:50:04.441Z" },
{ url = "https://files.pythonhosted.org/packages/e0/52/847fcd1a98407154e944feeb12e3b4d487a0e264c40191fb44d1269cbaa1/orjson-3.11.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b13c478fa413d4b4ee606ec8e11c3b2e52683a640b006bb586b3041c2ca5f606", size = 140127, upload-time = "2025-10-24T15:50:07.398Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ae/21d208f58bdb847dd4d0d9407e2929862561841baa22bdab7aea10ca088e/orjson-3.11.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:724ca721ecc8a831b319dcd72cfa370cc380db0bf94537f08f7edd0a7d4e1780", size = 406201, upload-time = "2025-10-24T15:50:08.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/55/0789d6de386c8366059db098a628e2ad8798069e94409b0d8935934cbcb9/orjson-3.11.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:977c393f2e44845ce1b540e19a786e9643221b3323dae190668a98672d43fb23", size = 149872, upload-time = "2025-10-24T15:50:10.234Z" },
{ url = "https://files.pythonhosted.org/packages/cc/1d/7ff81ea23310e086c17b41d78a72270d9de04481e6113dbe2ac19118f7fb/orjson-3.11.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e539e382cf46edec157ad66b0b0872a90d829a6b71f17cb633d6c160a223155", size = 139931, upload-time = "2025-10-24T15:50:11.623Z" },
{ url = "https://files.pythonhosted.org/packages/77/92/25b886252c50ed64be68c937b562b2f2333b45afe72d53d719e46a565a50/orjson-3.11.4-cp314-cp314-win32.whl", hash = "sha256:d63076d625babab9db5e7836118bdfa086e60f37d8a174194ae720161eb12394", size = 136065, upload-time = "2025-10-24T15:50:13.025Z" },
{ url = "https://files.pythonhosted.org/packages/63/b8/718eecf0bb7e9d64e4956afaafd23db9f04c776d445f59fe94f54bdae8f0/orjson-3.11.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a54d6635fa3aaa438ae32e8570b9f0de36f3f6562c308d2a2a452e8b0592db1", size = 131310, upload-time = "2025-10-24T15:50:14.46Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bf/def5e25d4d8bfce296a9a7c8248109bf58622c21618b590678f945a2c59c/orjson-3.11.4-cp314-cp314-win_arm64.whl", hash = "sha256:78b999999039db3cf58f6d230f524f04f75f129ba3d1ca2ed121f8657e575d3d", size = 126151, upload-time = "2025-10-24T15:50:15.878Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d6b0a36854179b93ed77839f107c4089d91cccc9f9ba1b752b6e3bac5f34/orjson-3.11.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e259e85a81d76d9665f03d6129e09e4435531870de5961ddcd0bf6e3a7fde7d7", size = 250029, upload-time = "2026-01-29T15:11:35.942Z" },
{ url = "https://files.pythonhosted.org/packages/a3/bb/22902619826641cf3b627c24aab62e2ad6b571bdd1d34733abb0dd57f67a/orjson-3.11.6-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:52263949f41b4a4822c6b1353bcc5ee2f7109d53a3b493501d3369d6d0e7937a", size = 134518, upload-time = "2026-01-29T15:11:37.347Z" },
{ url = "https://files.pythonhosted.org/packages/72/90/7a818da4bba1de711a9653c420749c0ac95ef8f8651cbc1dca551f462fe0/orjson-3.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6439e742fa7834a24698d358a27346bb203bff356ae0402e7f5df8f749c621a8", size = 137917, upload-time = "2026-01-29T15:11:38.511Z" },
{ url = "https://files.pythonhosted.org/packages/59/0f/02846c1cac8e205cb3822dd8aa8f9114acda216f41fd1999ace6b543418d/orjson-3.11.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b81ffd68f084b4e993e3867acb554a049fa7787cc8710bbcc1e26965580d99be", size = 134923, upload-time = "2026-01-29T15:11:39.711Z" },
{ url = "https://files.pythonhosted.org/packages/94/cf/aeaf683001b474bb3c3c757073a4231dfdfe8467fceaefa5bfd40902c99f/orjson-3.11.6-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5a5468e5e60f7ef6d7f9044b06c8f94a3c56ba528c6e4f7f06ae95164b595ec", size = 140752, upload-time = "2026-01-29T15:11:41.347Z" },
{ url = "https://files.pythonhosted.org/packages/fc/fe/dad52d8315a65f084044a0819d74c4c9daf9ebe0681d30f525b0d29a31f0/orjson-3.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72c5005eb45bd2535632d4f3bec7ad392832cfc46b62a3021da3b48a67734b45", size = 144201, upload-time = "2026-01-29T15:11:42.537Z" },
{ url = "https://files.pythonhosted.org/packages/36/bc/ab070dd421565b831801077f1e390c4d4af8bfcecafc110336680a33866b/orjson-3.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b14dd49f3462b014455a28a4d810d3549bf990567653eb43765cd847df09145", size = 142380, upload-time = "2026-01-29T15:11:44.309Z" },
{ url = "https://files.pythonhosted.org/packages/e6/d8/4b581c725c3a308717f28bf45a9fdac210bca08b67e8430143699413ff06/orjson-3.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0bb2c1ea30ef302f0f89f9bf3e7f9ab5e2af29dc9f80eb87aa99788e4e2d65", size = 145582, upload-time = "2026-01-29T15:11:45.506Z" },
{ url = "https://files.pythonhosted.org/packages/5b/a2/09aab99b39f9a7f175ea8fa29adb9933a3d01e7d5d603cdee7f1c40c8da2/orjson-3.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:825e0a85d189533c6bff7e2fc417a28f6fcea53d27125c4551979aecd6c9a197", size = 147270, upload-time = "2026-01-29T15:11:46.782Z" },
{ url = "https://files.pythonhosted.org/packages/b8/2f/5ef8eaf7829dc50da3bf497c7775b21ee88437bc8c41f959aa3504ca6631/orjson-3.11.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:b04575417a26530637f6ab4b1f7b4f666eb0433491091da4de38611f97f2fcf3", size = 421222, upload-time = "2026-01-29T15:11:48.106Z" },
{ url = "https://files.pythonhosted.org/packages/3b/b0/dd6b941294c2b5b13da5fdc7e749e58d0c55a5114ab37497155e83050e95/orjson-3.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b83eb2e40e8c4da6d6b340ee6b1d6125f5195eb1b0ebb7eac23c6d9d4f92d224", size = 155562, upload-time = "2026-01-29T15:11:49.408Z" },
{ url = "https://files.pythonhosted.org/packages/8e/09/43924331a847476ae2f9a16bd6d3c9dab301265006212ba0d3d7fd58763a/orjson-3.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1f42da604ee65a6b87eef858c913ce3e5777872b19321d11e6fc6d21de89b64f", size = 147432, upload-time = "2026-01-29T15:11:50.635Z" },
{ url = "https://files.pythonhosted.org/packages/5d/e9/d9865961081816909f6b49d880749dbbd88425afd7c5bbce0549e2290d77/orjson-3.11.6-cp311-cp311-win32.whl", hash = "sha256:5ae45df804f2d344cffb36c43fdf03c82fb6cd247f5faa41e21891b40dfbf733", size = 139623, upload-time = "2026-01-29T15:11:51.82Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f9/6836edb92f76eec1082919101eb1145d2f9c33c8f2c5e6fa399b82a2aaa8/orjson-3.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:f4295948d65ace0a2d8f2c4ccc429668b7eb8af547578ec882e16bf79b0050b2", size = 136647, upload-time = "2026-01-29T15:11:53.454Z" },
{ url = "https://files.pythonhosted.org/packages/b3/0c/4954082eea948c9ae52ee0bcbaa2f99da3216a71bcc314ab129bde22e565/orjson-3.11.6-cp311-cp311-win_arm64.whl", hash = "sha256:314e9c45e0b81b547e3a1cfa3df3e07a815821b3dac9fe8cb75014071d0c16a4", size = 135327, upload-time = "2026-01-29T15:11:56.616Z" },
{ url = "https://files.pythonhosted.org/packages/14/ba/759f2879f41910b7e5e0cdbd9cf82a4f017c527fb0e972e9869ca7fe4c8e/orjson-3.11.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6f03f30cd8953f75f2a439070c743c7336d10ee940da918d71c6f3556af3ddcf", size = 249988, upload-time = "2026-01-29T15:11:58.294Z" },
{ url = "https://files.pythonhosted.org/packages/f0/70/54cecb929e6c8b10104fcf580b0cc7dc551aa193e83787dd6f3daba28bb5/orjson-3.11.6-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:af44baae65ef386ad971469a8557a0673bb042b0b9fd4397becd9c2dfaa02588", size = 134445, upload-time = "2026-01-29T15:11:59.819Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6f/ec0309154457b9ba1ad05f11faa4441f76037152f75e1ac577db3ce7ca96/orjson-3.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c310a48542094e4f7dbb6ac076880994986dda8ca9186a58c3cb70a3514d3231", size = 137708, upload-time = "2026-01-29T15:12:01.488Z" },
{ url = "https://files.pythonhosted.org/packages/20/52/3c71b80840f8bab9cb26417302707b7716b7d25f863f3a541bcfa232fe6e/orjson-3.11.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8dfa7a5d387f15ecad94cb6b2d2d5f4aeea64efd8d526bfc03c9812d01e1cc0", size = 134798, upload-time = "2026-01-29T15:12:02.705Z" },
{ url = "https://files.pythonhosted.org/packages/30/51/b490a43b22ff736282360bd02e6bded455cf31dfc3224e01cd39f919bbd2/orjson-3.11.6-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba8daee3e999411b50f8b50dbb0a3071dd1845f3f9a1a0a6fa6de86d1689d84d", size = 140839, upload-time = "2026-01-29T15:12:03.956Z" },
{ url = "https://files.pythonhosted.org/packages/95/bc/4bcfe4280c1bc63c5291bb96f98298845b6355da2226d3400e17e7b51e53/orjson-3.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89d104c974eafd7436d7a5fdbc57f7a1e776789959a2f4f1b2eab5c62a339f4", size = 144080, upload-time = "2026-01-29T15:12:05.151Z" },
{ url = "https://files.pythonhosted.org/packages/01/74/22970f9ead9ab1f1b5f8c227a6c3aa8d71cd2c5acd005868a1d44f2362fa/orjson-3.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2e2e2456788ca5ea75616c40da06fc885a7dc0389780e8a41bf7c5389ba257b", size = 142435, upload-time = "2026-01-29T15:12:06.641Z" },
{ url = "https://files.pythonhosted.org/packages/29/34/d564aff85847ab92c82ee43a7a203683566c2fca0723a5f50aebbe759603/orjson-3.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a42efebc45afabb1448001e90458c4020d5c64fbac8a8dc4045b777db76cb5a", size = 145631, upload-time = "2026-01-29T15:12:08.351Z" },
{ url = "https://files.pythonhosted.org/packages/e7/ef/016957a3890752c4aa2368326ea69fa53cdc1fdae0a94a542b6410dbdf52/orjson-3.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71b7cbef8471324966c3738c90ba38775563ef01b512feb5ad4805682188d1b9", size = 147058, upload-time = "2026-01-29T15:12:10.023Z" },
{ url = "https://files.pythonhosted.org/packages/56/cc/9a899c3972085645b3225569f91a30e221f441e5dc8126e6d060b971c252/orjson-3.11.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f8515e5910f454fe9a8e13c2bb9dc4bae4c1836313e967e72eb8a4ad874f0248", size = 421161, upload-time = "2026-01-29T15:12:11.308Z" },
{ url = "https://files.pythonhosted.org/packages/21/a8/767d3fbd6d9b8fdee76974db40619399355fd49bf91a6dd2c4b6909ccf05/orjson-3.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:300360edf27c8c9bf7047345a94fddf3a8b8922df0ff69d71d854a170cb375cf", size = 155757, upload-time = "2026-01-29T15:12:12.776Z" },
{ url = "https://files.pythonhosted.org/packages/ad/0b/205cd69ac87e2272e13ef3f5f03a3d4657e317e38c1b08aaa2ef97060bbc/orjson-3.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:caaed4dad39e271adfadc106fab634d173b2bb23d9cf7e67bd645f879175ebfc", size = 147446, upload-time = "2026-01-29T15:12:14.166Z" },
{ url = "https://files.pythonhosted.org/packages/de/c5/dd9f22aa9f27c54c7d05cc32f4580c9ac9b6f13811eeb81d6c4c3f50d6b1/orjson-3.11.6-cp312-cp312-win32.whl", hash = "sha256:955368c11808c89793e847830e1b1007503a5923ddadc108547d3b77df761044", size = 139717, upload-time = "2026-01-29T15:12:15.7Z" },
{ url = "https://files.pythonhosted.org/packages/23/a1/e62fc50d904486970315a1654b8cfb5832eb46abb18cd5405118e7e1fc79/orjson-3.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:2c68de30131481150073d90a5d227a4a421982f42c025ecdfb66157f9579e06f", size = 136711, upload-time = "2026-01-29T15:12:17.055Z" },
{ url = "https://files.pythonhosted.org/packages/04/3d/b4fefad8bdf91e0fe212eb04975aeb36ea92997269d68857efcc7eb1dda3/orjson-3.11.6-cp312-cp312-win_arm64.whl", hash = "sha256:65dfa096f4e3a5e02834b681f539a87fbe85adc82001383c0db907557f666bfc", size = 135212, upload-time = "2026-01-29T15:12:18.3Z" },
{ url = "https://files.pythonhosted.org/packages/ae/45/d9c71c8c321277bc1ceebf599bc55ba826ae538b7c61f287e9a7e71bd589/orjson-3.11.6-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e4ae1670caabb598a88d385798692ce2a1b2f078971b3329cfb85253c6097f5b", size = 249828, upload-time = "2026-01-29T15:12:20.14Z" },
{ url = "https://files.pythonhosted.org/packages/ac/7e/4afcf4cfa9c2f93846d70eee9c53c3c0123286edcbeb530b7e9bd2aea1b2/orjson-3.11.6-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:2c6b81f47b13dac2caa5d20fbc953c75eb802543abf48403a4703ed3bff225f0", size = 134339, upload-time = "2026-01-29T15:12:22.01Z" },
{ url = "https://files.pythonhosted.org/packages/40/10/6d2b8a064c8d2411d3d0ea6ab43125fae70152aef6bea77bb50fa54d4097/orjson-3.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:647d6d034e463764e86670644bdcaf8e68b076e6e74783383b01085ae9ab334f", size = 137662, upload-time = "2026-01-29T15:12:23.307Z" },
{ url = "https://files.pythonhosted.org/packages/5a/50/5804ea7d586baf83ee88969eefda97a24f9a5bdba0727f73e16305175b26/orjson-3.11.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8523b9cc4ef174ae52414f7699e95ee657c16aa18b3c3c285d48d7966cce9081", size = 134626, upload-time = "2026-01-29T15:12:25.099Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2e/f0492ed43e376722bb4afd648e06cc1e627fc7ec8ff55f6ee739277813ea/orjson-3.11.6-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:313dfd7184cde50c733fc0d5c8c0e2f09017b573afd11dc36bd7476b30b4cb17", size = 140873, upload-time = "2026-01-29T15:12:26.369Z" },
{ url = "https://files.pythonhosted.org/packages/10/15/6f874857463421794a303a39ac5494786ad46a4ab46d92bda6705d78c5aa/orjson-3.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905ee036064ff1e1fd1fb800055ac477cdcb547a78c22c1bc2bbf8d5d1a6fb42", size = 144044, upload-time = "2026-01-29T15:12:28.082Z" },
{ url = "https://files.pythonhosted.org/packages/d2/c7/b7223a3a70f1d0cc2d86953825de45f33877ee1b124a91ca1f79aa6e643f/orjson-3.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce374cb98411356ba906914441fc993f271a7a666d838d8de0e0900dd4a4bc12", size = 142396, upload-time = "2026-01-29T15:12:30.529Z" },
{ url = "https://files.pythonhosted.org/packages/87/e3/aa1b6d3ad3cd80f10394134f73ae92a1d11fdbe974c34aa199cc18bb5fcf/orjson-3.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cded072b9f65fcfd188aead45efa5bd528ba552add619b3ad2a81f67400ec450", size = 145600, upload-time = "2026-01-29T15:12:31.848Z" },
{ url = "https://files.pythonhosted.org/packages/f6/cf/e4aac5a46cbd39d7e769ef8650efa851dfce22df1ba97ae2b33efe893b12/orjson-3.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ab85bdbc138e1f73a234db6bb2e4cc1f0fcec8f4bd2bd2430e957a01aadf746", size = 146967, upload-time = "2026-01-29T15:12:33.203Z" },
{ url = "https://files.pythonhosted.org/packages/0b/04/975b86a4bcf6cfeda47aad15956d52fbeda280811206e9967380fa9355c8/orjson-3.11.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:351b96b614e3c37a27b8ab048239ebc1e0be76cc17481a430d70a77fb95d3844", size = 421003, upload-time = "2026-01-29T15:12:35.097Z" },
{ url = "https://files.pythonhosted.org/packages/28/d1/0369d0baf40eea5ff2300cebfe209883b2473ab4aa4c4974c8bd5ee42bb2/orjson-3.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f9959c85576beae5cdcaaf39510b15105f1ee8b70d5dacd90152617f57be8c83", size = 155695, upload-time = "2026-01-29T15:12:36.589Z" },
{ url = "https://files.pythonhosted.org/packages/ab/1f/d10c6d6ae26ff1d7c3eea6fd048280ef2e796d4fb260c5424fd021f68ecf/orjson-3.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75682d62b1b16b61a30716d7a2ec1f4c36195de4a1c61f6665aedd947b93a5d5", size = 147392, upload-time = "2026-01-29T15:12:37.876Z" },
{ url = "https://files.pythonhosted.org/packages/8d/43/7479921c174441a0aa5277c313732e20713c0969ac303be9f03d88d3db5d/orjson-3.11.6-cp313-cp313-win32.whl", hash = "sha256:40dc277999c2ef227dcc13072be879b4cfd325502daeb5c35ed768f706f2bf30", size = 139718, upload-time = "2026-01-29T15:12:39.274Z" },
{ url = "https://files.pythonhosted.org/packages/88/bc/9ffe7dfbf8454bc4e75bb8bf3a405ed9e0598df1d3535bb4adcd46be07d0/orjson-3.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:f0f6e9f8ff7905660bc3c8a54cd4a675aa98f7f175cf00a59815e2ff42c0d916", size = 136635, upload-time = "2026-01-29T15:12:40.593Z" },
{ url = "https://files.pythonhosted.org/packages/6f/7e/51fa90b451470447ea5023b20d83331ec741ae28d1e6d8ed547c24e7de14/orjson-3.11.6-cp313-cp313-win_arm64.whl", hash = "sha256:1608999478664de848e5900ce41f25c4ecdfc4beacbc632b6fd55e1a586e5d38", size = 135175, upload-time = "2026-01-29T15:12:41.997Z" },
{ url = "https://files.pythonhosted.org/packages/31/9f/46ca908abaeeec7560638ff20276ab327b980d73b3cc2f5b205b4a1c60b3/orjson-3.11.6-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6026db2692041d2a23fe2545606df591687787825ad5821971ef0974f2c47630", size = 249823, upload-time = "2026-01-29T15:12:43.332Z" },
{ url = "https://files.pythonhosted.org/packages/ff/78/ca478089818d18c9cd04f79c43f74ddd031b63c70fa2a946eb5e85414623/orjson-3.11.6-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:132b0ab2e20c73afa85cf142e547511feb3d2f5b7943468984658f3952b467d4", size = 134328, upload-time = "2026-01-29T15:12:45.171Z" },
{ url = "https://files.pythonhosted.org/packages/39/5e/cbb9d830ed4e47f4375ad8eef8e4fff1bf1328437732c3809054fc4e80be/orjson-3.11.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b376fb05f20a96ec117d47987dd3b39265c635725bda40661b4c5b73b77b5fde", size = 137651, upload-time = "2026-01-29T15:12:46.602Z" },
{ url = "https://files.pythonhosted.org/packages/7c/3a/35df6558c5bc3a65ce0961aefee7f8364e59af78749fc796ea255bfa0cf5/orjson-3.11.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:954dae4e080574672a1dfcf2a840eddef0f27bd89b0e94903dd0824e9c1db060", size = 134596, upload-time = "2026-01-29T15:12:47.95Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8e/3d32dd7b7f26a19cc4512d6ed0ae3429567c71feef720fe699ff43c5bc9e/orjson-3.11.6-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe515bb89d59e1e4b48637a964f480b35c0a2676de24e65e55310f6016cca7ce", size = 140923, upload-time = "2026-01-29T15:12:49.333Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9c/1efbf5c99b3304f25d6f0d493a8d1492ee98693637c10ce65d57be839d7b/orjson-3.11.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:380f9709c275917af28feb086813923251e11ee10687257cd7f1ea188bcd4485", size = 144068, upload-time = "2026-01-29T15:12:50.927Z" },
{ url = "https://files.pythonhosted.org/packages/82/83/0d19eeb5be797de217303bbb55dde58dba26f996ed905d301d98fd2d4637/orjson-3.11.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8173e0d3f6081e7034c51cf984036d02f6bab2a2126de5a759d79f8e5a140e7", size = 142493, upload-time = "2026-01-29T15:12:52.432Z" },
{ url = "https://files.pythonhosted.org/packages/32/a7/573fec3df4dc8fc259b7770dc6c0656f91adce6e19330c78d23f87945d1e/orjson-3.11.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dddf9ba706294906c56ef5150a958317b09aa3a8a48df1c52ccf22ec1907eac", size = 145616, upload-time = "2026-01-29T15:12:53.903Z" },
{ url = "https://files.pythonhosted.org/packages/c2/0e/23551b16f21690f7fd5122e3cf40fdca5d77052a434d0071990f97f5fe2f/orjson-3.11.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cbae5c34588dc79938dffb0b6fbe8c531f4dc8a6ad7f39759a9eb5d2da405ef2", size = 146951, upload-time = "2026-01-29T15:12:55.698Z" },
{ url = "https://files.pythonhosted.org/packages/b8/63/5e6c8f39805c39123a18e412434ea364349ee0012548d08aa586e2bd6aa9/orjson-3.11.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f75c318640acbddc419733b57f8a07515e587a939d8f54363654041fd1f4e465", size = 421024, upload-time = "2026-01-29T15:12:57.434Z" },
{ url = "https://files.pythonhosted.org/packages/1d/4d/724975cf0087f6550bd01fd62203418afc0ea33fd099aed318c5bcc52df8/orjson-3.11.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e0ab8d13aa2a3e98b4a43487c9205b2c92c38c054b4237777484d503357c8437", size = 155774, upload-time = "2026-01-29T15:12:59.397Z" },
{ url = "https://files.pythonhosted.org/packages/a8/a3/f4c4e3f46b55db29e0a5f20493b924fc791092d9a03ff2068c9fe6c1002f/orjson-3.11.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f884c7fb1020d44612bd7ac0db0babba0e2f78b68d9a650c7959bf99c783773f", size = 147393, upload-time = "2026-01-29T15:13:00.769Z" },
{ url = "https://files.pythonhosted.org/packages/ee/86/6f5529dd27230966171ee126cecb237ed08e9f05f6102bfaf63e5b32277d/orjson-3.11.6-cp314-cp314-win32.whl", hash = "sha256:8d1035d1b25732ec9f971e833a3e299d2b1a330236f75e6fd945ad982c76aaf3", size = 139760, upload-time = "2026-01-29T15:13:02.173Z" },
{ url = "https://files.pythonhosted.org/packages/d3/b5/91ae7037b2894a6b5002fb33f4fbccec98424a928469835c3837fbb22a9b/orjson-3.11.6-cp314-cp314-win_amd64.whl", hash = "sha256:931607a8865d21682bb72de54231655c86df1870502d2962dbfd12c82890d077", size = 136633, upload-time = "2026-01-29T15:13:04.267Z" },
{ url = "https://files.pythonhosted.org/packages/55/74/f473a3ec7a0a7ebc825ca8e3c86763f7d039f379860c81ba12dcdd456547/orjson-3.11.6-cp314-cp314-win_arm64.whl", hash = "sha256:fe71f6b283f4f1832204ab8235ce07adad145052614f77c876fcf0dac97bc06f", size = 135168, upload-time = "2026-01-29T15:13:05.932Z" },
]
[[package]]

View File

@@ -53,6 +53,8 @@ const sharedConfig = {
// Testing & Mocking
"msw",
"until-async",
// Language Detection
"linguist-languages",
// Markdown & Syntax Highlighting
"react-markdown",
"remark-.*", // All remark packages

View File

@@ -55,8 +55,11 @@ type OpenButtonContentProps =
children?: string;
};
type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> &
OpenButtonContentProps & {
type OpenButtonVariant = "select-light" | "select-heavy" | "select-tinted";
type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> & {
variant?: OpenButtonVariant;
} & OpenButtonContentProps & {
/**
* Size preset — controls gap, text size, and Container height/rounding.
*/
@@ -65,6 +68,13 @@ type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> &
/** Width preset. */
width?: WidthVariant;
/**
* Content justify mode. When `"between"`, icon+label group left and
* chevron pushes to the right edge. Default keeps all items in a
* tight `gap-1` row.
*/
justifyContent?: "between";
/** Tooltip text shown on hover. */
tooltip?: string;
@@ -82,9 +92,11 @@ function OpenButton({
size = "lg",
foldable,
width,
justifyContent,
tooltip,
tooltipSide = "top",
interaction,
variant = "select-heavy",
...statefulProps
}: OpenButtonProps) {
const { isDisabled } = useDisabled();
@@ -111,7 +123,7 @@ function OpenButton({
const button = (
<Interactive.Stateful
variant="select-heavy"
variant={variant}
interaction={resolvedInteraction}
{...statefulProps}
>
@@ -125,19 +137,32 @@ function OpenButton({
>
<div
className={cn(
"opal-button interactive-foreground flex flex-row items-center gap-1",
foldable && "interactive-foldable-host"
"opal-button interactive-foreground flex flex-row items-center",
justifyContent === "between" ? "w-full justify-between" : "gap-1",
foldable &&
justifyContent !== "between" &&
"interactive-foldable-host"
)}
>
{iconWrapper(Icon, size, !foldable && !!children)}
{foldable ? (
<Interactive.Foldable>
{labelEl}
{justifyContent === "between" ? (
<>
<span className="flex flex-row items-center gap-1">
{iconWrapper(Icon, size, !foldable && !!children)}
{labelEl}
</span>
{iconWrapper(ChevronIcon, size, !!children)}
</Interactive.Foldable>
</>
) : foldable ? (
<>
{iconWrapper(Icon, size, !foldable && !!children)}
<Interactive.Foldable>
{labelEl}
{iconWrapper(ChevronIcon, size, !!children)}
</Interactive.Foldable>
</>
) : (
<>
{iconWrapper(Icon, size, !foldable && !!children)}
{labelEl}
{iconWrapper(ChevronIcon, size, !!children)}
</>

View File

@@ -10,7 +10,11 @@ import type { WithoutStyles } from "@opal/types";
// Types
// ---------------------------------------------------------------------------
type InteractiveStatefulVariant = "select-light" | "select-heavy" | "sidebar";
type InteractiveStatefulVariant =
| "select-light"
| "select-heavy"
| "select-tinted"
| "sidebar";
type InteractiveStatefulState = "empty" | "filled" | "selected";
type InteractiveStatefulInteraction = "rest" | "hover" | "active";

View File

@@ -11,7 +11,7 @@
Children read the variables with no independent transitions.
State dimension: `data-interactive-state` = "empty" | "filled" | "selected"
Variant dimension: `data-interactive-variant` = "select-light" | "select-heavy" | "sidebar"
Variant dimension: `data-interactive-variant` = "select-light" | "select-heavy" | "select-tinted" | "sidebar"
Interaction override: `data-interaction="hover"` and `data-interaction="active"`
allow JS-controlled visual state overrides.
@@ -211,6 +211,103 @@
--interactive-foreground-icon: var(--action-link-03);
}
/* ===========================================================================
Select-Tinted — like Select-Heavy but with a tinted rest background
=========================================================================== */
/* ---------------------------------------------------------------------------
Select-Tinted — Empty
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"] {
@apply bg-background-tint-01;
--interactive-foreground: var(--text-04);
--interactive-foreground-icon: var(--text-03);
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-02;
--interactive-foreground-icon: var(--text-04);
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"]:active:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"][data-interaction="active"]:not(
[data-disabled]
) {
@apply bg-background-neutral-00;
--interactive-foreground: var(--text-05);
--interactive-foreground-icon: var(--text-05);
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"][data-disabled] {
@apply bg-transparent;
--interactive-foreground: var(--text-01);
--interactive-foreground-icon: var(--text-01);
}
/* ---------------------------------------------------------------------------
Select-Tinted — Filled
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"] {
@apply bg-background-tint-01;
--interactive-foreground: var(--action-link-05);
--interactive-foreground-icon: var(--action-link-05);
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-02;
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"]:active:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"][data-interaction="active"]:not(
[data-disabled]
) {
@apply bg-background-tint-00;
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"][data-disabled] {
@apply bg-transparent;
--interactive-foreground: var(--text-01);
--interactive-foreground-icon: var(--text-01);
}
/* ---------------------------------------------------------------------------
Select-Tinted — Selected
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"] {
@apply bg-[var(--action-link-01)];
--interactive-foreground: var(--action-link-05);
--interactive-foreground-icon: var(--action-link-05);
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-02;
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"]:active:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"][data-interaction="active"]:not(
[data-disabled]
) {
@apply bg-background-tint-00;
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"][data-disabled] {
@apply bg-transparent;
--interactive-foreground: var(--action-link-03);
--interactive-foreground-icon: var(--action-link-03);
}
/* ===========================================================================
Sidebar
=========================================================================== */

16
web/package-lock.json generated
View File

@@ -59,6 +59,7 @@
"lowlight": "^3.3.0",
"lucide-react": "^0.454.0",
"mdast-util-find-and-replace": "^3.0.1",
"mime": "^4.1.0",
"motion": "^12.29.0",
"next": "16.1.6",
"next-themes": "^0.4.4",
@@ -13883,6 +13884,21 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
"integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
"funding": [
"https://github.com/sponsors/broofa"
],
"license": "MIT",
"bin": {
"mime": "bin/cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"license": "MIT",

View File

@@ -77,6 +77,7 @@
"lowlight": "^3.3.0",
"lucide-react": "^0.454.0",
"mdast-util-find-and-replace": "^3.0.1",
"mime": "^4.1.0",
"motion": "^12.29.0",
"next": "16.1.6",
"next-themes": "^0.4.4",

View File

@@ -12,7 +12,7 @@ import {
MODAL_ROOT_ID,
} from "@/lib/constants";
import { Metadata } from "next";
import { buildClientUrl } from "@/lib/utilsSS";
import { Inter } from "next/font/google";
import { EnterpriseSettings, ApplicationStatus } from "@/interfaces/settings";
import AppProvider from "@/providers/AppProvider";
@@ -45,14 +45,14 @@ const hankenGrotesk = Hanken_Grotesk({
});
export async function generateMetadata(): Promise<Metadata> {
let logoLocation = buildClientUrl("/onyx.ico");
let logoLocation = "/onyx.ico";
let enterpriseSettings: EnterpriseSettings | null = null;
if (SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED) {
enterpriseSettings = await (await fetchEnterpriseSettingsSS()).json();
logoLocation =
enterpriseSettings && enterpriseSettings.use_custom_logo
? "/api/enterprise-settings/logo"
: buildClientUrl("/onyx.ico");
: "/onyx.ico";
}
return {

View File

@@ -11,14 +11,13 @@ import rehypeHighlight from "rehype-highlight";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
import { transformLinkUri } from "@/lib/utils";
import { cn, transformLinkUri } from "@/lib/utils";
type MinimalMarkdownComponentOverrides = Partial<Components>;
interface MinimalMarkdownProps {
content: string;
className?: string;
style?: CSSProperties;
showHeader?: boolean;
/**
* Override specific markdown renderers.
@@ -30,7 +29,6 @@ interface MinimalMarkdownProps {
export default function MinimalMarkdown({
content,
className = "",
style,
showHeader = true,
components,
}: MinimalMarkdownProps) {
@@ -63,19 +61,17 @@ export default function MinimalMarkdown({
}, [content, components, showHeader]);
return (
<div style={style || {}} className={`${className}`}>
<ReactMarkdown
className="prose dark:prose-invert max-w-full text-sm break-words"
components={markdownComponents}
rehypePlugins={[rehypeHighlight, rehypeKatex]}
remarkPlugins={[
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
]}
urlTransform={transformLinkUri}
>
{content}
</ReactMarkdown>
</div>
<ReactMarkdown
className={cn(
"prose dark:prose-invert max-w-full text-sm break-words",
className
)}
components={markdownComponents}
rehypePlugins={[rehypeHighlight, rehypeKatex]}
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
urlTransform={transformLinkUri}
>
{content}
</ReactMarkdown>
);
}

View File

@@ -1,5 +1,6 @@
"use client";
import { useCallback } from "react";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
@@ -112,11 +113,11 @@ export default function useAdminUsers() {
const isLoading = acceptedLoading || invitedLoading || requestedLoading;
const error = acceptedError ?? invitedError ?? requestedError;
function refresh() {
const refresh = useCallback(() => {
acceptedMutate();
invitedMutate();
requestedMutate();
}
}, [acceptedMutate, invitedMutate, requestedMutate]);
return { users, isLoading, error, refresh };
}

View File

@@ -0,0 +1,102 @@
import {
getCodeLanguage,
getDataLanguage,
getLanguageByMime,
isMarkdownFile,
} from "./languages";
describe("getCodeLanguage", () => {
it.each([
["app.py", "python"],
["index.ts", "typescript"],
["main.go", "go"],
["style.css", "css"],
["page.html", "html"],
["App.vue", "vue"],
["lib.rs", "rust"],
["main.cpp", "c++"],
["util.c", "c"],
["script.js", "javascript"],
])("%s → %s", (filename, expected) => {
expect(getCodeLanguage(filename)).toBe(expected);
});
it.each([
[".h", "c"],
[".inc", "php"],
[".m", "objective-c"],
[".re", "reason"],
])("override: %s → %s", (ext, expected) => {
expect(getCodeLanguage(`file${ext}`)).toBe(expected);
});
it("resolves by exact filename when there is no extension", () => {
expect(getCodeLanguage("Dockerfile")).toBe("dockerfile");
expect(getCodeLanguage("Makefile")).toBe("makefile");
});
it("is case-insensitive for filenames", () => {
expect(getCodeLanguage("INDEX.JS")).toBe("javascript");
expect(getCodeLanguage("dockerfile")).toBe("dockerfile");
});
it("returns null for unknown extensions", () => {
expect(getCodeLanguage("file.xyz123")).toBeNull();
});
it("excludes markdown extensions", () => {
expect(getCodeLanguage("README.md")).toBeNull();
expect(getCodeLanguage("notes.markdown")).toBeNull();
});
});
describe("getDataLanguage", () => {
it.each([
["config.json", "json"],
["config.yaml", "yaml"],
["config.yml", "yaml"],
["config.toml", "toml"],
["data.xml", "xml"],
["data.csv", "csv"],
])("%s → %s", (filename, expected) => {
expect(getDataLanguage(filename)).toBe(expected);
});
it("returns null for code files", () => {
expect(getDataLanguage("app.py")).toBeNull();
expect(getDataLanguage("header.h")).toBeNull();
expect(getDataLanguage("view.m")).toBeNull();
expect(getDataLanguage("component.re")).toBeNull();
});
});
describe("isMarkdownFile", () => {
it("recognises markdown extensions", () => {
expect(isMarkdownFile("README.md")).toBe(true);
expect(isMarkdownFile("doc.markdown")).toBe(true);
});
it("is case-insensitive", () => {
expect(isMarkdownFile("NOTES.MD")).toBe(true);
});
it("rejects non-markdown files", () => {
expect(isMarkdownFile("app.py")).toBe(false);
expect(isMarkdownFile("data.json")).toBe(false);
});
});
describe("getLanguageByMime", () => {
it("resolves known MIME types", () => {
expect(getLanguageByMime("text/x-python")).toBe("python");
expect(getLanguageByMime("text/javascript")).toBe("javascript");
});
it("strips parameters before matching", () => {
expect(getLanguageByMime("text/x-python; charset=utf-8")).toBe("python");
});
it("returns null for unknown MIME types", () => {
expect(getLanguageByMime("application/x-unknown-thing")).toBeNull();
});
});

View File

@@ -7,6 +7,7 @@ interface LinguistLanguage {
type: string;
extensions?: string[];
filenames?: string[];
codemirrorMimeType?: string;
}
interface LanguageMaps {
@@ -14,7 +15,23 @@ interface LanguageMaps {
filenames: Map<string, string>;
}
const allLanguages = Object.values(languages) as LinguistLanguage[];
// Explicit winners for extensions claimed by multiple linguist-languages entries
// where the "most extensions" heuristic below picks the wrong language.
const EXTENSION_OVERRIDES: Record<string, string> = {
".h": "c",
".inc": "php",
".m": "objective-c",
".re": "reason",
".rs": "rust",
};
// Sort so that languages with more extensions (i.e. more general-purpose) win
// when multiple languages claim the same extension (e.g. Ecmarkup vs HTML both
// claim .html — HTML should win because it's the canonical language for that
// extension). Known mis-rankings are patched by EXTENSION_OVERRIDES above.
const allLanguages = (Object.values(languages) as LinguistLanguage[]).sort(
(a, b) => (b.extensions?.length ?? 0) - (a.extensions?.length ?? 0)
);
// Collect extensions that linguist-languages assigns to "Markdown" so we can
// exclude them from the code-language map
@@ -25,14 +42,22 @@ const markdownExtensions = new Set(
);
function buildLanguageMaps(
type: string,
types: string[],
excludedExtensions?: Set<string>
): LanguageMaps {
const typeSet = new Set(types);
const extensions = new Map<string, string>();
const filenames = new Map<string, string>();
if (typeSet.has("programming") || typeSet.has("markup")) {
for (const [ext, lang] of Object.entries(EXTENSION_OVERRIDES)) {
if (excludedExtensions?.has(ext.toLowerCase())) continue;
extensions.set(ext, lang);
}
}
for (const lang of allLanguages) {
if (lang.type !== type) continue;
if (!typeSet.has(lang.type)) continue;
const name = lang.name.toLowerCase();
for (const ext of lang.extensions ?? []) {
@@ -57,13 +82,17 @@ function lookupLanguage(name: string, maps: LanguageMaps): string | null {
return (ext && maps.extensions.get(ext)) ?? maps.filenames.get(lower) ?? null;
}
const codeMaps = buildLanguageMaps("programming", markdownExtensions);
const dataMaps = buildLanguageMaps("data");
const codeMaps = buildLanguageMaps(
["programming", "markup"],
markdownExtensions
);
const dataMaps = buildLanguageMaps(["data"]);
/**
* Returns the language name for a given file name, or null if it's not a
* recognised code file. Looks up by extension first, then by exact filename
* (e.g. "Dockerfile", "Makefile"). Runs in O(1).
* recognised code or markup file (programming + markup types from
* linguist-languages, e.g. Python, HTML, CSS, Vue). Looks up by extension
* first, then by exact filename (e.g. "Dockerfile", "Makefile"). Runs in O(1).
*/
export function getCodeLanguage(name: string): string | null {
return lookupLanguage(name, codeMaps);
@@ -86,3 +115,20 @@ export function isMarkdownFile(name: string): boolean {
const ext = name.toLowerCase().match(LANGUAGE_EXT_PATTERN)?.[0];
return !!ext && markdownExtensions.has(ext);
}
const mimeToLanguage = new Map<string, string>();
for (const lang of allLanguages) {
if (lang.codemirrorMimeType && !mimeToLanguage.has(lang.codemirrorMimeType)) {
mimeToLanguage.set(lang.codemirrorMimeType, lang.name.toLowerCase());
}
}
/**
* Returns the language name for a given MIME type using the codemirrorMimeType
* field from linguist-languages (~297 entries). Returns null if unrecognised.
*/
export function getLanguageByMime(mime: string): string | null {
const base = mime.split(";")[0];
if (!base) return null;
return mimeToLanguage.get(base.trim().toLowerCase()) ?? null;
}

View File

@@ -6,6 +6,8 @@ import type { IconProps } from "@opal/types";
export interface ChipProps {
children?: string;
icon?: React.FunctionComponent<IconProps>;
/** Icon rendered after the label (e.g. a warning indicator) */
rightIcon?: React.FunctionComponent<IconProps>;
onRemove?: () => void;
smallLabel?: boolean;
}
@@ -24,6 +26,7 @@ export interface ChipProps {
export default function Chip({
children,
icon: Icon,
rightIcon: RightIcon,
onRemove,
smallLabel = true,
}: ChipProps) {
@@ -35,6 +38,7 @@ export default function Chip({
{children}
</Text>
)}
{RightIcon && <RightIcon size={14} className="text-text-03" />}
{onRemove && (
<Button
onClick={(e) => {

View File

@@ -6,10 +6,42 @@ import { cn } from "@/lib/utils";
// Throttle interval for scroll events (~60fps)
const SCROLL_THROTTLE_MS = 16;
/**
* A scrollable container that shows gradient or shadow indicators when
* content overflows above or below the visible area.
*
* HEIGHT CONSTRAINT REQUIREMENT
*
* This component relies on its inner scroll container having a smaller
* clientHeight than its scrollHeight. For that to happen, the entire
* ancestor chain must constrain height via flex sizing (flex-1 min-h-0),
* NOT via percentage heights (h-full).
*
* height: 100% resolves to "auto" when the containing block's height is
* determined by flex layout (flex-auto, flex-1) rather than an explicit
* height property — this is per the CSS spec. When that happens, the
* container grows to fit its content and scrollHeight === clientHeight,
* making scroll indicators invisible.
*
* Correct pattern: every ancestor up to the nearest fixed-height boundary
* must form an unbroken flex column chain using "flex-1 min-h-0":
*
* fixed-height-ancestor (e.g. h-[500px])
* flex flex-col flex-1 min-h-0 <-- use flex-1, NOT h-full
* ScrollIndicatorDiv
* ...tall content...
*
* Common mistakes:
* - Using h-full instead of flex-1 min-h-0 anywhere in the chain.
* - Placing this inside a parent with overflow-y: auto (e.g. Modal.Body),
* which becomes the scroll container instead of this component's inner div.
*/
export interface ScrollIndicatorDivProps
extends React.HTMLAttributes<HTMLDivElement> {
// Mask/Shadow options
disableIndicators?: boolean;
disableTopIndicator?: boolean;
disableBottomIndicator?: boolean;
backgroundColor?: string;
indicatorHeight?: string;
@@ -22,6 +54,8 @@ export interface ScrollIndicatorDivProps
export default function ScrollIndicatorDiv({
disableIndicators = false,
disableTopIndicator = false,
disableBottomIndicator = false,
backgroundColor = "var(--background-tint-02)",
indicatorHeight = "3rem",
variant = "gradient",
@@ -77,13 +111,19 @@ export default function ScrollIndicatorDiv({
// Update on scroll (throttled)
container.addEventListener("scroll", handleScroll, { passive: true });
// Update on resize (in case content changes)
// Update when the container itself resizes
const resizeObserver = new ResizeObserver(updateScrollIndicators);
resizeObserver.observe(container);
// Update when descendants change (e.g. syntax highlighting mutates the
// DOM after initial render, which changes scrollHeight without firing
// resize or scroll events on the container).
const mutationObserver = new MutationObserver(handleScroll);
return () => {
container.removeEventListener("scroll", handleScroll);
resizeObserver.disconnect();
mutationObserver.disconnect();
if (throttleTimeoutRef.current) {
clearTimeout(throttleTimeoutRef.current);
}
@@ -120,7 +160,7 @@ export default function ScrollIndicatorDiv({
return (
<div className="relative flex-1 min-h-0 overflow-y-hidden flex flex-col w-full">
{/* Top indicator */}
{!disableIndicators && showTopIndicator && (
{!disableIndicators && !disableTopIndicator && showTopIndicator && (
<div
className="absolute top-0 left-0 right-0 z-[20] pointer-events-none transition-opacity duration-200"
style={getIndicatorStyle("top")}
@@ -141,7 +181,7 @@ export default function ScrollIndicatorDiv({
</div>
{/* Bottom indicator */}
{!disableIndicators && showBottomIndicator && (
{!disableIndicators && !disableBottomIndicator && showBottomIndicator && (
<div
className="absolute bottom-0 left-0 right-0 z-[20] pointer-events-none transition-opacity duration-200"
style={getIndicatorStyle("bottom")}

View File

@@ -9,11 +9,14 @@ import {
Variants,
wrapperClasses,
} from "@/refresh-components/inputs/styles";
import { SvgAlertTriangle } from "@opal/icons";
import type { IconProps } from "@opal/types";
export interface ChipItem {
id: string;
label: string;
/** When true the chip shows a warning icon */
error?: boolean;
}
export interface InputChipFieldProps {
@@ -29,6 +32,8 @@ export interface InputChipFieldProps {
variant?: Variants;
icon?: React.FunctionComponent<IconProps>;
className?: string;
/** "inline" renders chips and input in one row; "stacked" puts chips above the input */
layout?: "inline" | "stacked";
}
/**
@@ -61,6 +66,7 @@ function InputChipField({
variant = "primary",
icon: Icon,
className,
layout = "inline",
}: InputChipFieldProps) {
const inputRef = React.useRef<HTMLInputElement>(null);
@@ -85,25 +91,32 @@ function InputChipField({
}
}
return (
<div
className={cn(
"flex flex-row items-center flex-wrap gap-1 p-1.5 rounded-08 cursor-text w-full",
wrapperClasses[variant],
className
)}
onClick={() => inputRef.current?.focus()}
>
const chipElements =
chips.length > 0
? chips.map((chip) => (
<Chip
key={chip.id}
onRemove={disabled ? undefined : () => onRemoveChip(chip.id)}
rightIcon={
chip.error
? (props) => (
<SvgAlertTriangle
{...props}
className="text-status-warning-text"
/>
)
: undefined
}
smallLabel={layout === "stacked"}
>
{chip.label}
</Chip>
))
: null;
const inputElement = (
<>
{Icon && <Icon size={16} className="text-text-04 shrink-0" />}
{chips.map((chip) => (
<Chip
key={chip.id}
onRemove={disabled ? undefined : () => onRemoveChip(chip.id)}
smallLabel={false}
>
{chip.label}
</Chip>
))}
<input
ref={inputRef}
type="text"
@@ -118,6 +131,36 @@ function InputChipField({
textClasses[variant]
)}
/>
</>
);
return (
<div
className={cn(
"flex p-1.5 rounded-08 cursor-text w-full",
layout === "stacked"
? "flex-col gap-1"
: "flex-row flex-wrap items-center gap-1",
wrapperClasses[variant],
className
)}
onClick={() => inputRef.current?.focus()}
>
{layout === "stacked" ? (
<>
{chipElements && (
<div className="flex flex-row items-center flex-wrap gap-1">
{chipElements}
</div>
)}
<div className="flex flex-row items-center gap-1">{inputElement}</div>
</>
) : (
<>
{chipElements}
{inputElement}
</>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { cn, noProp } from "@/lib/utils";
import { cn } from "@/lib/utils";
import LineItem, { LineItemProps } from "@/refresh-components/buttons/LineItem";
import Text from "@/refresh-components/texts/Text";
import type { IconProps } from "@opal/types";
@@ -298,7 +298,10 @@ function InputSelectContent({
)}
sideOffset={4}
position="popper"
onMouseDown={noProp()}
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault();
}}
{...props}
>
<SelectPrimitive.Viewport className="flex flex-col gap-1">

View File

@@ -12,6 +12,7 @@ import type { StatusFilter } from "./UsersPage/interfaces";
import UsersSummary from "./UsersPage/UsersSummary";
import UsersTable from "./UsersPage/UsersTable";
import InviteUsersModal from "./UsersPage/InviteUsersModal";
// ---------------------------------------------------------------------------
// Users page content
@@ -63,19 +64,24 @@ function UsersContent() {
// ---------------------------------------------------------------------------
export default function UsersPage() {
const [inviteOpen, setInviteOpen] = useState(false);
return (
<SettingsLayouts.Root width="lg">
<SettingsLayouts.Header
title="Users & Requests"
icon={SvgUser}
rightChildren={
// TODO (ENG-3806): Wire up invite modal
<Button icon={SvgUserPlus}>Invite Users</Button>
<Button icon={SvgUserPlus} onClick={() => setInviteOpen(true)}>
Invite Users
</Button>
}
/>
<SettingsLayouts.Body>
<UsersContent />
</SettingsLayouts.Body>
<InviteUsersModal open={inviteOpen} onOpenChange={setInviteOpen} />
</SettingsLayouts.Root>
);
}

View File

@@ -0,0 +1,178 @@
"use client";
import { useState, useCallback } from "react";
import { Button } from "@opal/components";
import { SvgUsers } from "@opal/icons";
import { Disabled } from "@opal/core";
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
import InputChipField from "@/refresh-components/inputs/InputChipField";
import type { ChipItem } from "@/refresh-components/inputs/InputChipField";
import { toast } from "@/hooks/useToast";
import { inviteUsers } from "./svc";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface InviteUsersModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function InviteUsersModal({
open,
onOpenChange,
}: InviteUsersModalProps) {
const [chips, setChips] = useState<ChipItem[]>([]);
const [inputValue, setInputValue] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
/** Parse a comma-separated string into de-duped ChipItems */
function parseEmails(value: string, existing: ChipItem[]): ChipItem[] {
const entries = value
.split(",")
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
const newChips: ChipItem[] = [];
for (const email of entries) {
const alreadyAdded =
existing.some((c) => c.label === email) ||
newChips.some((c) => c.label === email);
if (!alreadyAdded) {
newChips.push({
id: email,
label: email,
error: !EMAIL_REGEX.test(email),
});
}
}
return newChips;
}
function addEmail(value: string) {
const newChips = parseEmails(value, chips);
if (newChips.length > 0) {
setChips((prev) => [...prev, ...newChips]);
}
setInputValue("");
}
function removeChip(id: string) {
setChips((prev) => prev.filter((c) => c.id !== id));
}
const handleClose = useCallback(() => {
onOpenChange(false);
// Reset state after close animation
setTimeout(() => {
setChips([]);
setInputValue("");
setIsSubmitting(false);
}, 200);
}, [onOpenChange]);
/** Intercept backdrop/ESC closes so state is always reset */
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) {
if (!isSubmitting) handleClose();
} else {
onOpenChange(next);
}
},
[handleClose, isSubmitting, onOpenChange]
);
async function handleInvite() {
// Flush any pending text in the input into chips synchronously
const pending = inputValue.trim();
const allChips = pending
? [...chips, ...parseEmails(pending, chips)]
: chips;
if (pending) {
setChips(allChips);
setInputValue("");
}
const validEmails = allChips.filter((c) => !c.error).map((c) => c.label);
if (validEmails.length === 0) {
toast.error("Please add at least one valid email address");
return;
}
setIsSubmitting(true);
try {
await inviteUsers(validEmails);
toast.success(
`Invited ${validEmails.length} user${validEmails.length > 1 ? "s" : ""}`
);
handleClose();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to invite users"
);
} finally {
setIsSubmitting(false);
}
}
return (
<Modal open={open} onOpenChange={handleOpenChange}>
<Modal.Content width="sm" height="fit">
<Modal.Header
icon={SvgUsers}
title="Invite Users"
onClose={isSubmitting ? undefined : handleClose}
/>
<Modal.Body>
<InputChipField
chips={chips}
onRemoveChip={removeChip}
onAdd={addEmail}
value={inputValue}
onChange={setInputValue}
placeholder="Add emails to invite, comma separated"
layout="stacked"
/>
</Modal.Body>
<Modal.Footer>
<BasicModalFooter
cancel={
<Disabled disabled={isSubmitting}>
<Button prominence="tertiary" onClick={handleClose}>
Cancel
</Button>
</Disabled>
}
submit={
<Disabled
disabled={
isSubmitting ||
chips.length === 0 ||
chips.every((c) => c.error)
}
>
<Button onClick={handleInvite}>Invite</Button>
</Disabled>
}
/>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}

View File

@@ -0,0 +1,133 @@
"use client";
import { useState, useRef } from "react";
import { UserRole, USER_ROLE_LABELS } from "@/lib/types";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { OpenButton } from "@opal/components";
import { Disabled } from "@opal/core";
import {
SvgCheck,
SvgGlobe,
SvgUser,
SvgSlack,
SvgUserManage,
} from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import Text from "@/refresh-components/texts/Text";
import Popover from "@/refresh-components/Popover";
import LineItem from "@/refresh-components/buttons/LineItem";
import { toast } from "@/hooks/useToast";
import { setUserRole } from "./svc";
import type { UserRow } from "./interfaces";
const ROLE_ICONS: Partial<Record<UserRole, IconFunctionComponent>> = {
[UserRole.ADMIN]: SvgUserManage,
[UserRole.GLOBAL_CURATOR]: SvgGlobe,
[UserRole.SLACK_USER]: SvgSlack,
};
const SELECTABLE_ROLES = [
UserRole.ADMIN,
UserRole.GLOBAL_CURATOR,
UserRole.BASIC,
] as const;
interface UserRoleCellProps {
user: UserRow;
onMutate: () => void;
}
export default function UserRoleCell({ user, onMutate }: UserRoleCellProps) {
const [isUpdating, setIsUpdating] = useState(false);
const [open, setOpen] = useState(false);
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const isUpdatingRef = useRef(false);
if (!user.role) {
return (
<Text as="span" secondaryBody text03>
</Text>
);
}
if (user.is_scim_synced) {
return (
<div className="flex items-center gap-1.5">
<Text as="span" mainUiBody text03>
{USER_ROLE_LABELS[user.role] ?? user.role}
</Text>
</div>
);
}
const applyRole = async (newRole: UserRole) => {
if (isUpdatingRef.current) return;
isUpdatingRef.current = true;
setIsUpdating(true);
try {
await setUserRole(user.email, newRole);
toast.success("Role updated");
onMutate();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to update role");
onMutate();
} finally {
setIsUpdating(false);
isUpdatingRef.current = false;
}
};
const handleSelect = (role: UserRole) => {
if (role === user.role) {
setOpen(false);
return;
}
setOpen(false);
void applyRole(role);
};
const currentIcon = ROLE_ICONS[user.role] ?? SvgUser;
return (
<Disabled disabled={isUpdating}>
<Popover open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<OpenButton
icon={currentIcon}
variant="select-tinted"
width="full"
justifyContent="between"
>
{USER_ROLE_LABELS[user.role]}
</OpenButton>
</Popover.Trigger>
<Popover.Content align="start">
<div className="flex flex-col gap-1 p-1 min-w-[160px]">
{SELECTABLE_ROLES.map((role) => {
if (
role === UserRole.GLOBAL_CURATOR &&
!isPaidEnterpriseFeaturesEnabled
) {
return null;
}
const isSelected = user.role === role;
const icon = ROLE_ICONS[role] ?? SvgUser;
return (
<LineItem
key={role}
icon={isSelected ? SvgCheck : icon}
selected={isSelected}
emphasized={isSelected}
onClick={() => handleSelect(role)}
>
{USER_ROLE_LABELS[role]}
</LineItem>
);
})}
</div>
</Popover.Content>
</Popover>
</Disabled>
);
}

View File

@@ -0,0 +1,210 @@
"use client";
import { useState } from "react";
import { Button } from "@opal/components";
import { SvgMoreHorizontal, SvgXCircle, SvgTrash, SvgCheck } from "@opal/icons";
import { Disabled } from "@opal/core";
import Popover from "@/refresh-components/Popover";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import Text from "@/refresh-components/texts/Text";
import { UserStatus } from "@/lib/types";
import { toast } from "@/hooks/useToast";
import { deactivateUser, activateUser, deleteUser } from "./svc";
import type { UserRow } from "./interfaces";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ModalType = "deactivate" | "activate" | "delete" | null;
interface UserRowActionsProps {
user: UserRow;
onMutate: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function UserRowActions({
user,
onMutate,
}: UserRowActionsProps) {
const [modal, setModal] = useState<ModalType>(null);
const [popoverOpen, setPopoverOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleAction(
action: () => Promise<void>,
successMessage: string
) {
setIsSubmitting(true);
try {
await action();
onMutate();
toast.success(successMessage);
setModal(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsSubmitting(false);
}
}
// Only show actions for accepted users (active or inactive).
// Invited/requested users have no row actions in this PR.
if (
user.status !== UserStatus.ACTIVE &&
user.status !== UserStatus.INACTIVE
) {
return null;
}
// SCIM-managed users cannot be modified from the UI — changes would be
// overwritten on the next IdP sync.
if (user.is_scim_synced) {
return null;
}
return (
<>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<Popover.Trigger asChild>
<Button prominence="tertiary" icon={SvgMoreHorizontal} />
</Popover.Trigger>
<Popover.Content align="end">
<div className="flex flex-col gap-0.5 p-1">
{user.status === UserStatus.ACTIVE ? (
<Button
prominence="tertiary"
icon={SvgXCircle}
onClick={() => {
setPopoverOpen(false);
setModal("deactivate");
}}
>
Deactivate User
</Button>
) : (
<>
<Button
prominence="tertiary"
icon={SvgCheck}
onClick={() => {
setPopoverOpen(false);
setModal("activate");
}}
>
Activate User
</Button>
<Button
prominence="tertiary"
variant="danger"
icon={SvgTrash}
onClick={() => {
setPopoverOpen(false);
setModal("delete");
}}
>
Delete User
</Button>
</>
)}
</div>
</Popover.Content>
</Popover>
{modal === "deactivate" && (
<ConfirmationModalLayout
icon={SvgXCircle}
title="Deactivate User"
onClose={isSubmitting ? undefined : () => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={async () => {
await handleAction(
() => deactivateUser(user.email),
"User deactivated"
);
}}
>
Deactivate
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
will immediately lose access to Onyx. Their sessions and agents will
be preserved. You can reactivate this account later.
</Text>
</ConfirmationModalLayout>
)}
{modal === "activate" && (
<ConfirmationModalLayout
icon={SvgCheck}
title="Activate User"
onClose={isSubmitting ? undefined : () => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<Button
onClick={async () => {
await handleAction(
() => activateUser(user.email),
"User activated"
);
}}
>
Activate
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
will regain access to Onyx.
</Text>
</ConfirmationModalLayout>
)}
{modal === "delete" && (
<ConfirmationModalLayout
icon={SvgTrash}
title="Delete User"
onClose={isSubmitting ? undefined : () => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={async () => {
await handleAction(
() => deleteUser(user.email),
"User deleted"
);
}}
>
Delete
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
will be permanently removed from Onyx. All of their session history
will be deleted. This cannot be undone.
</Text>
</ConfirmationModalLayout>
)}
</>
);
}

View File

@@ -4,23 +4,18 @@ import { useMemo, useState } from "react";
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
import { Content } from "@opal/layouts";
import { SvgUser, SvgUsers, SvgSlack } from "@opal/icons";
import SvgNoResult from "@opal/illustrations/no-result";
import { IllustrationContent } from "@opal/layouts";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import type { IconFunctionComponent } from "@opal/types";
import {
UserRole,
UserStatus,
USER_ROLE_LABELS,
USER_STATUS_LABELS,
} from "@/lib/types";
import { UserRole, UserStatus, USER_STATUS_LABELS } from "@/lib/types";
import { timeAgo } from "@/lib/time";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import useAdminUsers from "@/hooks/useAdminUsers";
import useGroups from "@/hooks/useGroups";
import UserFilters from "./UserFilters";
import UserRowActions from "./UserRowActions";
import UserRoleCell from "./UserRoleCell";
import type {
UserRow,
UserGroupInfo,
@@ -30,20 +25,6 @@ import type {
} from "./interfaces";
import { getInitials } from "./utils";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ROLE_ICONS: Record<UserRole, IconFunctionComponent> = {
[UserRole.BASIC]: SvgUser,
[UserRole.ADMIN]: SvgUser,
[UserRole.GLOBAL_CURATOR]: SvgUsers,
[UserRole.CURATOR]: SvgUsers,
[UserRole.LIMITED]: SvgUser,
[UserRole.EXT_PERM_USER]: SvgUser,
[UserRole.SLACK_USER]: SvgSlack,
};
// ---------------------------------------------------------------------------
// Column renderers
// ---------------------------------------------------------------------------
@@ -90,25 +71,6 @@ function renderGroupsColumn(groups: UserGroupInfo[]) {
);
}
function renderRoleColumn(role: UserRole | null) {
if (!role) {
return (
<Text as="span" secondaryBody text03>
</Text>
);
}
const Icon = ROLE_ICONS[role];
return (
<div className="flex items-center gap-1.5">
{Icon && <Icon size={14} className="text-text-03 shrink-0" />}
<Text as="span" mainUiBody text03>
{USER_ROLE_LABELS[role] ?? role}
</Text>
</div>
);
}
function renderStatusColumn(value: UserStatus, row: UserRow) {
return (
<div className="flex flex-col">
@@ -133,50 +95,54 @@ function renderLastUpdatedColumn(value: string | null) {
}
// ---------------------------------------------------------------------------
// Columns (stable reference — defined at module scope)
// Columns
// ---------------------------------------------------------------------------
const tc = createTableColumns<UserRow>();
const columns = [
tc.qualifier({
content: "avatar-user",
getInitials: (row) => getInitials(row.personal_name, row.email),
selectable: false,
}),
tc.column("email", {
header: "Name",
weight: 22,
minWidth: 140,
cell: renderNameColumn,
}),
tc.column("groups", {
header: "Groups",
weight: 24,
minWidth: 200,
enableSorting: false,
cell: renderGroupsColumn,
}),
tc.column("role", {
header: "Account Type",
weight: 16,
minWidth: 180,
cell: renderRoleColumn,
}),
tc.column("status", {
header: "Status",
weight: 14,
minWidth: 100,
cell: renderStatusColumn,
}),
tc.column("updated_at", {
header: "Last Updated",
weight: 14,
minWidth: 100,
cell: renderLastUpdatedColumn,
}),
tc.actions(),
];
function buildColumns(onMutate: () => void) {
return [
tc.qualifier({
content: "avatar-user",
getInitials: (row) => getInitials(row.personal_name, row.email),
selectable: false,
}),
tc.column("email", {
header: "Name",
weight: 22,
minWidth: 140,
cell: renderNameColumn,
}),
tc.column("groups", {
header: "Groups",
weight: 24,
minWidth: 200,
enableSorting: false,
cell: renderGroupsColumn,
}),
tc.column("role", {
header: "Account Type",
weight: 16,
minWidth: 180,
cell: (_value, row) => <UserRoleCell user={row} onMutate={onMutate} />,
}),
tc.column("status", {
header: "Status",
weight: 14,
minWidth: 100,
cell: renderStatusColumn,
}),
tc.column("updated_at", {
header: "Last Updated",
weight: 14,
minWidth: 100,
cell: renderLastUpdatedColumn,
}),
tc.actions({
cell: (row) => <UserRowActions user={row} onMutate={onMutate} />,
}),
];
}
// ---------------------------------------------------------------------------
// Component
@@ -213,7 +179,9 @@ export default function UsersTable({
[allGroups]
);
const { users, isLoading, error } = useAdminUsers();
const { users, isLoading, error, refresh } = useAdminUsers();
const columns = useMemo(() => buildColumns(refresh), [refresh]);
// Client-side filtering
const filteredUsers = useMemo(() => {

View File

@@ -0,0 +1,71 @@
import { UserRole } from "@/lib/types";
async function parseErrorDetail(
res: Response,
fallback: string
): Promise<string> {
try {
const body = await res.json();
return body?.detail ?? fallback;
} catch {
return fallback;
}
}
export async function deactivateUser(email: string): Promise<void> {
const res = await fetch("/api/manage/admin/deactivate-user", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_email: email }),
});
if (!res.ok) {
throw new Error(await parseErrorDetail(res, "Failed to deactivate user"));
}
}
export async function activateUser(email: string): Promise<void> {
const res = await fetch("/api/manage/admin/activate-user", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_email: email }),
});
if (!res.ok) {
throw new Error(await parseErrorDetail(res, "Failed to activate user"));
}
}
export async function deleteUser(email: string): Promise<void> {
const res = await fetch("/api/manage/admin/delete-user", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_email: email }),
});
if (!res.ok) {
throw new Error(await parseErrorDetail(res, "Failed to delete user"));
}
}
export async function setUserRole(
email: string,
newRole: UserRole
): Promise<void> {
const res = await fetch("/api/manage/set-user-role", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_email: email, new_role: newRole }),
});
if (!res.ok) {
throw new Error(await parseErrorDetail(res, "Failed to update user role"));
}
}
export async function inviteUsers(emails: string[]): Promise<void> {
const res = await fetch("/api/manage/admin/users", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ emails }),
});
if (!res.ok) {
throw new Error(await parseErrorDetail(res, "Failed to invite users"));
}
}

View File

@@ -7,13 +7,14 @@ import Text from "@/refresh-components/texts/Text";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { cn } from "@/lib/utils";
import { Section } from "@/layouts/general-layouts";
import { getCodeLanguage, getDataLanguage } from "@/lib/languages";
import mime from "mime";
import {
getCodeLanguage,
getDataLanguage,
getLanguageByMime,
} from "@/lib/languages";
import { fetchChatFile } from "@/lib/chat/svc";
import { PreviewContext } from "@/sections/modals/PreviewModal/interfaces";
import {
getMimeLanguage,
resolveMimeType,
} from "@/sections/modals/PreviewModal/mimeUtils";
import { resolveVariant } from "@/sections/modals/PreviewModal/variants";
interface PreviewModalProps {
@@ -41,7 +42,7 @@ export default function PreviewModal({
const language = useMemo(
() =>
getCodeLanguage(presentingDocument.semantic_identifier || "") ||
getMimeLanguage(mimeType) ||
getLanguageByMime(mimeType) ||
getDataLanguage(presentingDocument.semantic_identifier || "") ||
"plaintext",
[mimeType, presentingDocument.semantic_identifier]
@@ -86,7 +87,10 @@ export default function PreviewModal({
const rawContentType =
response.headers.get("Content-Type") || "application/octet-stream";
const resolvedMime = resolveMimeType(rawContentType, originalFileName);
const resolvedMime =
rawContentType === "application/octet-stream"
? mime.getType(originalFileName) ?? rawContentType
: rawContentType;
setMimeType(resolvedMime);
const resolved = resolveVariant(
@@ -166,24 +170,24 @@ export default function PreviewModal({
onClose={onClose}
/>
{/* Body + floating footer wrapper */}
<Modal.Body padding={0} gap={0}>
<Section padding={0} gap={0}>
{isLoading ? (
<Section>
<SimpleLoader className="h-8 w-8" />
</Section>
) : loadError ? (
<Section padding={1}>
<Text text03 mainUiBody>
{loadError}
</Text>
</Section>
) : (
variant.renderContent(ctx)
)}
</Section>
</Modal.Body>
{/* Body — uses flex-1/min-h-0/overflow-hidden (not Modal.Body)
so that child ScrollIndicatorDivs become the actual scroll
container instead of the body stealing it via overflow-y-auto. */}
<div className="flex flex-col flex-1 min-h-0 overflow-hidden w-full bg-background-tint-01">
{isLoading ? (
<Section>
<SimpleLoader className="h-8 w-8" />
</Section>
) : loadError ? (
<Section padding={1}>
<Text text03 mainUiBody>
{loadError}
</Text>
</Section>
) : (
variant.renderContent(ctx)
)}
</div>
{/* Floating footer */}
{!isLoading && !loadError && (
@@ -194,8 +198,9 @@ export default function PreviewModal({
"p-4 pointer-events-none w-full"
)}
style={{
background:
"linear-gradient(to top, var(--background-code-01) 40%, transparent)",
background: `linear-gradient(to top, var(--background-${
variant.codeBackground ? "code-01" : "tint-01"
}) 40%, transparent)`,
}}
>
{/* Left slot */}

View File

@@ -19,6 +19,8 @@ export interface PreviewVariant
matches: (semanticIdentifier: string | null, mimeType: string) => boolean;
/** Whether the fetcher should read the blob as text. */
needsTextContent: boolean;
/** Whether the variant renders on a code-style background (bg-background-code-01). */
codeBackground: boolean;
/** String shown below the title in the modal header. */
headerDescription: (ctx: PreviewContext) => string;
/** Body content. */

View File

@@ -1,50 +0,0 @@
const MIME_LANGUAGE_PREFIXES: Array<[prefix: string, language: string]> = [
["application/json", "json"],
["application/xml", "xml"],
["text/xml", "xml"],
["application/x-yaml", "yaml"],
["application/yaml", "yaml"],
["text/yaml", "yaml"],
["text/x-yaml", "yaml"],
];
const OCTET_STREAM_EXTENSION_TO_MIME: Record<string, string> = {
".md": "text/markdown",
".markdown": "text/markdown",
".txt": "text/plain",
".log": "text/plain",
".conf": "text/plain",
".sql": "text/plain",
".csv": "text/csv",
".tsv": "text/tab-separated-values",
".json": "application/json",
".xml": "application/xml",
".yml": "application/x-yaml",
".yaml": "application/x-yaml",
};
export function getMimeLanguage(mimeType: string): string | null {
return (
MIME_LANGUAGE_PREFIXES.find(([prefix]) =>
mimeType.startsWith(prefix)
)?.[1] ?? null
);
}
export function resolveMimeType(mimeType: string, fileName: string): string {
if (mimeType !== "application/octet-stream") {
return mimeType;
}
const lowerFileName = fileName.toLowerCase();
for (const [extension, resolvedMime] of Object.entries(
OCTET_STREAM_EXTENSION_TO_MIME
)) {
if (lowerFileName.endsWith(extension)) {
return resolvedMime;
}
}
return mimeType;
}

View File

@@ -1,22 +1,37 @@
"use client";
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import ScrollIndicatorDiv from "@/refresh-components/ScrollIndicatorDiv";
import { cn } from "@/lib/utils";
import "@/app/app/message/custom-code-styles.css";
interface CodePreviewProps {
content: string;
language?: string | null;
normalize?: boolean;
}
export function CodePreview({ content, language }: CodePreviewProps) {
const normalizedContent = content.replace(/~~~/g, "\\~\\~\\~");
const fenceHeader = language ? `~~~${language}` : "~~~";
export function CodePreview({
content,
language,
normalize,
}: CodePreviewProps) {
// Wrap raw content in a fenced code block for syntax highlighting. Uses ~~~
// instead of ``` to avoid conflicts with backticks in the content. Any literal
// ~~~ sequences in the content are escaped so they don't accidentally close the fence.
const markdownContent = normalize
? `~~~${language || ""}\n${content.replace(/~~~/g, "\\~\\~\\~")}\n~~~`
: content;
return (
<MinimalMarkdown
content={`${fenceHeader}\n${normalizedContent}\n\n~~~`}
className="w-full h-full"
showHeader={false}
/>
<ScrollIndicatorDiv
className={cn("p-4", normalize && "bg-background-code-01")}
backgroundColor={normalize ? "var(--background-code-01)" : undefined}
variant="shadow"
bottomSpacing="2rem"
disableBottomIndicator
>
<MinimalMarkdown content={markdownContent} showHeader={false} />
</ScrollIndicatorDiv>
);
}

View File

@@ -13,6 +13,7 @@ export const codeVariant: PreviewVariant = {
width: "md",
height: "lg",
needsTextContent: true,
codeBackground: true,
headerDescription: (ctx) =>
ctx.fileContent
@@ -22,7 +23,7 @@ export const codeVariant: PreviewVariant = {
: "",
renderContent: (ctx) => (
<CodePreview content={ctx.fileContent} language={ctx.language} />
<CodePreview normalize content={ctx.fileContent} language={ctx.language} />
),
renderFooterLeft: (ctx) => (

View File

@@ -34,6 +34,7 @@ export const csvVariant: PreviewVariant = {
width: "lg",
height: "full",
needsTextContent: true,
codeBackground: false,
headerDescription: (ctx) => {
if (!ctx.fileContent) return "";
const { rows } = parseCsv(ctx.fileContent);

View File

@@ -1,8 +1,7 @@
import Text from "@/refresh-components/texts/Text";
import { Section } from "@/layouts/general-layouts";
import { getDataLanguage } from "@/lib/languages";
import { getDataLanguage, getLanguageByMime } from "@/lib/languages";
import { PreviewVariant } from "@/sections/modals/PreviewModal/interfaces";
import { getMimeLanguage } from "@/sections/modals/PreviewModal/mimeUtils";
import { CodePreview } from "@/sections/modals/PreviewModal/variants/CodePreview";
import {
CopyButton,
@@ -22,10 +21,11 @@ function formatContent(language: string, content: string): string {
export const dataVariant: PreviewVariant = {
matches: (name, mime) =>
!!getDataLanguage(name || "") || !!getMimeLanguage(mime),
!!getDataLanguage(name || "") || !!getLanguageByMime(mime),
width: "md",
height: "lg",
needsTextContent: true,
codeBackground: true,
headerDescription: (ctx) =>
ctx.fileContent
@@ -36,7 +36,9 @@ export const dataVariant: PreviewVariant = {
renderContent: (ctx) => {
const formatted = formatContent(ctx.language, ctx.fileContent);
return <CodePreview content={formatted} language={ctx.language} />;
return (
<CodePreview normalize content={formatted} language={ctx.language} />
);
},
renderFooterLeft: (ctx) => (

View File

@@ -130,6 +130,7 @@ export const docxVariant: PreviewVariant = {
width: "lg",
height: "full",
needsTextContent: false,
codeBackground: false,
headerDescription: () => {
if (lastDocxResult) {
const count = lastDocxResult.wordCount;

View File

@@ -11,6 +11,7 @@ export const imageVariant: PreviewVariant = {
width: "lg",
height: "full",
needsTextContent: false,
codeBackground: false,
headerDescription: () => "",
renderContent: (ctx) => (

View File

@@ -15,10 +15,10 @@ const PREVIEW_VARIANTS: PreviewVariant[] = [
imageVariant,
pdfVariant,
csvVariant,
dataVariant,
textVariant,
markdownVariant,
docxVariant,
textVariant,
dataVariant,
];
export function resolveVariant(

View File

@@ -1,8 +1,7 @@
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import ScrollIndicatorDiv from "@/refresh-components/ScrollIndicatorDiv";
import { Section } from "@/layouts/general-layouts";
import { isMarkdownFile } from "@/lib/languages";
import { PreviewVariant } from "@/sections/modals/PreviewModal/interfaces";
import { CodePreview } from "@/sections/modals/PreviewModal/variants/CodePreview";
import {
CopyButton,
DownloadButton,
@@ -23,15 +22,11 @@ export const markdownVariant: PreviewVariant = {
width: "lg",
height: "full",
needsTextContent: true,
codeBackground: false,
headerDescription: () => "",
renderContent: (ctx) => (
<ScrollIndicatorDiv className="flex-1 min-h-0 p-4" variant="shadow">
<MinimalMarkdown
content={ctx.fileContent}
className="w-full pb-4 text-lg break-words"
/>
</ScrollIndicatorDiv>
<CodePreview content={ctx.fileContent} language={ctx.language} />
),
renderFooterLeft: () => null,

View File

@@ -7,6 +7,7 @@ export const pdfVariant: PreviewVariant = {
width: "lg",
height: "full",
needsTextContent: false,
codeBackground: false,
headerDescription: () => "",
renderContent: (ctx) => (

View File

@@ -28,6 +28,7 @@ export const textVariant: PreviewVariant = {
width: "md",
height: "lg",
needsTextContent: true,
codeBackground: true,
headerDescription: (ctx) =>
ctx.fileContent
? `${ctx.lineCount} ${ctx.lineCount === 1 ? "line" : "lines"} · ${
@@ -36,7 +37,7 @@ export const textVariant: PreviewVariant = {
: "",
renderContent: (ctx) => (
<CodePreview content={ctx.fileContent} language={ctx.language} />
<CodePreview normalize content={ctx.fileContent} language={ctx.language} />
),
renderFooterLeft: (ctx) => (

View File

@@ -5,13 +5,14 @@ import { DownloadButton } from "@/sections/modals/PreviewModal/variants/shared";
export const unsupportedVariant: PreviewVariant = {
matches: () => true,
width: "lg",
width: "md",
height: "full",
needsTextContent: false,
codeBackground: false,
headerDescription: () => "",
renderContent: (ctx) => (
<div className="flex flex-col items-center justify-center flex-1 min-h-0 gap-4 p-6">
<div className="flex flex-col items-center justify-center flex-1 w-full min-h-0 gap-4 p-6">
<Text as="p" text03 mainUiBody>
This file format is not supported for preview.
</Text>

View File

@@ -260,6 +260,7 @@ module.exports = {
"code-string": "var(--code-string)",
"code-number": "var(--code-number)",
"code-definition": "var(--code-definition)",
"background-code-01": "var(--background-code-01)",
// Shimmer colors for loading animations
"shimmer-base": "var(--shimmer-base)",