sync: update from private repo (9a86f49b)
CI / build-and-test (push) Successful in 9m8s
CI / build-and-test (pull_request) Successful in 9m42s

This commit is contained in:
oss-sync
2026-07-12 23:51:48 +00:00
parent a67c40d33c
commit ed3dc5529a
159 changed files with 11696 additions and 1600 deletions
+77 -10
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { useConfig } from '../../hooks/useConfig';
@@ -33,13 +33,18 @@ import { AuthForm } from './AuthForm';
import { OrgsForm } from './OrgsForm';
import { PetsForm } from './PetsForm';
import { A2aDelegationsForm } from './A2aDelegationsForm';
import { ChatConnectorsForm } from './ChatConnectorsForm';
import { useToast } from '../../hooks/useToast';
import { settingsFieldId, type ConfigDraftStatus } from './types';
import { useAuthState } from '../../App';
interface ConfigFormProps {
section: string;
isAdmin: boolean;
focusFieldKey?: string;
focusFieldRequestId?: number;
onDraftStatusChange?: (status: ConfigDraftStatus) => void;
}
function PreferencesFormWrapper() {
@@ -116,8 +121,14 @@ function countDiff(a: any, b: any): number {
return norm(a) === norm(b) ? 0 : 1;
}
export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
export function ConfigForm({ section, isAdmin, onDraftStatusChange, focusFieldKey, focusFieldRequestId }: ConfigFormProps) {
const { t } = useTranslation('settings');
const usesAdminDraft = isAdmin && !['preferences', 'notifications', 'memory-learning', 'pets', 'a2a-delegations', 'chat-connectors', 'organizations'].includes(section);
useEffect(() => {
if (!usesAdminDraft) onDraftStatusChange?.({ dirtySectionIds: new Set() });
}, [onDraftStatusChange, usesAdminDraft]);
// User-scoped sections use their own per-user APIs and should not load the
// admin /api/config draft. Render them stand-alone without the global save bar.
if (section === 'preferences') {
@@ -143,14 +154,20 @@ export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
if (section === 'organizations') {
return <OrgsForm />;
}
// Chat connectors: admin-managed via /api/admin/chat/bindings (not
// config.yaml), so render stand-alone without the global save bar —
// mirrors a2a-delegations/organizations above.
if (section === 'chat-connectors') {
return <div className="max-w-2xl"><ChatConnectorsForm /></div>;
}
// Step 8: 'gateway-keys' bookmarks are redirected to 'gateway-server'
// by SettingsPage via LEGACY_SECTION_REDIRECT, so we no longer need a
// dedicated branch here. The keys UI lives inside GatewayServerForm
// as the Virtual Keys section.
return <ConfigFormInner section={section} isAdmin={isAdmin} />;
return <ConfigFormInner section={section} isAdmin={isAdmin} onDraftStatusChange={onDraftStatusChange} focusFieldKey={focusFieldKey} focusFieldRequestId={focusFieldRequestId} />;
}
function ConfigFormInner({ section }: ConfigFormProps) {
function ConfigFormInner({ section, onDraftStatusChange, focusFieldKey, focusFieldRequestId }: ConfigFormProps) {
const { t } = useTranslation('settings');
const { data, isLoading, error, refetch } = useConfig();
const queryClient = useQueryClient();
@@ -170,6 +187,8 @@ function ConfigFormInner({ section }: ConfigFormProps) {
// contract on SectionFormProps), so section switches / row removals cannot
// leave Save bricked by a stale key.
const [invalidKeys, setInvalidKeys] = useState<ReadonlySet<string>>(new Set());
const [dirtySectionIds, setDirtySectionIds] = useState<ReadonlySet<string>>(new Set());
const completedFocusRequestRef = useRef<number | undefined>();
// Bumped whenever the draft is replaced wholesale out from under any
// in-progress local field state (Discard Changes, or a fresh `data`
// load/refetch below). Section forms with local "in-progress draft"
@@ -194,6 +213,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
setOverriddenByEnv(data.overriddenByEnv);
setIsDirty(false);
setInvalidKeys(new Set());
setDirtySectionIds(new Set());
setResetToken(t => t + 1);
}
}, [data]);
@@ -201,7 +221,8 @@ function ConfigFormInner({ section }: ConfigFormProps) {
const handleChange = useCallback((path: string, value: any) => {
setDraft((prev: any) => setNestedValue(prev, path, value));
setIsDirty(true);
}, []);
setDirtySectionIds(prev => prev.has(section) ? prev : new Set(prev).add(section));
}, [section]);
const handleValidityChange = useCallback((fieldKey: string, valid: boolean) => {
setInvalidKeys(prev => {
@@ -213,6 +234,32 @@ function ConfigFormInner({ section }: ConfigFormProps) {
});
}, []);
const handleNavigateToInvalid = () => {
const fieldKey = invalidKeys.values().next().value;
if (!fieldKey) return;
const field = document.getElementById(settingsFieldId(fieldKey));
if (!(field instanceof HTMLElement)) return;
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
field.scrollIntoView({ block: 'center', behavior: reduceMotion ? 'auto' : 'smooth' });
field.focus({ preventScroll: true });
};
useEffect(() => {
if (!focusFieldKey) return;
if (completedFocusRequestRef.current === focusFieldRequestId) return;
const field = document.getElementById(settingsFieldId(focusFieldKey));
if (!(field instanceof HTMLElement)) return;
completedFocusRequestRef.current = focusFieldRequestId;
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
field.scrollIntoView({ block: 'center', behavior: reduceMotion ? 'auto' : 'smooth' });
field.focus({ preventScroll: true });
field.classList.add('ring-2', 'ring-amber-400');
const timer = window.setTimeout(() => field.classList.remove('ring-2', 'ring-amber-400'), 1600);
return () => window.clearTimeout(timer);
}, [focusFieldKey, focusFieldRequestId, draft, section]);
const handleDiscard = () => {
if (data) {
setDraft(data.config);
@@ -223,6 +270,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
// in-progress textarea + JSON error) remounts from the reverted
// value instead of getting stuck showing a stale error forever.
setInvalidKeys(new Set());
setDirtySectionIds(new Set());
setResetToken(t => t + 1);
}
};
@@ -240,6 +288,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
}
await queryClient.invalidateQueries({ queryKey: ['config'] });
setIsDirty(false);
setDirtySectionIds(new Set());
setToastIsError(false);
setToast(t('configForm.saved'));
setTimeout(() => setToast(null), 2000);
@@ -253,6 +302,15 @@ function ConfigFormInner({ section }: ConfigFormProps) {
};
const dirtyCount = isDirty && data ? countDiff(data.config, draft) : 0;
useEffect(() => {
if (dirtyCount === 0 && dirtySectionIds.size > 0) setDirtySectionIds(new Set());
}, [dirtyCount, dirtySectionIds]);
useEffect(() => {
onDraftStatusChange?.({ dirtySectionIds: dirtyCount > 0 ? dirtySectionIds : new Set() });
}, [dirtyCount, dirtySectionIds, onDraftStatusChange]);
// Arm beforeunload + register an in-app guard so navigating elsewhere
// (e.g. clicking a TopBar tab) prompts when there are unsaved fields.
useUnsavedGuard(dirtyCount > 0);
@@ -354,15 +412,24 @@ function ConfigFormInner({ section }: ConfigFormProps) {
{toast}
</span>
) : blockedByInvalid ? (
<span className="text-xs mr-auto text-red-600 dark:text-red-400 flex items-center gap-1.5 font-medium min-w-0">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0" aria-hidden />
<span className="truncate">{t('configForm.invalidBlocked')}</span>
</span>
<div className="mr-auto flex min-w-0 items-center gap-2">
<span className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1.5 font-medium min-w-0" aria-live="polite">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0" aria-hidden />
<span className="truncate">{t('configForm.invalidBlocked')}</span>
</span>
<button
type="button"
onClick={handleNavigateToInvalid}
className="h-8 flex-shrink-0 rounded-md border border-red-300 bg-canvas px-2 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
>
{t('configForm.showInvalidField')}
</button>
</div>
) : dirty ? (
<span className="text-xs mr-auto text-amber-800 dark:text-amber-300 flex items-center gap-1.5 font-medium min-w-0">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse flex-shrink-0" aria-hidden />
<span className="truncate">
<span className="hidden sm:inline">{t('configForm.unsaved', { count: dirtyCount })}</span>
<span className="hidden sm:inline">{t('configForm.unsavedAdmin', { count: dirtyCount })}</span>
<span className="sm:hidden">{t('configForm.unsavedShort', { count: dirtyCount })}</span>
</span>
</span>