Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/components/configuration/review/ChangeReview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ function reviewFor(
saving: false,
sessionExpired: false,
termsUrl: null,
conflict: false,
reloadLatest: vi.fn(),
mergeLatest: vi.fn().mockResolvedValue(undefined),
saveIssues: [],
title: 'Review',
blocked: issues.some((issue) => issue.blocking),
Expand Down Expand Up @@ -99,6 +102,47 @@ describe('ChangeReview', () => {
expect(link).toHaveAttribute('target', '_blank');
});

it('offers to reload or merge when the target was saved elsewhere meanwhile', () => {
const review = reviewFor([{ fieldPath: 'registration.socialLogins', value: ['openid'] }], {
conflict: true,
});
render(<ChangeReview review={review} />);
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent('Changed elsewhere');
expect(alert).toHaveTextContent(/Base configuration was saved elsewhere/);
fireEvent.click(within(alert).getByRole('button', { name: 'Reload' }));
expect(review.reloadLatest).toHaveBeenCalledTimes(1);
fireEvent.click(within(alert).getByRole('button', { name: 'Merge and save' }));
expect(review.mergeLatest).toHaveBeenCalledTimes(1);
});

it('keeps the footer Save disabled while the conflict waits for Reload or Merge', () => {
const edit = [{ fieldPath: 'registration.socialLogins', value: ['openid'] }];
const { unmount } = render(<ChangeReview review={reviewFor(edit, { conflict: true })} />);
const footer = screen.getByRole('contentinfo');
expect(within(footer).getByRole('button', { name: 'Save' })).toBeDisabled();
unmount();
render(<ChangeReview review={reviewFor(edit)} />);
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
});

it('names the profile that was saved elsewhere', () => {
const scope = {
principalType: 'role',
principalId: 'USER',
name: 'USER',
} as t.ConfigScope;
render(
<ChangeReview
review={reviewFor([{ fieldPath: 'registration.socialLogins', value: ['openid'] }], {
conflict: true,
scope,
})}
/>,
);
expect(screen.getByRole('alert')).toHaveTextContent(/USER was saved elsewhere/);
});

it('shows list changes as added and removed items', () => {
render(
<ChangeReview
Expand Down
51 changes: 48 additions & 3 deletions src/components/configuration/review/ChangeReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,59 @@ function SaveFailureAction({ review }: { review: t.ChangeReviewState }) {
return null;
}

/**
* A save that found the target saved elsewhere meanwhile (nothing was
* written; the page already shows what is stored now): reload, dropping the
* edits, or merge them over it.
*/
function ConflictNotice({ review }: { review: t.ChangeReviewState }) {
const localize = useLocalize();
return (
<div
role="alert"
className="flex flex-col gap-2 rounded-lg bg-(--cui-color-feedback-warning-bg) px-3 py-2 text-(--cui-color-feedback-warning-fg)"
>
<p className="m-0 flex items-center gap-1.5 text-sm font-medium">
<Icon name="warning" size="xs" aria-hidden="true" />
{localize('com_config_conflict_title')}
</p>
<p className="m-0 text-xs">
{localize('com_config_conflict_body', {
target: review.scope
? `${scopeTypeLabel(review.scope, localize)}: ${review.scope.name}`
: localize('com_scope_base_config'),
})}
</p>
<div className="flex flex-wrap justify-end gap-2">
<Button
htmlType="button"
type="secondary"
label={localize('com_config_conflict_reload')}
onClick={review.reloadLatest}
disabled={review.saving}
/>
<Button
htmlType="button"
type="primary"
label={localize('com_config_conflict_merge')}
onClick={() => void review.mergeLatest()}
disabled={review.saving || review.blocked}
/>
</div>
</div>
);
}

/**
* The change review drawer (WS7): what Save writes to the open target,
* grouped tab › section › entry, with BEFORE → AFTER, the value a reset
* reverts to, masked secrets, warnings and problems. Save stays disabled
* while pre-flight problems exist.
* while pre-flight problems exist, and after a conflict, which the notice's
* Reload or Merge and save resolves.
*/
export function ChangeReview({ review }: t.ChangeReviewProps) {
const localize = useLocalize();
const { model, open, saving, blocked } = review;
const { model, open, saving, blocked, conflict } = review;
const blocking = model.issues.filter((issue) => issue.blocking);
const isEmpty = model.count === 0;
const returnFocus = useReturnFocus(open, activeConfigTab);
Expand Down Expand Up @@ -148,6 +192,7 @@ export function ChangeReview({ review }: t.ChangeReviewProps) {
</header>

<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-3">
{conflict && <ConflictNotice review={review} />}
{blocking.length > 0 && (
<IssueList
title={localize('com_config_review_issues', { count: blocking.length })}
Expand Down Expand Up @@ -206,7 +251,7 @@ export function ChangeReview({ review }: t.ChangeReviewProps) {
saving ? localize('com_config_review_saving') : localize('com_config_review_save')
}
onClick={() => void review.confirm()}
disabled={saving || blocked}
disabled={saving || blocked || conflict}
/>
</footer>
</Dialog.Content>
Expand Down
9 changes: 9 additions & 0 deletions src/constants/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ export const BUNDLED_CONFIG_LIMITS: t.ConfigLimits = {
overrideCacheTtlMs: 60_000,
};

/** `expectedRevision` that makes a write apply only when the principal has no document. */
export const ABSENT_REVISION = 'absent';

/** The admin API's code for a write whose `expectedRevision` no longer matches (409). */
export const VERSION_CONFLICT_CODE = 'CONFIG_VERSION_CONFLICT';

/** A save refused before anything is written: one section holds more changes than one batch takes (`maxChangeOps`). */
export const TOO_MANY_CHANGES_CODE = 'TOO_MANY_CHANGES';

/** A stdio MCP server's process fields: only librechat.yaml sets them (D10). */
export const PROCESS_MCP_FIELDS: readonly string[] = ['command', 'args', 'env', 'cwd', 'stderr'];

Expand Down
1 change: 1 addition & 0 deletions src/hooks/config/layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export function useConfigLayers({
overridePaths: isEditingScope ? scopeOverridePaths : dbOverridePaths,
listOverrideCounts,
valueLayers,
targetRevision: isEditingScope ? scopeData?.revision : baseConfigData?.dbRevision,
};
}

Expand Down
Loading
Loading