Compare commits

..

6 Commits

Author SHA1 Message Date
Nikolas Garza
2425bd4d8d feat(groups): add shared resources and token limit sections (#9538) 2026-03-24 23:44:44 +00:00
Raunak Bhagat
333b2b19cb refactor: fix sidebar layout (#9601)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-24 23:22:00 +00:00
Jamison Lahman
44895b3bd6 fix(ux): disable MCP Tools toggle if needs authenticated (#9607) 2026-03-24 22:45:23 +00:00
Raunak Bhagat
78c2ecf99f refactor(opal): restructure Onyx logo icons into composable parts (#9606)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-24 22:26:28 +00:00
Ciaran Sweet
e3e0e04edc fix: update values.yaml comment for opensearch admin password secretKeyRef (#9595) 2026-03-24 21:54:03 +00:00
Justin Tahara
a19fe03bd8 fix(ui): Text focused paste from PowerPoint (#9603) 2026-03-24 21:23:58 +00:00
36 changed files with 1365 additions and 139 deletions

View File

@@ -4,6 +4,7 @@ from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from ee.onyx.db.persona import update_persona_access
from ee.onyx.db.user_group import add_users_to_user_group
from ee.onyx.db.user_group import delete_user_group as db_delete_user_group
from ee.onyx.db.user_group import fetch_user_group
@@ -17,6 +18,7 @@ from ee.onyx.db.user_group import update_user_group
from ee.onyx.server.user_group.models import AddUsersToUserGroupRequest
from ee.onyx.server.user_group.models import MinimalUserGroupSnapshot
from ee.onyx.server.user_group.models import SetCuratorRequest
from ee.onyx.server.user_group.models import UpdateGroupAgentsRequest
from ee.onyx.server.user_group.models import UserGroup
from ee.onyx.server.user_group.models import UserGroupCreate
from ee.onyx.server.user_group.models import UserGroupRename
@@ -29,6 +31,7 @@ from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.db.models import UserRole
from onyx.db.persona import get_persona_by_id
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.utils.logger import setup_logger
@@ -191,3 +194,38 @@ def delete_user_group(
user_group = fetch_user_group(db_session, user_group_id)
if user_group:
db_delete_user_group(db_session, user_group)
@router.patch("/admin/user-group/{user_group_id}/agents")
def update_group_agents(
user_group_id: int,
request: UpdateGroupAgentsRequest,
user: User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> None:
for agent_id in request.added_agent_ids:
persona = get_persona_by_id(
persona_id=agent_id, user=user, db_session=db_session
)
current_group_ids = [g.id for g in persona.groups]
if user_group_id not in current_group_ids:
update_persona_access(
persona_id=agent_id,
creator_user_id=user.id,
db_session=db_session,
group_ids=current_group_ids + [user_group_id],
)
for agent_id in request.removed_agent_ids:
persona = get_persona_by_id(
persona_id=agent_id, user=user, db_session=db_session
)
current_group_ids = [g.id for g in persona.groups]
update_persona_access(
persona_id=agent_id,
creator_user_id=user.id,
db_session=db_session,
group_ids=[gid for gid in current_group_ids if gid != user_group_id],
)
db_session.commit()

View File

@@ -112,3 +112,8 @@ class UserGroupRename(BaseModel):
class SetCuratorRequest(BaseModel):
user_id: UUID
is_curator: bool
class UpdateGroupAgentsRequest(BaseModel):
added_agent_ids: list[int]
removed_agent_ids: list[int]

View File

@@ -103,7 +103,7 @@ opensearch:
- name: OPENSEARCH_INITIAL_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: onyx-opensearch # Must match auth.opensearch.secretName.
name: onyx-opensearch # Must match auth.opensearch.secretName or auth.opensearch.existingSecret if defined.
key: opensearch_admin_password # Must match auth.opensearch.secretKeys value.
resources:

View File

@@ -121,7 +121,9 @@ export { default as SvgNetworkGraph } from "@opal/icons/network-graph";
export { default as SvgNotificationBubble } from "@opal/icons/notification-bubble";
export { default as SvgOllama } from "@opal/icons/ollama";
export { default as SvgOnyxLogo } from "@opal/icons/onyx-logo";
export { default as SvgOnyxLogoTyped } from "@opal/icons/onyx-logo-typed";
export { default as SvgOnyxOctagon } from "@opal/icons/onyx-octagon";
export { default as SvgOnyxTyped } from "@opal/icons/onyx-typed";
export { default as SvgOpenai } from "@opal/icons/openai";
export { default as SvgOpenrouter } from "@opal/icons/openrouter";
export { default as SvgOrganization } from "@opal/icons/organization";

View File

@@ -0,0 +1,27 @@
import SvgOnyxLogo from "@opal/icons/onyx-logo";
import SvgOnyxTyped from "@opal/icons/onyx-typed";
import { cn } from "@opal/utils";
interface OnyxLogoTypedProps {
size?: number;
className?: string;
}
// # NOTE(@raunakab):
// This ratio is not some random, magical number; it is available on Figma.
const HEIGHT_TO_GAP_RATIO = 5 / 16;
const SvgOnyxLogoTyped = ({ size: height, className }: OnyxLogoTypedProps) => {
const gap = height != null ? height * HEIGHT_TO_GAP_RATIO : undefined;
return (
<div
className={cn(`flex flex-row items-center`, className)}
style={{ gap }}
>
<SvgOnyxLogo size={height} />
<SvgOnyxTyped size={height} />
</div>
);
};
export default SvgOnyxLogoTyped;

View File

@@ -1,19 +1,27 @@
import type { IconProps } from "@opal/types";
const SvgOnyxLogo = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 56 56"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M28 0 10.869 7.77 28 15.539l17.131-7.77L28 0Zm0 40.461-17.131 7.77L28 56l17.131-7.77L28 40.461Zm20.231-29.592L56 28.001l-7.769 17.131L40.462 28l7.769-17.131ZM15.538 28 7.77 10.869 0 28l7.769 17.131L15.538 28Z"
fill="currentColor"
d="M10.4014 13.25L18.875 32L10.3852 50.75L2 32L10.4014 13.25Z"
fill="var(--theme-primary-05)"
/>
<path
d="M53.5264 13.25L62 32L53.5102 50.75L45.125 32L53.5264 13.25Z"
fill="var(--theme-primary-05)"
/>
<path
d="M32 45.125L50.75 53.5625L32 62L13.25 53.5625L32 45.125Z"
fill="var(--theme-primary-05)"
/>
<path
d="M32 2L50.75 10.4375L32 18.875L13.25 10.4375L32 2Z"
fill="var(--theme-primary-05)"
/>
</svg>
);

View File

@@ -0,0 +1,28 @@
import type { IconProps } from "@opal/types";
const SvgOnyxTyped = ({ size, ...props }: IconProps) => (
<svg
height={size}
viewBox="0 0 152 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M19.1795 51.2136C15.6695 51.2136 12.4353 50.3862 9.47691 48.7315C6.56865 47.0768 4.2621 44.8454 2.55726 42.0374C0.85242 39.1793 0 36.0955 0 32.7861C0 30.279 0.451281 27.9223 1.35384 25.716C2.30655 23.4596 3.76068 21.3285 5.71623 19.3228L11.8085 13.08C12.4604 12.6789 13.4131 12.3529 14.6666 12.1022C15.9202 11.8014 17.2991 11.6509 18.8034 11.6509C22.3134 11.6509 25.5225 12.4783 28.4307 14.133C31.3891 15.7877 33.7208 18.0441 35.4256 20.9023C37.1304 23.7103 37.9829 26.794 37.9829 30.1536C37.9829 32.6106 37.5065 34.9673 36.5538 37.2237C35.6512 39.4802 34.147 41.6864 32.041 43.8426L26.3248 49.7845C25.3219 50.2358 24.2188 50.5868 23.0154 50.8375C21.8621 51.0882 20.5835 51.2136 19.1795 51.2136ZM20.1572 43.8426C21.8621 43.8426 23.4917 43.4164 25.0461 42.5639C26.6005 41.6614 27.8541 40.3577 28.8068 38.6528C29.8097 36.948 30.3111 34.9172 30.3111 32.5605C30.3111 30.0032 29.6843 27.6966 28.4307 25.6408C27.2273 23.5849 25.6478 21.9803 23.6923 20.8271C21.7869 19.6236 19.8313 19.0219 17.8256 19.0219C16.0706 19.0219 14.4159 19.4732 12.8615 20.3758C11.3573 21.2282 10.1288 22.5068 9.17606 24.2117C8.22335 25.9166 7.747 27.9473 7.747 30.304C7.747 32.8613 8.34871 35.1679 9.55212 37.2237C10.7555 39.2796 12.31 40.9092 14.2154 42.1127C16.1709 43.2659 18.1515 43.8426 20.1572 43.8426Z"
fill="var(--theme-primary-05)"
/>
<path
d="M42.6413 50.4614V12.4031H50.6891V17.7433L55.5028 12.7039C56.0544 12.4532 56.8065 12.2276 57.7592 12.027C58.7621 11.7763 59.8903 11.6509 61.1438 11.6509C64.0521 11.6509 66.5843 12.3028 68.7404 13.6065C70.9467 14.8601 72.6264 16.6401 73.7797 18.9467C74.9831 21.2533 75.5848 23.961 75.5848 27.0698V50.4614H67.6122V29.1006C67.6122 26.9946 67.2612 25.1895 66.5592 23.6852C65.9074 22.1308 64.9547 20.9775 63.7011 20.2253C62.4977 19.4231 61.0686 19.0219 59.4139 19.0219C56.7564 19.0219 54.6253 19.9245 53.0208 21.7296C51.4663 23.4846 50.6891 25.9416 50.6891 29.1006V50.4614H42.6413Z"
fill="var(--theme-primary-05)"
/>
<path
d="M82.3035 64V56.0273H89.9753C91.2288 56.0273 92.2066 55.7264 92.9086 55.1247C93.6607 54.523 94.2625 53.5452 94.7137 52.1913L108.027 12.4031H116.751L103.664 49.4084C103.062 51.1634 102.461 52.5173 101.859 53.47C101.307 54.4227 100.53 55.4506 99.5274 56.5538L92.4573 64H82.3035ZM90.7274 46.6255L76.9633 12.4031H85.989L99.4522 46.6255H90.7274Z"
fill="var(--theme-primary-05)"
/>
<path
d="M115.657 50.4614L129.045 31.2066L116.033 12.4031H125.435L134.085 24.8134L142.358 12.4031H151.308L138.372 31.0562L151.684 50.4614H142.358L133.332 37.3742L124.683 50.4614H115.657Z"
fill="var(--theme-primary-05)"
/>
</svg>
);
export default SvgOnyxTyped;

View File

@@ -395,7 +395,7 @@ function SeatsCard({
<InputLayouts.Vertical title="Seats">
<InputNumber
value={newSeatCount}
onChange={setNewSeatCount}
onChange={(v) => setNewSeatCount(v ?? 1)}
min={1}
defaultValue={totalSeats}
showReset

View File

@@ -230,7 +230,7 @@ export default function CheckoutView({ onAdjustPlan }: CheckoutViewProps) {
>
<InputNumber
value={seats}
onChange={setSeats}
onChange={(v) => setSeats(v ?? minRequiredSeats)}
min={minRequiredSeats}
defaultValue={minRequiredSeats}
showReset

View File

@@ -260,7 +260,7 @@ export default function VoiceProviderSetupModal({
<SvgArrowExchange className="size-3 text-text-04" />
</div>
<div className="flex items-center justify-center size-7 p-0.5 shrink-0 overflow-clip">
<SvgOnyxLogo size={24} className="text-text-04 shrink-0" />
<SvgOnyxLogo size={24} className="shrink-0" />
</div>
</div>
);

View File

@@ -69,7 +69,7 @@ export const WebProviderSetupModal = memo(
<SvgArrowExchange className="size-3 text-text-04" />
</div>
<div className="flex items-center justify-center size-7 p-0.5 shrink-0 overflow-clip">
<SvgOnyxLogo size={24} className="text-text-04 shrink-0" />
<SvgOnyxLogo size={24} className="shrink-0" />
</div>
</div>
);

View File

@@ -1372,7 +1372,7 @@ export default function Page() {
} logo`,
fallback:
selectedContentProviderType === "onyx_web_crawler" ? (
<SvgOnyxLogo size={24} className="text-text-05" />
<SvgOnyxLogo size={24} />
) : undefined,
size: 24,
containerSize: 28,

View File

@@ -13,6 +13,7 @@ import {
type KeyboardEvent,
} from "react";
import { useRouter } from "next/navigation";
import { getPastedFilesIfNoText } from "@/lib/clipboard";
import { cn, isImageFile } from "@/lib/utils";
import { Disabled } from "@opal/core";
import {
@@ -230,21 +231,11 @@ const InputBar = memo(
const handlePaste = useCallback(
(event: ClipboardEvent) => {
const items = event.clipboardData?.items;
if (items) {
const pastedFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item && item.kind === "file") {
const file = item.getAsFile();
if (file) pastedFiles.push(file);
}
}
if (pastedFiles.length > 0) {
event.preventDefault();
// Context handles session binding internally
uploadFiles(pastedFiles);
}
const pastedFiles = getPastedFilesIfNoText(event.clipboardData);
if (pastedFiles.length > 0) {
event.preventDefault();
// Context handles session binding internally
uploadFiles(pastedFiles);
}
},
[uploadFiles]

View File

@@ -413,7 +413,7 @@ const MemoizedBuildSidebarInner = memo(
return (
<SidebarWrapper folded={folded} onFoldClick={onFoldClick}>
<SidebarBody
actionButtons={
pinnedContent={
<div className="flex flex-col gap-0.5">
{newBuildButton}
{buildConfigurePanel}

View File

@@ -2,7 +2,9 @@
/* Base layers */
--z-base: 0;
--z-content: 1;
--z-settings-header: 8;
/* Settings header must sit above sticky table headers (--z-sticky: 10) so
the page header scrolls over pinned columns without being obscured. */
--z-settings-header: 11;
--z-app-layout: 9;
--z-sticky: 10;

View File

@@ -0,0 +1,89 @@
import { getPastedFilesIfNoText } from "./clipboard";
type MockClipboardData = Parameters<typeof getPastedFilesIfNoText>[0];
function makeClipboardData({
textPlain = "",
text = "",
files = [],
}: {
textPlain?: string;
text?: string;
files?: File[];
}): MockClipboardData {
return {
items: files.map((file) => ({
kind: "file",
getAsFile: () => file,
})),
getData: (format: string) => {
if (format === "text/plain") {
return textPlain;
}
if (format === "text") {
return text;
}
return "";
},
};
}
describe("getPastedFilesIfNoText", () => {
it("prefers plain text over pasted files when both are present", () => {
const imageFile = new File(["slide preview"], "slide.png", {
type: "image/png",
});
expect(
getPastedFilesIfNoText(
makeClipboardData({
textPlain: "Welcome to PowerPoint for Mac",
files: [imageFile],
})
)
).toEqual([]);
});
it("falls back to text data when text/plain is empty", () => {
const imageFile = new File(["slide preview"], "slide.png", {
type: "image/png",
});
expect(
getPastedFilesIfNoText(
makeClipboardData({
text: "Welcome to PowerPoint for Mac",
files: [imageFile],
})
)
).toEqual([]);
});
it("still returns files for image-only pastes", () => {
const imageFile = new File(["slide preview"], "slide.png", {
type: "image/png",
});
expect(
getPastedFilesIfNoText(makeClipboardData({ files: [imageFile] }))
).toEqual([imageFile]);
});
it("ignores whitespace-only text and keeps file pastes working", () => {
const imageFile = new File(["slide preview"], "slide.png", {
type: "image/png",
});
expect(
getPastedFilesIfNoText(
makeClipboardData({
textPlain: " ",
text: "\n",
files: [imageFile],
})
)
).toEqual([imageFile]);
});
});

52
web/src/lib/clipboard.ts Normal file
View File

@@ -0,0 +1,52 @@
type ClipboardFileItem = {
kind: string;
getAsFile: () => File | null;
};
type ClipboardDataLike = {
items?: ArrayLike<ClipboardFileItem> | null;
getData: (format: string) => string;
};
function getClipboardText(
clipboardData: ClipboardDataLike,
format: "text/plain" | "text"
): string {
try {
return clipboardData.getData(format);
} catch {
return "";
}
}
export function getPastedFilesIfNoText(
clipboardData?: ClipboardDataLike | null
): File[] {
if (!clipboardData) {
return [];
}
const plainText = getClipboardText(clipboardData, "text/plain").trim();
const fallbackText = getClipboardText(clipboardData, "text").trim();
// Apps like PowerPoint on macOS can place both rendered image data and the
// original text on the clipboard. Prefer letting the textarea consume text.
if (plainText || fallbackText || !clipboardData.items) {
return [];
}
const pastedFiles: File[] = [];
for (let i = 0; i < clipboardData.items.length; i++) {
const item = clipboardData.items[i];
if (item?.kind !== "file") {
continue;
}
const file = item.getAsFile();
if (file) {
pastedFiles.push(file);
}
}
return pastedFiles;
}

View File

@@ -127,8 +127,7 @@ export const DESKTOP_SMALL_BREAKPOINT_PX = 912;
export const DESKTOP_MEDIUM_BREAKPOINT_PX = 1232;
export const DEFAULT_AVATAR_SIZE_PX = 18;
export const HORIZON_DISTANCE_PX = 800;
export const LOGO_FOLDED_SIZE_PX = 24;
export const LOGO_UNFOLDED_SIZE_PX = 88;
export const DEFAULT_LOGO_SIZE_PX = 24;
export const DEFAULT_CONTEXT_TOKENS = 120_000;
export const MAX_CHUNKS_FED_TO_CHAT = 25;

View File

@@ -1,16 +1,15 @@
"use client";
import { OnyxIcon, OnyxLogoTypeIcon } from "@/components/icons/icons";
import { useSettingsContext } from "@/providers/SettingsProvider";
import {
LOGO_FOLDED_SIZE_PX,
LOGO_UNFOLDED_SIZE_PX,
DEFAULT_LOGO_SIZE_PX,
NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED,
} from "@/lib/constants";
import { cn } from "@/lib/utils";
import Text from "@/refresh-components/texts/Text";
import Truncated from "@/refresh-components/texts/Truncated";
import { useMemo } from "react";
import { SvgOnyxLogo, SvgOnyxLogoTyped } from "@opal/icons";
export interface LogoProps {
folded?: boolean;
@@ -19,8 +18,7 @@ export interface LogoProps {
}
export default function Logo({ folded, size, className }: LogoProps) {
const foldedSize = size ?? LOGO_FOLDED_SIZE_PX;
const unfoldedSize = size ?? LOGO_UNFOLDED_SIZE_PX;
const resolvedSize = size ?? DEFAULT_LOGO_SIZE_PX;
const settings = useSettingsContext();
const logoDisplayStyle = settings.enterpriseSettings?.logo_display_style;
const applicationName = settings.enterpriseSettings?.application_name;
@@ -42,7 +40,7 @@ export default function Logo({ folded, size, className }: LogoProps) {
"aspect-square rounded-full overflow-hidden relative flex-shrink-0",
className
)}
style={{ height: foldedSize, width: foldedSize }}
style={{ height: resolvedSize }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -52,7 +50,10 @@ export default function Logo({ folded, size, className }: LogoProps) {
/>
</div>
) : (
<OnyxIcon size={foldedSize} className={cn("flex-shrink-0", className)} />
<SvgOnyxLogo
size={resolvedSize}
className={cn("flex-shrink-0", className)}
/>
);
const renderNameAndPoweredBy = (opts: {
@@ -98,8 +99,11 @@ export default function Logo({ folded, size, className }: LogoProps) {
return applicationName ? (
renderNameAndPoweredBy({ includeLogo: true, includeName: true })
) : folded ? (
<OnyxIcon size={foldedSize} className={cn("flex-shrink-0", className)} />
<SvgOnyxLogo
size={resolvedSize}
className={cn("flex-shrink-0", className)}
/>
) : (
<OnyxLogoTypeIcon size={unfoldedSize} className={className} />
<SvgOnyxLogoTyped size={resolvedSize} className={className} />
);
}

View File

@@ -142,7 +142,6 @@ function PopoverContent({
collisionPadding={8}
className={cn(
"bg-background-neutral-00 p-1 z-popover rounded-12 border shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"flex flex-col",
"max-h-[var(--radix-popover-content-available-height)]",
"overflow-hidden",
widthClasses[width]
@@ -227,7 +226,7 @@ export function PopoverMenu({
});
return (
<Section alignItems="stretch" height="auto" className="flex-1 min-h-0">
<Section alignItems="stretch">
<ShadowDiv
scrollContainerRef={scrollContainerRef}
className="flex flex-col gap-1 max-h-[20rem] w-full"

View File

@@ -105,7 +105,7 @@ export default function ShadowDiv({
}, [containerRef, checkScroll]);
return (
<div className="relative min-h-0 flex flex-col">
<div className="relative min-h-0">
<div
ref={containerRef}
className={cn("overflow-y-auto", className)}

View File

@@ -44,8 +44,8 @@ import { SvgChevronUp, SvgChevronDown, SvgRevert } from "@opal/icons";
* ```
*/
export interface InputNumberProps {
value: number;
onChange: (value: number) => void;
value: number | null;
onChange: (value: number | null) => void;
min?: number;
max?: number;
step?: number;
@@ -54,6 +54,7 @@ export interface InputNumberProps {
variant?: Variants;
disabled?: boolean;
className?: string;
placeholder?: string;
}
export default function InputNumber({
@@ -67,31 +68,36 @@ export default function InputNumber({
variant = "primary",
disabled = false,
className,
placeholder,
}: InputNumberProps) {
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [inputValue, setInputValue] = React.useState(String(value));
const [inputValue, setInputValue] = React.useState(
value === null ? "" : String(value)
);
const isDisabled = disabled || variant === "disabled";
// Sync input value when external value changes (e.g., from stepper buttons or reset)
React.useEffect(() => {
setInputValue(String(value));
setInputValue(value === null ? "" : String(value));
}, [value]);
const canIncrement = max === undefined || value < max;
const canDecrement = min === undefined || value > min;
const effectiveValue = value ?? 0;
const canIncrement = max === undefined || effectiveValue < max;
const canDecrement =
value !== null && (min === undefined || effectiveValue > min);
const canReset =
showReset && defaultValue !== undefined && value !== defaultValue;
const handleIncrement = () => {
if (canIncrement) {
const newValue = value + step;
const newValue = effectiveValue + step;
onChange(max !== undefined ? Math.min(newValue, max) : newValue);
}
};
const handleDecrement = () => {
if (canDecrement) {
const newValue = value - step;
const newValue = effectiveValue - step;
onChange(min !== undefined ? Math.max(newValue, min) : newValue);
}
};
@@ -103,14 +109,11 @@ export default function InputNumber({
};
const handleBlur = () => {
// On blur, if empty, set fallback value; otherwise sync display with actual value
// On blur, if empty, keep as null so placeholder shows
if (inputValue.trim() === "") {
let fallback = min ?? 0;
if (max !== undefined) fallback = Math.min(fallback, max);
setInputValue(String(fallback));
onChange(fallback);
onChange(null);
} else {
setInputValue(String(value));
setInputValue(value === null ? "" : String(value));
}
};
@@ -152,6 +155,7 @@ export default function InputNumber({
pattern="[0-9]*"
disabled={isDisabled}
value={inputValue}
placeholder={placeholder}
onChange={handleInputChange}
onBlur={handleBlur}
className={cn(

View File

@@ -114,6 +114,10 @@ function MCPServerCard({
const allToolIds = tools.map((t) => t.id);
const serverEnabled =
tools.length > 0 && tools.some((t) => isToolEnabled(t.id));
const needsAuth = !server.is_authenticated;
const authTooltip = needsAuth
? "Authenticate this MCP server before enabling its tools."
: undefined;
return (
<ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
@@ -122,10 +126,13 @@ function MCPServerCard({
description={server.description}
icon={getActionIcon(server.server_url, server.name)}
rightChildren={
<Switch
checked={serverEnabled}
onCheckedChange={(checked) => onToggleTools(allToolIds, checked)}
/>
<SimpleTooltip tooltip={authTooltip} side="top">
<Switch
checked={serverEnabled}
onCheckedChange={(checked) => onToggleTools(allToolIds, checked)}
disabled={needsAuth}
/>
</SimpleTooltip>
}
>
{tools.length > 0 && (
@@ -158,12 +165,15 @@ function MCPServerCard({
description={tool.description}
icon={tool.icon}
rightChildren={
<Switch
checked={isToolEnabled(tool.id)}
onCheckedChange={(checked) =>
onToggleTool(tool.id, checked)
}
/>
<SimpleTooltip tooltip={authTooltip} side="top">
<Switch
checked={isToolEnabled(tool.id)}
onCheckedChange={(checked) =>
onToggleTool(tool.id, checked)
}
disabled={needsAuth}
/>
</SimpleTooltip>
}
/>
))}

View File

@@ -19,6 +19,9 @@ import useAdminUsers from "@/hooks/useAdminUsers";
import type { ApiKeyDescriptor, MemberRow } from "./interfaces";
import { createGroup } from "./svc";
import { apiKeyToMemberRow, memberTableColumns, PAGE_SIZE } from "./shared";
import SharedGroupResources from "@/refresh-pages/admin/GroupsPage/SharedGroupResources";
import TokenLimitSection from "./TokenLimitSection";
import type { TokenLimit } from "./TokenLimitSection";
function CreateGroupPage() {
const router = useRouter();
@@ -26,6 +29,12 @@ function CreateGroupPage() {
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedCcPairIds, setSelectedCcPairIds] = useState<number[]>([]);
const [selectedDocSetIds, setSelectedDocSetIds] = useState<number[]>([]);
const [selectedAgentIds, setSelectedAgentIds] = useState<number[]>([]);
const [tokenLimits, setTokenLimits] = useState<TokenLimit[]>([
{ tokenBudget: null, periodHours: null },
]);
const { users, isLoading: usersLoading, error: usersError } = useAdminUsers();
@@ -53,7 +62,7 @@ function CreateGroupPage() {
setIsSubmitting(true);
try {
await createGroup(trimmed, selectedUserIds);
await createGroup(trimmed, selectedUserIds, selectedCcPairIds);
toast.success(`Group "${trimmed}" created`);
router.push("/admin/groups");
} catch (e) {
@@ -81,7 +90,7 @@ function CreateGroupPage() {
);
return (
<SettingsLayouts.Root width="lg">
<SettingsLayouts.Root width="sm">
<SettingsLayouts.Header
icon={SvgUsers}
title="Create Group"
@@ -150,6 +159,19 @@ function CreateGroupPage() {
/>
</Section>
)}
<SharedGroupResources
selectedCcPairIds={selectedCcPairIds}
onCcPairIdsChange={setSelectedCcPairIds}
selectedDocSetIds={selectedDocSetIds}
onDocSetIdsChange={setSelectedDocSetIds}
selectedAgentIds={selectedAgentIds}
onAgentIdsChange={setSelectedAgentIds}
/>
<TokenLimitSection
limits={tokenLimits}
onLimitsChange={setTokenLimits}
/>
</SettingsLayouts.Body>
</SettingsLayouts.Root>
);

View File

@@ -0,0 +1,58 @@
"use client";
import type { ReactNode } from "react";
import { SvgX } from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import { Content } from "@opal/layouts";
import IconButton from "@/refresh-components/buttons/IconButton";
interface ResourceContentProps {
/** SVG icon for connectors/doc sets. */
icon?: IconFunctionComponent;
/** Custom ReactNode icon (e.g. AgentAvatar). Takes priority over `icon`. */
leftContent?: ReactNode;
title: string;
description?: string;
/** Inline info rendered after description (e.g. source icon stack). */
infoContent?: ReactNode;
onRemove: () => void;
}
function ResourceContent({
icon,
leftContent,
title,
description,
infoContent,
onRemove,
}: ResourceContentProps) {
return (
<div className="flex flex-1 gap-0.5 items-start p-1.5 rounded-08 bg-background-tint-01 min-w-[240px] max-w-[302px]">
<div className="flex flex-1 gap-1 p-0.5 items-start min-w-0">
{leftContent ? (
<>
{leftContent}
<Content
title={title}
description={description}
sizePreset="main-ui"
variant="section"
/>
</>
) : (
<Content
icon={icon}
title={title}
description={description}
sizePreset="main-ui"
variant="section"
/>
)}
{infoContent}
</div>
<IconButton small icon={SvgX} onClick={onRemove} className="shrink-0" />
</div>
);
}
export default ResourceContent;

View File

@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { SvgEmpty } from "@opal/icons";
import { Content } from "@opal/layouts";
import { Section } from "@/layouts/general-layouts";
import Popover from "@/refresh-components/Popover";
import Separator from "@/refresh-components/Separator";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import type { ResourcePopoverProps } from "@/refresh-pages/admin/GroupsPage/SharedGroupResources/interfaces";
function ResourcePopover({
placeholder,
searchValue,
onSearchChange,
sections,
}: ResourcePopoverProps) {
const [open, setOpen] = useState(false);
const totalItems = sections.reduce((sum, s) => sum + s.items.length, 0);
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover.Trigger
onClick={(e) => {
e.preventDefault();
}}
>
<InputTypeIn
placeholder={placeholder}
value={searchValue}
onChange={(e) => {
onSearchChange(e.target.value);
if (!open) setOpen(true);
}}
onFocus={() => setOpen(true)}
/>
</Popover.Trigger>
<Popover.Content
width="trigger"
align="start"
sideOffset={4}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto">
{totalItems === 0 ? (
<div className="px-3 py-3">
<Content
icon={SvgEmpty}
title="No results found"
sizePreset="secondary"
variant="section"
/>
</div>
) : (
sections.map(
(section, idx) =>
section.items.length > 0 && (
<div key={section.label ?? `section-${idx}`}>
{section.label && (
<Section
flexDirection="row"
gap={0.25}
padding={0}
height="auto"
alignItems="center"
justifyContent="start"
className="px-2 pt-2 pb-1"
>
<Text secondaryBody text03 className="shrink-0">
{section.label}
</Text>
<Separator noPadding className="flex-1" />
</Section>
)}
<Section
gap={0.25}
alignItems="stretch"
justifyContent="start"
>
{section.items.map((item) => (
<div
key={item.key}
className={cn(
"rounded-08 cursor-pointer",
item.disabled
? "bg-background-tint-02"
: "hover:bg-background-tint-02 transition-colors"
)}
onClick={() => {
item.onSelect();
}}
>
{item.render(!!item.disabled)}
</div>
))}
</Section>
</div>
)
)
)}
</div>
</Popover.Content>
</Popover>
);
}
export default ResourcePopover;

View File

@@ -0,0 +1,403 @@
"use client";
import { useState, useMemo } from "react";
import { SvgEmpty, SvgFiles, SvgXOctagon } from "@opal/icons";
import { Content } from "@opal/layouts";
import { Section } from "@/layouts/general-layouts";
import Card from "@/refresh-components/cards/Card";
import LineItem from "@/refresh-components/buttons/LineItem";
import Text from "@/refresh-components/texts/Text";
import Separator from "@/refresh-components/Separator";
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
import AgentAvatar from "@/refresh-components/avatars/AgentAvatar";
import { useConnectorStatus } from "@/lib/hooks";
import { useDocumentSets } from "@/lib/hooks/useDocumentSets";
import { useAgents } from "@/hooks/useAgents";
import { getSourceMetadata } from "@/lib/sources";
import type { ValidSources } from "@/lib/types";
import ResourceContent from "@/refresh-pages/admin/GroupsPage/SharedGroupResources/ResourceContent";
import ResourcePopover from "@/refresh-pages/admin/GroupsPage/SharedGroupResources/ResourcePopover";
import type { PopoverSection } from "@/refresh-pages/admin/GroupsPage/SharedGroupResources/interfaces";
interface SharedGroupResourcesProps {
selectedCcPairIds: number[];
onCcPairIdsChange: (ids: number[]) => void;
selectedDocSetIds: number[];
onDocSetIdsChange: (ids: number[]) => void;
selectedAgentIds: number[];
onAgentIdsChange: (ids: number[]) => void;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function SharedBadge() {
return (
<Text as="span" secondaryBody text03>
Shared
</Text>
);
}
function SourceIconStack({ sources }: { sources: { source: ValidSources }[] }) {
if (sources.length === 0) return null;
const unique = Array.from(
new Map(sources.map((s) => [s.source, s])).values()
).slice(0, 3);
return (
<Section
flexDirection="row"
alignItems="center"
height="auto"
gap={0}
className="shrink-0 px-0.5"
>
{unique.map((s, i) => {
const Icon = getSourceMetadata(s.source).icon;
return (
<div
key={s.source}
className="flex items-center justify-center size-4 rounded-04 bg-background-tint-00 border border-border-01 overflow-hidden"
style={{ zIndex: unique.length - i, marginLeft: i > 0 ? -6 : 0 }}
>
<Icon />
</div>
);
})}
</Section>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
function SharedGroupResources({
selectedCcPairIds,
onCcPairIdsChange,
selectedDocSetIds,
onDocSetIdsChange,
selectedAgentIds,
onAgentIdsChange,
}: SharedGroupResourcesProps) {
const [connectorSearch, setConnectorSearch] = useState("");
const [agentSearch, setAgentSearch] = useState("");
const { data: connectors = [] } = useConnectorStatus();
const { documentSets } = useDocumentSets();
const { agents } = useAgents();
// --- Derived data ---
const selectedCcPairSet = useMemo(
() => new Set(selectedCcPairIds),
[selectedCcPairIds]
);
const selectedDocSetSet = useMemo(
() => new Set(selectedDocSetIds),
[selectedDocSetIds]
);
const selectedAgentSet = useMemo(
() => new Set(selectedAgentIds),
[selectedAgentIds]
);
const selectedPairs = useMemo(
() => connectors.filter((p) => selectedCcPairSet.has(p.cc_pair_id)),
[connectors, selectedCcPairSet]
);
const selectedDocSets = useMemo(
() => documentSets.filter((ds) => selectedDocSetSet.has(ds.id)),
[documentSets, selectedDocSetSet]
);
const selectedAgentObjects = useMemo(
() => agents.filter((a) => selectedAgentSet.has(a.id)),
[agents, selectedAgentSet]
);
// --- Popover sections ---
const connectorDocSetSections: PopoverSection[] = useMemo(() => {
const q = connectorSearch.toLowerCase();
const connectorItems = connectors
.filter((p) => !q || (p.name ?? "").toLowerCase().includes(q))
.map((p) => {
const isSelected = selectedCcPairSet.has(p.cc_pair_id);
return {
key: `c-${p.cc_pair_id}`,
disabled: isSelected,
onSelect: () =>
isSelected
? onCcPairIdsChange(
selectedCcPairIds.filter((id) => id !== p.cc_pair_id)
)
: onCcPairIdsChange([...selectedCcPairIds, p.cc_pair_id]),
render: (dimmed: boolean) => (
<LineItem
interactive={!dimmed}
muted={dimmed}
icon={getSourceMetadata(p.connector.source).icon}
rightChildren={
p.groups.length > 0 || dimmed ? <SharedBadge /> : undefined
}
>
{p.name ?? `Connector #${p.cc_pair_id}`}
</LineItem>
),
};
});
const docSetItems = documentSets
.filter((ds) => !q || ds.name.toLowerCase().includes(q))
.map((ds) => {
const isSelected = selectedDocSetSet.has(ds.id);
return {
key: `d-${ds.id}`,
disabled: isSelected,
onSelect: () =>
isSelected
? onDocSetIdsChange(
selectedDocSetIds.filter((id) => id !== ds.id)
)
: onDocSetIdsChange([...selectedDocSetIds, ds.id]),
render: (dimmed: boolean) => (
<LineItem
interactive={!dimmed}
muted={dimmed}
icon={SvgFiles}
rightChildren={
ds.groups.length > 0 || dimmed ? <SharedBadge /> : undefined
}
>
{ds.name}
</LineItem>
),
};
});
return [
...(connectorItems.length > 0
? [{ label: "Connectors", items: connectorItems }]
: []),
...(docSetItems.length > 0
? [{ label: "Document Sets", items: docSetItems }]
: []),
];
}, [
connectors,
documentSets,
connectorSearch,
selectedCcPairSet,
selectedDocSetSet,
selectedCcPairIds,
selectedDocSetIds,
onCcPairIdsChange,
onDocSetIdsChange,
]);
const agentSections: PopoverSection[] = useMemo(() => {
const q = agentSearch.toLowerCase();
const items = agents
.filter((a) => !q || a.name.toLowerCase().includes(q))
.map((a) => {
const isSelected = selectedAgentSet.has(a.id);
return {
key: `a-${a.id}`,
disabled: isSelected,
onSelect: () =>
isSelected
? onAgentIdsChange(selectedAgentIds.filter((id) => id !== a.id))
: onAgentIdsChange([...selectedAgentIds, a.id]),
render: (dimmed: boolean) => (
<LineItem
interactive={!dimmed}
muted={dimmed}
icon={(_props) => <AgentAvatar agent={a} size={16} />}
description="agent"
rightChildren={
!a.is_public || dimmed ? <SharedBadge /> : undefined
}
>
{a.name}
</LineItem>
),
};
});
return items.length > 0 ? [{ items }] : [];
}, [
agents,
agentSearch,
selectedAgentSet,
selectedAgentIds,
onAgentIdsChange,
]);
// --- Handlers ---
function removeConnector(id: number) {
onCcPairIdsChange(selectedCcPairIds.filter((cid) => cid !== id));
}
function removeDocSet(id: number) {
onDocSetIdsChange(selectedDocSetIds.filter((did) => did !== id));
}
function removeAgent(id: number) {
onAgentIdsChange(selectedAgentIds.filter((aid) => aid !== id));
}
const hasSelectedResources =
selectedPairs.length > 0 || selectedDocSets.length > 0;
return (
<SimpleCollapsible>
<SimpleCollapsible.Header
title="Shared with This Group"
description="Share connectors, document sets, agents with members of this group."
/>
<SimpleCollapsible.Content>
<Card>
<Section
gap={1}
height="auto"
alignItems="stretch"
justifyContent="start"
width="full"
>
{/* Connectors & Document Sets */}
<Section
gap={0.5}
height="auto"
alignItems="stretch"
justifyContent="start"
>
<Section
gap={0.25}
height="auto"
alignItems="stretch"
justifyContent="start"
>
<Text mainUiAction text04>
Connectors & Document Sets
</Text>
<ResourcePopover
placeholder="Add connectors, document sets"
searchValue={connectorSearch}
onSearchChange={setConnectorSearch}
sections={connectorDocSetSections}
/>
</Section>
{hasSelectedResources ? (
<Section
flexDirection="row"
wrap
gap={0.25}
height="auto"
alignItems="start"
justifyContent="start"
>
{selectedPairs.map((pair) => (
<ResourceContent
key={`c-${pair.cc_pair_id}`}
icon={getSourceMetadata(pair.connector.source).icon}
title={pair.name ?? `Connector #${pair.cc_pair_id}`}
description="Connector"
onRemove={() => removeConnector(pair.cc_pair_id)}
/>
))}
{selectedDocSets.map((ds) => (
<ResourceContent
key={`d-${ds.id}`}
icon={SvgFiles}
title={ds.name}
description={`Document Set - ${ds.cc_pair_summaries.length} Sources`}
infoContent={
<SourceIconStack sources={ds.cc_pair_summaries} />
}
onRemove={() => removeDocSet(ds.id)}
/>
))}
</Section>
) : (
<Content
icon={SvgEmpty}
title="No connectors or document sets added"
description="Add connectors or document set to share with this group."
sizePreset="secondary"
variant="section"
/>
)}
</Section>
<Separator noPadding />
{/* Agents */}
<Section
gap={0.5}
height="auto"
alignItems="stretch"
justifyContent="start"
>
<Section
gap={0.25}
height="auto"
alignItems="stretch"
justifyContent="start"
>
<Text mainUiAction text04>
Agents
</Text>
<ResourcePopover
placeholder="Add agents"
searchValue={agentSearch}
onSearchChange={setAgentSearch}
sections={agentSections}
/>
</Section>
{selectedAgentObjects.length > 0 ? (
<Section
flexDirection="row"
wrap
gap={0.25}
height="auto"
alignItems="start"
justifyContent="start"
>
{selectedAgentObjects.map((agent) => (
<ResourceContent
key={agent.id}
leftContent={
<div className="flex items-center justify-center shrink-0 size-5 p-0.5 rounded-04">
<AgentAvatar agent={agent} size={16} />
</div>
}
title={agent.name}
description="agent"
onRemove={() => removeAgent(agent.id)}
/>
))}
</Section>
) : (
<Content
icon={SvgXOctagon}
title="No agents added"
description="Add agents to share with this group."
sizePreset="secondary"
variant="section"
/>
)}
</Section>
</Section>
</Card>
</SimpleCollapsible.Content>
</SimpleCollapsible>
);
}
export default SharedGroupResources;

View File

@@ -0,0 +1,19 @@
export interface PopoverItem {
key: string;
render: (disabled: boolean) => React.ReactNode;
onSelect: () => void;
/** When true, the item is already selected — shown dimmed with bg-tint-02. */
disabled?: boolean;
}
export interface PopoverSection {
label?: string;
items: PopoverItem[];
}
export interface ResourcePopoverProps {
placeholder: string;
searchValue: string;
onSearchChange: (value: string) => void;
sections: PopoverSection[];
}

View File

@@ -0,0 +1,148 @@
"use client";
import { useRef } from "react";
import { SvgPlusCircle, SvgMinusCircle } from "@opal/icons";
import { Button } from "@opal/components";
import { Section } from "@/layouts/general-layouts";
import Card from "@/refresh-components/cards/Card";
import InputNumber from "@/refresh-components/inputs/InputNumber";
import Text from "@/refresh-components/texts/Text";
import IconButton from "@/refresh-components/buttons/IconButton";
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface TokenLimit {
tokenBudget: number | null;
periodHours: number | null;
}
interface TokenLimitSectionProps {
limits: TokenLimit[];
onLimitsChange: (limits: TokenLimit[]) => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
function TokenLimitSection({ limits, onLimitsChange }: TokenLimitSectionProps) {
const nextKeyRef = useRef(limits.length);
const keysRef = useRef<number[]>(limits.map((_, i) => i));
// Sync keys if the parent provides a different number of limits externally
// (e.g. loaded from server after initial mount).
if (keysRef.current.length < limits.length) {
while (keysRef.current.length < limits.length) {
keysRef.current.push(nextKeyRef.current++);
}
} else if (keysRef.current.length > limits.length) {
keysRef.current = keysRef.current.slice(0, limits.length);
}
function addLimit() {
const emptyIndex = limits.findIndex(
(l) => l.tokenBudget === null && l.periodHours === null
);
if (emptyIndex !== -1) return;
const key = nextKeyRef.current++;
keysRef.current = [...keysRef.current, key];
onLimitsChange([...limits, { tokenBudget: null, periodHours: null }]);
}
function removeLimit(index: number) {
keysRef.current = keysRef.current.filter((_, i) => i !== index);
onLimitsChange(limits.filter((_, i) => i !== index));
}
function updateLimit(
index: number,
field: keyof TokenLimit,
value: number | null
) {
onLimitsChange(
limits.map((l, i) => (i === index ? { ...l, [field]: value } : l))
);
}
return (
<SimpleCollapsible>
<SimpleCollapsible.Header
title="Token Rate Limit"
description="Limit number of tokens this group can use within a given time period."
/>
<SimpleCollapsible.Content>
<Card>
<Section
gap={0.5}
height="auto"
alignItems="stretch"
justifyContent="start"
width="full"
>
{/* Column headers */}
<div className="flex flex-wrap items-center gap-1 pr-[40px]">
<div className="flex-1 flex items-center min-w-[160px]">
<Text mainUiAction text04>
Token Limit
</Text>
<Text mainUiMuted text03 className="ml-0.5">
(thousand tokens)
</Text>
</div>
<div className="flex-1 flex items-center min-w-[160px]">
<Text mainUiAction text04>
Time Window
</Text>
<Text mainUiMuted text03 className="ml-0.5">
(hours)
</Text>
</div>
</div>
{/* Limit rows */}
{limits.map((limit, i) => (
<div key={keysRef.current[i]} className="flex items-center gap-1">
<div className="flex-1">
<InputNumber
value={limit.tokenBudget}
onChange={(v) => updateLimit(i, "tokenBudget", v)}
min={0}
placeholder="Token limit in thousands"
/>
</div>
<div className="flex-1">
<InputNumber
value={limit.periodHours}
onChange={(v) => updateLimit(i, "periodHours", v)}
min={1}
placeholder="24"
/>
</div>
<IconButton
small
icon={SvgMinusCircle}
onClick={() => removeLimit(i)}
/>
</div>
))}
{/* Add button */}
<Button
icon={SvgPlusCircle}
prominence="secondary"
size="md"
onClick={addLimit}
>
Add Limit
</Button>
</Section>
</Card>
</SimpleCollapsible.Content>
</SimpleCollapsible>
);
}
export default TokenLimitSection;

View File

@@ -27,7 +27,7 @@ function GroupsPage() {
} = useSWR<UserGroup[]>(USER_GROUP_URL, errorHandlingFetcher);
return (
<SettingsLayouts.Root>
<SettingsLayouts.Root width="sm">
{/* This is the sticky header for the groups page. It is used to display
* the groups page title and search input when scrolling down.
*/}

View File

@@ -16,14 +16,18 @@ async function renameGroup(groupId: number, newName: string): Promise<void> {
}
}
async function createGroup(name: string, userIds: string[]): Promise<void> {
async function createGroup(
name: string,
userIds: string[],
ccPairIds: number[] = []
): Promise<number> {
const res = await fetch(USER_GROUP_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
user_ids: userIds,
cc_pair_ids: [],
cc_pair_ids: ccPairIds,
}),
});
if (!res.ok) {
@@ -32,6 +36,234 @@ async function createGroup(name: string, userIds: string[]): Promise<void> {
detail?.detail ?? `Failed to create group: ${res.statusText}`
);
}
const group = await res.json();
return group.id;
}
export { USER_GROUP_URL, renameGroup, createGroup };
async function deleteGroup(groupId: number): Promise<void> {
const res = await fetch(`${USER_GROUP_URL}/${groupId}`, {
method: "DELETE",
});
if (!res.ok) {
const detail = await res.json().catch(() => null);
throw new Error(
detail?.detail ?? `Failed to delete group: ${res.statusText}`
);
}
}
// ---------------------------------------------------------------------------
// Agent (persona) sharing — managed from the persona side
// ---------------------------------------------------------------------------
async function updateAgentGroupSharing(
groupId: number,
initialAgentIds: number[],
currentAgentIds: number[]
): Promise<void> {
const initialSet = new Set(initialAgentIds);
const currentSet = new Set(currentAgentIds);
const added_agent_ids = currentAgentIds.filter((id) => !initialSet.has(id));
const removed_agent_ids = initialAgentIds.filter((id) => !currentSet.has(id));
if (added_agent_ids.length === 0 && removed_agent_ids.length === 0) return;
const res = await fetch(`${USER_GROUP_URL}/${groupId}/agents`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ added_agent_ids, removed_agent_ids }),
});
if (!res.ok) {
const detail = await res.json().catch(() => null);
throw new Error(
detail?.detail ?? `Failed to update agent sharing: ${res.statusText}`
);
}
}
// ---------------------------------------------------------------------------
// Document set sharing — managed from the document set side
// ---------------------------------------------------------------------------
interface DocumentSetSummary {
id: number;
description: string;
cc_pair_summaries: { id: number }[];
federated_connector_summaries: { id: number }[];
is_public: boolean;
users: string[];
groups: number[];
}
async function updateDocSetGroupSharing(
groupId: number,
initialDocSetIds: number[],
currentDocSetIds: number[]
): Promise<void> {
const initialSet = new Set(initialDocSetIds);
const currentSet = new Set(currentDocSetIds);
const added = currentDocSetIds.filter((id) => !initialSet.has(id));
const removed = initialDocSetIds.filter((id) => !currentSet.has(id));
if (added.length === 0 && removed.length === 0) return;
// Fetch all document sets to get their current state
const allRes = await fetch("/api/manage/document-set");
if (!allRes.ok) {
throw new Error("Failed to fetch document sets");
}
const allDocSets: DocumentSetSummary[] = await allRes.json();
const docSetMap = new Map(allDocSets.map((ds) => [ds.id, ds]));
for (const dsId of added) {
const ds = docSetMap.get(dsId);
if (!ds) {
throw new Error(`Document set ${dsId} not found`);
}
const updatedGroups = ds.groups.includes(groupId)
? ds.groups
: [...ds.groups, groupId];
const res = await fetch("/api/manage/admin/document-set", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: ds.id,
description: ds.description,
cc_pair_ids: ds.cc_pair_summaries.map((cc) => cc.id),
federated_connectors: ds.federated_connector_summaries.map((fc) => ({
federated_connector_id: fc.id,
})),
is_public: ds.is_public,
users: ds.users,
groups: updatedGroups,
}),
});
if (!res.ok) {
throw new Error(`Failed to add group to document set ${dsId}`);
}
}
for (const dsId of removed) {
const ds = docSetMap.get(dsId);
if (!ds) {
throw new Error(`Document set ${dsId} not found`);
}
const updatedGroups = ds.groups.filter((id) => id !== groupId);
const res = await fetch("/api/manage/admin/document-set", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: ds.id,
description: ds.description,
cc_pair_ids: ds.cc_pair_summaries.map((cc) => cc.id),
federated_connectors: ds.federated_connector_summaries.map((fc) => ({
federated_connector_id: fc.id,
})),
is_public: ds.is_public,
users: ds.users,
groups: updatedGroups,
}),
});
if (!res.ok) {
throw new Error(`Failed to remove group from document set ${dsId}`);
}
}
}
// ---------------------------------------------------------------------------
// Token rate limits — create / update / delete
// ---------------------------------------------------------------------------
interface TokenLimitPayload {
tokenBudget: number | null;
periodHours: number | null;
}
interface ExistingTokenLimit {
token_id: number;
enabled: boolean;
token_budget: number;
period_hours: number;
}
async function saveTokenLimits(
groupId: number,
limits: TokenLimitPayload[],
existing: ExistingTokenLimit[]
): Promise<void> {
// Filter to only valid (non-null) limits
const validLimits = limits.filter(
(l): l is { tokenBudget: number; periodHours: number } =>
l.tokenBudget != null && l.periodHours != null
);
// Update existing limits (match by index position)
const toUpdate = Math.min(validLimits.length, existing.length);
for (let i = 0; i < toUpdate; i++) {
const limit = validLimits[i]!;
const existingLimit = existing[i]!;
const updateRes = await fetch(
`/api/admin/token-rate-limits/rate-limit/${existingLimit.token_id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
enabled: existingLimit.enabled,
token_budget: limit.tokenBudget,
period_hours: limit.periodHours,
}),
}
);
if (!updateRes.ok) {
throw new Error(
`Failed to update token rate limit ${existingLimit.token_id}`
);
}
}
// Create new limits beyond existing count
for (let i = toUpdate; i < validLimits.length; i++) {
const limit = validLimits[i]!;
const createRes = await fetch(
`/api/admin/token-rate-limits/user-group/${groupId}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
enabled: true,
token_budget: limit.tokenBudget,
period_hours: limit.periodHours,
}),
}
);
if (!createRes.ok) {
throw new Error("Failed to create token rate limit");
}
}
// Delete excess existing limits
for (let i = toUpdate; i < existing.length; i++) {
const existingLimit = existing[i]!;
const deleteRes = await fetch(
`/api/admin/token-rate-limits/rate-limit/${existingLimit.token_id}`,
{ method: "DELETE" }
);
if (!deleteRes.ok) {
throw new Error(
`Failed to delete token rate limit ${existingLimit.token_id}`
);
}
}
}
export {
USER_GROUP_URL,
renameGroup,
createGroup,
deleteGroup,
updateAgentGroupSharing,
updateDocSetGroupSharing,
saveTokenLimits,
};

View File

@@ -20,6 +20,7 @@ import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import { ChatState } from "@/app/app/interfaces";
import { useForcedTools } from "@/lib/hooks/useForcedTools";
import useAppFocus from "@/hooks/useAppFocus";
import { getPastedFilesIfNoText } from "@/lib/clipboard";
import { cn, isImageFile } from "@/lib/utils";
import { Disabled } from "@opal/core";
import { useUser } from "@/providers/UserProvider";
@@ -300,20 +301,10 @@ const AppInputBar = React.memo(
}, [showFiles, currentMessageFiles]);
function handlePaste(event: React.ClipboardEvent) {
const items = event.clipboardData?.items;
if (items) {
const pastedFiles = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item && item.kind === "file") {
const file = item.getAsFile();
if (file) pastedFiles.push(file);
}
}
if (pastedFiles.length > 0) {
event.preventDefault();
handleFileUpload(pastedFiles);
}
const pastedFiles = getPastedFilesIfNoText(event.clipboardData);
if (pastedFiles.length > 0) {
event.preventDefault();
handleFileUpload(pastedFiles);
}
}

View File

@@ -223,7 +223,7 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
<SidebarWrapper>
<SidebarBody
scrollKey="admin-sidebar"
actionButtons={
pinnedContent={
<div className="flex flex-col w-full">
<SidebarTab
icon={({ className }) => <SvgX className={className} size={16} />}

View File

@@ -678,21 +678,18 @@ const MemoizedAppSidebarInner = memo(
<SidebarBody
scrollKey="app-sidebar"
footer={settingsButton}
actionButtons={
pinnedContent={
<div className="flex flex-col">
{newSessionButton}
{searchChatsButton}
{isOnyxCraftEnabled && buildButton}
{folded && moreAgentsButton}
{folded && newProjectButton}
</div>
}
>
{/* When folded, show icons immediately without waiting for data */}
{folded ? (
<>
{moreAgentsButton}
{newProjectButton}
</>
) : isLoadingDynamicContent ? null : (
{/* When folded, all nav buttons are in pinnedContent — nothing here */}
{folded ? null : isLoadingDynamicContent ? null : (
<>
{/* Agents */}
<DndContext

View File

@@ -4,7 +4,7 @@ import React from "react";
import OverflowDiv from "@/refresh-components/OverflowDiv";
export interface SidebarBodyProps {
actionButtons?: React.ReactNode;
pinnedContent?: React.ReactNode;
children?: React.ReactNode;
footer?: React.ReactNode;
/**
@@ -15,21 +15,14 @@ export interface SidebarBodyProps {
}
export default function SidebarBody({
actionButtons,
pinnedContent,
children,
footer,
scrollKey,
}: SidebarBodyProps) {
return (
<div className="flex flex-col min-h-0 h-full gap-3">
<div className="flex flex-col gap-1.5 px-2">
{actionButtons &&
(Array.isArray(actionButtons)
? actionButtons.map((button, index) => (
<div key={index}>{button}</div>
))
: actionButtons)}
</div>
{pinnedContent && <div className="px-2">{pinnedContent}</div>}
<OverflowDiv className="gap-3 px-2" scrollKey={scrollKey}>
{children}
</OverflowDiv>

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from "react";
import React, { useMemo } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@opal/components";
import Logo from "@/refresh-components/Logo";
@@ -12,53 +12,48 @@ interface LogoSectionProps {
function LogoSection({ folded, onFoldClick }: LogoSectionProps) {
const settings = useSettingsContext();
const applicationName = settings.enterpriseSettings?.application_name;
const logoDisplayStyle = settings.enterpriseSettings?.logo_display_style;
const logo = useCallback(
(className?: string) => <Logo folded={folded} className={className} />,
const logo = useMemo(
() => (
<div className="px-1">
<Logo folded={folded} size={28} />
</div>
),
[folded]
);
const closeButton = useCallback(
(shouldFold: boolean) => (
<Button
icon={SvgSidebar}
prominence="tertiary"
tooltip="Close Sidebar"
onClick={onFoldClick}
/>
const closeButton = useMemo(
() => (
<div className="px-1">
<Button
icon={SvgSidebar}
prominence="tertiary"
tooltip="Close Sidebar"
size="md"
onClick={onFoldClick}
/>
</div>
),
[onFoldClick]
);
return (
<div
className={cn(
/* px-2.5 => 2 for the standard sidebar padding + 0.5 for internal padding specific to this component. */
"flex px-2.5 py-2",
folded ? "justify-center" : "justify-between",
applicationName
? "h-[3.75rem] min-h-[3.75rem]"
: "h-[3.25rem] min-h-[3.25rem]"
)}
>
<div className="flex flex-row justify-between items-start pt-3 px-2">
{folded === undefined ? (
<div className="p-1">{logo()}</div>
logo
) : folded && logoDisplayStyle !== "name_only" ? (
<>
<div className="group-hover/SidebarWrapper:hidden pt-1.5">
{logo()}
</div>
<div className="w-full justify-center hidden group-hover/SidebarWrapper:flex">
{closeButton(false)}
<div className="group-hover/SidebarWrapper:hidden">{logo}</div>
<div className="hidden group-hover/SidebarWrapper:flex">
{closeButton}
</div>
</>
) : folded ? (
<div className="flex w-full justify-center">{closeButton(false)}</div>
closeButton
) : (
<>
<div className="p-1"> {logo()}</div>
{closeButton(true)}
{logo}
{closeButton}
</>
)}
</div>