diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 8424c8b5..31d79c03 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -531,6 +531,7 @@ export function ConfigPage({ scope={editingScope} onBackToBase={backToBase} onError={notifyError} + canEditPriority={canManageConfig} values={layers.scopeOverrideValues} pendingResets={edits.pendingResets} onRevert={ diff --git a/src/components/configuration/ConfigTabContent.tsx b/src/components/configuration/ConfigTabContent.tsx index ebed52d4..16acd8ec 100644 --- a/src/components/configuration/ConfigTabContent.tsx +++ b/src/components/configuration/ConfigTabContent.tsx @@ -27,7 +27,8 @@ import { ConfigSection } from './ConfigSection'; import { CodeField } from './fields/CodeField'; import { InfoBanner } from './InfoBanner'; import { ScopeGate } from './ScopeGate'; -import { useLocalize } from '@/hooks'; +import { useLocalize, useConfigContract } from '@/hooks'; +import { getWriteRules } from '@/constants'; /** * Enter in an input of the tab form must never submit it (and so "click" its @@ -149,8 +150,11 @@ export function ConfigTabContent({ return map; }, [sectionCounts]); + const contract = useConfigContract(); const valuesData = useMemo( () => ({ + writeRules: getWriteRules(contract), + titleResolution: contract.rules.titleResolution, configValues, editedValues, resolvedValues, @@ -173,6 +177,7 @@ export function ConfigTabContent({ isEditingScope, valueLayers, visibilityEdits, + contract, ], ); diff --git a/src/components/configuration/ScopeBar.tsx b/src/components/configuration/ScopeBar.tsx index f7b4d75f..344511ee 100644 --- a/src/components/configuration/ScopeBar.tsx +++ b/src/components/configuration/ScopeBar.tsx @@ -4,9 +4,10 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import type * as t from '@/types'; import { formatScopeName, isSameScope, findPriorityTies, countScopeOverrides } from '@/utils'; import { availableScopesOptions, toggleScopeActiveFn } from '@/server'; +import { useLocalize, useConfigFeatures } from '@/hooks'; import { ScopeOverridesDialog } from './ScopeOverridesDialog'; +import { ScopePriorityEditor } from './ScopePriority'; import { getScopeTypeConfig } from '@/constants'; -import { useLocalize } from '@/hooks'; const TYPE_LABEL_KEYS: Readonly> = { role: 'com_scope_type_role', @@ -16,13 +17,17 @@ const TYPE_LABEL_KEYS: Readonly> = { /** * Persistent context bar shown under the tabs while a profile is open: which - * profile is being edited, its priority (with ties), members, override count, - * whether it is active, and the way back to Base (WS8: PR-1, PR-2). + * profile is being edited, its priority (with ties, and Change where the + * server has a priority route, D6), members, override count, whether it is + * active, and the way back to Base (WS8: PR-1, PR-2). Ties with profiles + * that change the same settings are a warning while LibreChat leaves their + * order undefined; with `deterministicPrecedence` the bar says which wins. */ export function ScopeBar({ scope, onBackToBase, onError, + canEditPriority = false, values, pendingResets, onRevert, @@ -35,6 +40,7 @@ export function ScopeBar({ }: t.ScopeBarProps) { const localize = useLocalize(); const queryClient = useQueryClient(); + const { deterministicPrecedence, priorityRoute } = useConfigFeatures(); const [toggling, setToggling] = useState(false); const [overridesOpen, setOverridesOpen] = useState(false); const { data: scopes, isSuccess, isFetching } = useQuery(availableScopesOptions); @@ -44,6 +50,8 @@ export function ScopeBar({ const isDeleted = isSuccess && !isFetching && !live; const ties = useMemo(() => findPriorityTies(current, scopes ?? []), [current, scopes]); const conflicts = ties.filter((tie) => tie.sharedPaths.length > 0); + const winsOver = conflicts.filter((tie) => tie.appliesAfter); + const losesTo = conflicts.filter((tie) => !tie.appliesAfter); const config = getScopeTypeConfig(current.principalType); const typeLabel = localize(TYPE_LABEL_KEYS[current.principalType] ?? config.labelKey); const overrideCount = countScopeOverrides(current); @@ -70,8 +78,8 @@ export function ScopeBar({ } }; + const priorityEditable = canEditPriority && priorityRoute && !isDeleted; const details = [ - localize('com_scope_priority', { priority: current.priority }), current.memberCount != null && localize('com_scope_member_count', { count: current.memberCount }), overrideCount > 0 @@ -102,18 +110,47 @@ export function ScopeBar({ {localize('com_scope_bar_name', { type: typeLabel, name: formatScopeName(current) })}

-

- {details.map((item, i) => ( + {priorityEditable ? ( + + ) : ( + {localize('com_scope_priority', { priority: current.priority })} + )} + {details.map((item) => ( - {i > 0 && } + {item} ))} -

- {conflicts.length > 0 && ( + + {deterministicPrecedence && + [ + { list: winsOver, key: 'com_scope_tie_order_wins' }, + { list: losesTo, key: 'com_scope_tie_order_loses' }, + ].map( + ({ list, key }) => + list.length > 0 && ( +

+ {localize(key, { + names: tieNames(list), + count: new Set(list.flatMap((tie) => tie.sharedPaths)).size, + })} +

+ ), + )} + {!deterministicPrecedence && conflicts.length > 0 && (

)} - {conflicts.length === 0 && ties.length > 0 && ( + {!deterministicPrecedence && conflicts.length === 0 && ties.length > 0 && (

{localize('com_scope_tie_note', { names: tieNames(ties) })}

diff --git a/src/components/configuration/ScopePriority.tsx b/src/components/configuration/ScopePriority.tsx new file mode 100644 index 00000000..05300a95 --- /dev/null +++ b/src/components/configuration/ScopePriority.tsx @@ -0,0 +1,138 @@ +import { useId, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { Button, NumberField } from '@clickhouse/click-ui'; +import type * as t from '@/types'; +import { MAX_SCOPE_PRIORITY } from '@/constants'; +import { setScopePriorityFn } from '@/server'; +import { useLocalize } from '@/hooks'; +import { errorText } from '@/utils'; + +const PRIORITY_FORMAT = new Intl.NumberFormat(); + +/** The problem with a typed priority, or null when it can be saved. */ +function priorityProblem(text: string, localize: t.LocalizeFn): string | null { + const value = Number(text); + if (text.trim() === '' || !Number.isInteger(value)) { + return localize('com_scope_priority_invalid'); + } + if (value < 0 || value > MAX_SCOPE_PRIORITY) { + return localize('com_scope_priority_range', { + max: PRIORITY_FORMAT.format(MAX_SCOPE_PRIORITY), + }); + } + return null; +} + +/** + * A profile's priority with Change (D6, `priorityRoute`): an inline number + * field that sets only the priority, leaving the profile's settings and + * active state as they are. Enter saves, Escape cancels. + */ +export function ScopePriorityEditor({ scope, onError }: t.ScopePriorityEditorProps) { + const localize = useLocalize(); + const queryClient = useQueryClient(); + const inputId = useId(); + const errorId = useId(); + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + const [problem, setProblem] = useState(null); + + const close = () => { + setDraft(null); + setProblem(null); + }; + + const save = async () => { + if (draft == null || saving) return; + const invalid = priorityProblem(draft, localize); + if (invalid) { + setProblem(invalid); + return; + } + const priority = Number(draft); + if (priority === scope.priority) { + close(); + return; + } + setSaving(true); + try { + await setScopePriorityFn({ + data: { principalType: scope.principalType, principalId: scope.principalId, priority }, + }); + await queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); + close(); + } catch (err) { + onError?.(errorText(err, localize('com_scope_priority_error'), localize)); + } finally { + setSaving(false); + } + }; + + if (draft == null) { + return ( + + {localize('com_scope_priority', { priority: scope.priority })} + + + ); + } + + return ( + + + { + setDraft(next); + setProblem(null); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void save(); + } + if (event.key === 'Escape') close(); + }} + /> + +