Skip to content
Merged
1 change: 1 addition & 0 deletions src/components/configuration/ConfigPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,7 @@ export function ConfigPage({
scope={editingScope}
onBackToBase={backToBase}
onError={notifyError}
canEditPriority={canManageConfig}
values={layers.scopeOverrideValues}
pendingResets={edits.pendingResets}
onRevert={
Expand Down
7 changes: 6 additions & 1 deletion src/components/configuration/ConfigTabContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -149,8 +150,11 @@ export function ConfigTabContent({
return map;
}, [sectionCounts]);

const contract = useConfigContract();
const valuesData = useMemo<t.ConfigValuesData>(
() => ({
writeRules: getWriteRules(contract),
titleResolution: contract.rules.titleResolution,
configValues,
editedValues,
resolvedValues,
Expand All @@ -173,6 +177,7 @@ export function ConfigTabContent({
isEditingScope,
valueLayers,
visibilityEdits,
contract,
],
);

Expand Down
61 changes: 49 additions & 12 deletions src/components/configuration/ScopeBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>> = {
role: 'com_scope_type_role',
Expand All @@ -16,13 +17,17 @@ const TYPE_LABEL_KEYS: Readonly<Record<string, string>> = {

/**
* 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,
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -102,18 +110,47 @@ export function ScopeBar({
{localize('com_scope_bar_name', { type: typeLabel, name: formatScopeName(current) })}
</strong>
</p>
<p
className="flex flex-wrap items-center gap-x-1.5 text-xs text-(--cui-color-text-muted)"
title={localize('com_scope_priority_help')}
<div
className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-(--cui-color-text-muted)"
title={localize(
deterministicPrecedence
? 'com_scope_priority_help_ordered'
: 'com_scope_priority_help',
)}
>
{details.map((item, i) => (
{priorityEditable ? (
<ScopePriorityEditor scope={current} onError={onError} />
) : (
<span>{localize('com_scope_priority', { priority: current.priority })}</span>
)}
{details.map((item) => (
<span key={item} className="flex items-center gap-1.5">
{i > 0 && <span aria-hidden="true">·</span>}
<span aria-hidden="true">·</span>
{item}
</span>
))}
</p>
{conflicts.length > 0 && (
</div>
{deterministicPrecedence &&
[
{ list: winsOver, key: 'com_scope_tie_order_wins' },
{ list: losesTo, key: 'com_scope_tie_order_loses' },
].map(
({ list, key }) =>
list.length > 0 && (
<p
key={key}
role="status"
className="text-xs text-(--cui-color-text-muted)"
title={localize('com_scope_tie_order_rule')}
>
{localize(key, {
names: tieNames(list),
count: new Set(list.flatMap((tie) => tie.sharedPaths)).size,
})}
</p>
),
)}
{!deterministicPrecedence && conflicts.length > 0 && (
<p
role="status"
className="flex items-start gap-1 text-xs text-(--cui-color-text-default)"
Expand All @@ -129,7 +166,7 @@ export function ScopeBar({
</span>
</p>
)}
{conflicts.length === 0 && ties.length > 0 && (
{!deterministicPrecedence && conflicts.length === 0 && ties.length > 0 && (
<p className="text-xs text-(--cui-color-text-muted)">
{localize('com_scope_tie_note', { names: tieNames(ties) })}
</p>
Expand Down
138 changes: 138 additions & 0 deletions src/components/configuration/ScopePriority.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [saving, setSaving] = useState(false);
const [problem, setProblem] = useState<string | null>(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 (
<span className="flex items-center gap-1">
{localize('com_scope_priority', { priority: scope.priority })}
<button
type="button"
className="settings-row-action"
onClick={() => setDraft(String(scope.priority))}
aria-label={localize('com_scope_priority_change_label')}
>
{localize('com_scope_priority_change')}
</button>
</span>
);
}

return (
<span className="flex flex-wrap items-center gap-2">
<span className="w-28">
<NumberField
id={inputId}
label={localize('com_scope_priority_label')}
orientation="horizontal"
value={draft}
min={0}
max={MAX_SCOPE_PRIORITY}
step={1}
hideControls
autoFocus
disabled={saving}
aria-invalid={problem ? true : undefined}
aria-describedby={problem ? errorId : undefined}
onChange={(next) => {
setDraft(next);
setProblem(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void save();
}
if (event.key === 'Escape') close();
}}
/>
</span>
<Button
htmlType="button"
type="primary"
label={localize('com_ui_save')}
loading={saving}
disabled={saving}
onClick={() => void save()}
/>
<Button
htmlType="button"
type="secondary"
label={localize('com_ui_cancel')}
disabled={saving}
onClick={close}
/>
{problem && (
<span id={errorId} role="alert" className="text-xs text-(--cui-color-text-danger)">
{problem}
</span>
)}
</span>
);
}
14 changes: 10 additions & 4 deletions src/components/configuration/ScopeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
isCurrentSelection,
countScopeOverrides,
} from '@/utils';
import { useLocalize, useDebouncedFilter } from '@/hooks';
import { useLocalize, useConfigFeatures, useDebouncedFilter } from '@/hooks';
import { DecorativeIcon } from '@/components/shared';
import { getScopeTypeConfig } from '@/constants';

Expand Down Expand Up @@ -58,6 +58,10 @@ export function ScopeSelector({
}: t.ScopeSelectorProps) {
const localize = useLocalize();
const queryClient = useQueryClient();
const { deterministicPrecedence } = useConfigFeatures();
const precedenceKey = deterministicPrecedence
? 'com_scope_precedence_ordered'
: 'com_scope_precedence';
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [creatingKey, setCreatingKey] = useState<string | null>(null);
Expand Down Expand Up @@ -433,7 +437,7 @@ export function ScopeSelector({
<Command.Dialog {...dialogProps} label={localize('com_scope_select')}>
<VisuallyHidden>
<DialogTitle>{localize('com_scope_select')}</DialogTitle>
<DialogDescription>{localize('com_scope_precedence')}</DialogDescription>
<DialogDescription>{localize(precedenceKey)}</DialogDescription>
</VisuallyHidden>
<div className="flex items-center gap-2 border-b border-(--cui-color-stroke-default) px-4">
<Command.Input
Expand Down Expand Up @@ -526,7 +530,7 @@ export function ScopeSelector({
)}
</Command.List>
<p className="border-t border-(--cui-color-stroke-default) px-4 py-2 text-xs text-(--cui-color-text-muted)">
{localize('com_scope_precedence')}
{localize(precedenceKey)}
</p>
</Command.Dialog>
);
Expand All @@ -543,12 +547,14 @@ function ScopeRow({
toggling,
}: t.ScopeRowProps) {
const localize = useLocalize();
const { deterministicPrecedence } = useConfigFeatures();
const config = getScopeTypeConfig(scope.principalType);
const name = formatScopeName(scope);
const overrideCount = countScopeOverrides(scope);
const conflicts = ties.filter((tie) => tie.sharedPaths.length > 0);
/** With a fixed order, a tie decides which profile wins rather than leaving it undefined. */
const tieText =
conflicts.length > 0
!deterministicPrecedence && conflicts.length > 0
? localize('com_scope_tie_short', {
names: conflicts.map((tie) => formatScopeName(tie.scope)).join(', '),
})
Expand Down
Loading
Loading