From b718c7b5dd4ac85226f372008f4db680490794b2 Mon Sep 17 00:00:00 2001 From: Hyu Date: Thu, 8 Oct 2026 15:21:59 +0800 Subject: [PATCH] fix(web): reduce form rerenders and stabilize assistant model labels --- .../agents/components/AgentFormComponent.tsx | 112 ++- .../home/bots/components/bot-form/BotForm.tsx | 27 +- .../dynamic-form/DynamicFormComponent.tsx | 859 ++++++++++-------- .../dynamic-form/DynamicFormItemComponent.tsx | 16 +- .../knowledge/components/kb-form/KBForm.tsx | 127 ++- .../pipeline-form/PipelineFormComponent.tsx | 48 +- web/src/app/infra/hooks/useSnapshotDirty.ts | 32 + .../e2e/bot-session-tool-timeline.spec.ts | 17 +- web/tests/e2e/fixtures/langbot-api.ts | 8 +- web/tests/e2e/form-dirty-regressions.spec.ts | 317 +++++++ web/tests/e2e/workspace-assistant.spec.ts | 127 ++- 11 files changed, 1180 insertions(+), 510 deletions(-) create mode 100644 web/src/app/infra/hooks/useSnapshotDirty.ts create mode 100644 web/tests/e2e/form-dirty-regressions.spec.ts diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx index 6f7f93014..cfb9656ea 100644 --- a/web/src/app/home/agents/components/AgentFormComponent.tsx +++ b/web/src/app/home/agents/components/AgentFormComponent.tsx @@ -1,3 +1,4 @@ +import { useSnapshotDirty } from '@/app/infra/hooks/useSnapshotDirty'; import EntityLoadState from '@/components/EntityLoadState'; import { forwardRef, @@ -9,7 +10,7 @@ import { useRef, useState, } from 'react'; -import { useForm } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useTranslation } from 'react-i18next'; @@ -157,7 +158,6 @@ function AgentFormComponent( const [activeSection, setActiveSection] = useState('runner'); const isSavingRef = useRef(false); - const hasUnsavedChangesRef = useRef(false); const loadedHostToolPolicyRef = useRef(undefined); const formSchema = z.object({ @@ -197,18 +197,20 @@ function AgentFormComponent( const savedSnapshotRef = useRef(''); const initializedStagesRef = useRef>(new Set()); - const watchedValues = form.watch(); - const hasUnsavedChanges = (() => { - if (!savedSnapshotRef.current) return false; - return JSON.stringify(watchedValues) !== savedSnapshotRef.current; - })(); - hasUnsavedChangesRef.current = hasUnsavedChanges; + const { + isDirty: hasUnsavedChanges, + dirtyRef: hasUnsavedChangesRef, + refreshDirty, + } = useSnapshotDirty(form, savedSnapshotRef); useEffect(() => { onDirtyChange?.(hasUnsavedChanges); }, [hasUnsavedChanges, onDirtyChange]); - const supportedEventPatterns = form.watch('supported_event_patterns'); + const supportedEventPatterns = useWatch({ + control: form.control, + name: 'supported_event_patterns', + }); useEffect(() => { onSupportedEventPatternsChange?.(supportedEventPatterns); }, [onSupportedEventPatternsChange, supportedEventPatterns]); @@ -279,6 +281,7 @@ function AgentFormComponent( }; form.reset(loadedValues); savedSnapshotRef.current = JSON.stringify(loadedValues); + refreshDirty(); initializedStagesRef.current.clear(); setInitialDataLoaded(true); }) @@ -290,7 +293,7 @@ function AgentFormComponent( return () => { cancelled = true; }; - }, [agentId, form, t, loadAttempt]); + }, [agentId, form, t, loadAttempt, refreshDirty]); useEffect(() => { if (!initialDataLoaded || !readPendingRunnerInstall(runnerInstallScope)) { @@ -340,7 +343,11 @@ function AgentFormComponent( void loadPluginSystemStatus(); }, [loadPluginSystemStatus]); - const currentRunner = (form.watch('runner') as Record)?.id; + const currentRunner = useWatch({ + control: form.control, + name: 'runner', + compute: (runner) => runner?.id, + }); const runnerOptions = useMemo(() => { const runnerStage = runnerConfigSchema?.stages.find( (stage) => stage.name === 'runner', @@ -358,24 +365,38 @@ function AgentFormComponent( const activeRunnerStage = runnerConfigSchema?.stages.find( (stage) => stage.name === currentRunner, ); - const runnerConfigValues = form.watch('runner_config') as Record< + const runnerConfigValues = form.getValues('runner_config') as Record< string, Record >; - const activeRunnerValues = useMemo( - () => runnerConfigValues?.[currentRunner] ?? {}, - [currentRunner, runnerConfigValues], - ); - const missingRunnerFields = useMemo( - () => - (activeRunnerStage?.config ?? []).filter( - (field) => - field.required && - isRunnerFieldVisible(field, activeRunnerValues) && - isRequiredRunnerValueMissing(activeRunnerValues[field.name]), - ), - [activeRunnerStage, activeRunnerValues], + useWatch({ + control: form.control, + compute: (values) => + (activeRunnerStage?.config ?? []) + .filter( + (field) => + field.required && + isRunnerFieldVisible( + field, + values.runner_config?.[currentRunner] ?? {}, + ) && + isRequiredRunnerValueMissing( + values.runner_config?.[currentRunner]?.[field.name], + ), + ) + .map((field) => field.name) + .join('\n'), + }); + const activeRunnerValues = runnerConfigValues?.[currentRunner] ?? {}; + const missingRunnerFields = (activeRunnerStage?.config ?? []).filter( + (field) => + field.required && + isRunnerFieldVisible(field, activeRunnerValues) && + isRequiredRunnerValueMissing(activeRunnerValues[field.name]), ); + const missingRunnerFieldNames = missingRunnerFields + .map((field) => extractI18nObject(field.label)) + .join(', '); const primarySections: Array<{ name: AgentConfigSection; label: string; @@ -448,9 +469,7 @@ function AgentFormComponent( return { label: t('agents.runnerConfigIncomplete'), description: t('agents.runnerConfigIncompleteDescription', { - fields: missingRunnerFields - .map((field) => extractI18nObject(field.label)) - .join(', '), + fields: missingRunnerFieldNames, }), tone: 'warning', }; @@ -471,7 +490,8 @@ function AgentFormComponent( pluginStatusLoading, pluginSystemStatus, runnerOptions.length, - missingRunnerFields, + missingRunnerFieldNames, + missingRunnerFields.length, selectedRunnerOption, t, ]); @@ -520,11 +540,12 @@ function AgentFormComponent( onRunnerStatusChange?.(runnerStatus); }, [onRunnerStatusChange, runnerStatus]); - function updateSnapshotIfInitial(stageKey: string) { + function updateSnapshotIfInitial(stageKey: string, wasDirty: boolean) { if (!initializedStagesRef.current.has(stageKey)) { initializedStagesRef.current.add(stageKey); - if (!hasUnsavedChanges) { + if (!wasDirty) { savedSnapshotRef.current = JSON.stringify(form.getValues()); + refreshDirty(); } } } @@ -534,9 +555,10 @@ function AgentFormComponent( stageName: string, values: object, ) { + const wasDirty = hasUnsavedChangesRef.current; if (formName === 'runner') { form.setValue('runner', values, { shouldDirty: true }); - updateSnapshotIfInitial(`runner.${stageName}`); + updateSnapshotIfInitial(`runner.${stageName}`, wasDirty); return; } @@ -550,7 +572,7 @@ function AgentFormComponent( }, { shouldDirty: true }, ); - updateSnapshotIfInitial(`runner_config.${stageName}`); + updateSnapshotIfInitial(`runner_config.${stageName}`, wasDirty); } function renderDynamicStage(stage: PipelineConfigStage) { @@ -558,8 +580,8 @@ function AgentFormComponent( if (!isRunnerSelector && stage.name !== currentRunner) return null; const initialValues = isRunnerSelector - ? (form.watch('runner') as Record) || {} - : ((form.watch('runner_config') as Record) || {})[ + ? (form.getValues('runner') as Record) || {} + : ((form.getValues('runner_config') as Record) || {})[ stage.name ] || {}; @@ -640,6 +662,7 @@ function AgentFormComponent( loadedHostToolPolicyRef.current = [...values.allowed_tools]; } savedSnapshotRef.current = submittedSnapshot; + refreshDirty(); onFinish(agent); toast.success(t('agents.saveSuccess')); return true; @@ -655,7 +678,14 @@ function AgentFormComponent( onSavingChange?.(false); } }, - [agentId, hostToolCatalogAvailable, onFinish, onSavingChange, t], + [ + agentId, + hostToolCatalogAvailable, + onFinish, + onSavingChange, + refreshDirty, + t, + ], ); function handleSubmit(values: FormValues) { @@ -682,6 +712,7 @@ function AgentFormComponent( emoji: values.emoji || '๐Ÿค–', }; savedSnapshotRef.current = JSON.stringify(snapshot); + refreshDirty(); } }, async save() { @@ -693,7 +724,14 @@ function AgentFormComponent( return (await saveValues(form.getValues())) ?? false; }, }), - [form, initialDataLoaded, loadFailed, saveValues], + [ + form, + initialDataLoaded, + loadFailed, + saveValues, + hasUnsavedChangesRef, + refreshDirty, + ], ); if (loadFailed) diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index d8b6f2123..eae7f1f4b 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -38,7 +38,7 @@ import EventBindingsEditor from './EventBindingsEditor'; import PluginProcessorBindings from './PluginProcessorBindings'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useForm } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import { z } from 'zod'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; @@ -273,7 +273,22 @@ const BotForm = forwardRef(function BotForm( useEffect(() => { onAdapterLabelChange?.(adapterLabel); }, [adapterLabel, onAdapterLabelChange]); - const currentAdapterConfig = form.watch('adapter_config'); + const currentAdapterConfig = form.getValues('adapter_config'); + const embedTitle = useWatch({ + control: form.control, + name: 'adapter_config', + compute: (config) => config?.title, + }); + const dynamicFormSystemContext = useMemo( + () => ({ + bot_uuid: initBotId, + webhook_url: webhookUrl, + extra_webhook_url: extraWebhookUrl, + adapter_config: { title: embedTitle }, + outbound_ips: systemInfo.outbound_ips, + }), + [initBotId, webhookUrl, extraWebhookUrl, embedTitle], + ); // Group adapters by category for the Select dropdown. Legacy adapters are // split out and shown in a collapsed group at the bottom so they're @@ -1146,13 +1161,7 @@ const BotForm = forwardRef(function BotForm( shouldDirty: !isInitializing.current, }); }} - systemContext={{ - webhook_url: webhookUrl, - extra_webhook_url: extraWebhookUrl, - bot_uuid: initBotId || '', - adapter_config: form.getValues('adapter_config') || {}, - outbound_ips: systemInfo.outbound_ips, - }} + systemContext={dynamicFormSystemContext} /> )} diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 5ec6eff88..8c66f787d 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -3,7 +3,7 @@ import { SYSTEM_FIELD_PREFIX, DynamicFormItemType, } from '@/app/infra/entities/form/dynamic'; -import { useForm } from 'react-hook-form'; +import { useForm, useWatch, type UseFormReturn } from 'react-hook-form'; import type { ControllerRenderProps } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { isJsonValue, isPromptValue } from './StructuredFieldValue'; @@ -24,7 +24,7 @@ import { import QrCodeLoginDialog, { QrLoginPlatform, } from '@/app/home/components/qrcode-login/QrCodeLoginDialog'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { useTranslation } from 'react-i18next'; @@ -466,7 +466,7 @@ export default function DynamicFormComponent({ }) { const isInitialMount = useRef(true); const previousInitialValues = useRef(initialValues); - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); // Filter out display-only fields (webhook-url/embed-code/qr-code-login types // and `__system.*`-named fields) that should not participate in form state, @@ -490,28 +490,32 @@ export default function DynamicFormComponent({ ); // ๆ นๆฎ itemConfigList ๅŠจๆ€็”Ÿๆˆ zod schema - const formSchema = z.object( - editableValueSpecs.reduce( - (acc, item) => { - let fieldSchema = getValueSchema(item); + const formSchema = useMemo( + () => + z.object( + editableValueSpecs.reduce( + (acc, item) => { + let fieldSchema = getValueSchema(item); - if ( - item.required && - (fieldSchema instanceof z.ZodString || - fieldSchema instanceof z.ZodArray) - ) { - fieldSchema = fieldSchema.min(1, { - message: t('common.fieldRequired'), - }); - } + if ( + item.required && + (fieldSchema instanceof z.ZodString || + fieldSchema instanceof z.ZodArray) + ) { + fieldSchema = fieldSchema.min(1, { + message: t('common.fieldRequired'), + }); + } - return { - ...acc, - [item.name]: fieldSchema, - }; - }, - {} as Record, - ), + return { + ...acc, + [item.name]: fieldSchema, + }; + }, + {} as Record, + ), + ), + [editableValueSpecs, t], ); type FormValues = z.infer; @@ -577,21 +581,12 @@ export default function DynamicFormComponent({ } }, [initialValues, form, editableValueSpecs]); - // Get reactive form values for conditional rendering - const watchedValues = form.watch(); - const setFormValue = (name: string, value: unknown) => { - form.setValue(name as keyof FormValues, value as never, { - shouldDirty: true, - shouldValidate: true, - }); - }; - // Stable ref for onSubmit to avoid re-triggering the effect when the // parent passes a new closure on every render. const onSubmitRef = useRef(onSubmit); onSubmitRef.current = onSubmit; - // ็›‘ๅฌ่กจๅ•ๅ€ผๅ˜ๅŒ– + // Keep parent drafts synchronous with field changes. useEffect(() => { // Emit initial form values immediately so the parent always has a valid snapshot, // even if the user saves without modifying any field. @@ -618,323 +613,459 @@ export default function DynamicFormComponent({ editableValueSpecs, formValues as Record, ); - onSubmitRef.current?.(finalValues); previousInitialValues.current = finalValues as Record; + onSubmitRef.current?.(finalValues); }); return () => subscription.unsubscribe(); }, [form, editableValueSpecs]); + return ( + + ); +} + +type DynamicFormProps = Parameters[0]; + +type DynamicFormFieldsProps = Omit< + DynamicFormProps, + 'initialValues' | 'onSubmit' | 'onValidate' +> & { + form: UseFormReturn>; +}; + +// The stable provider also isolates useWatch/useController from an outer form's +// context updates. The field list itself must subscribe inside this provider. +const DynamicFormFields = memo(function DynamicFormFields( + props: DynamicFormFieldsProps, +) { + return ( +
+ + + ); +}); + +function DynamicFormFieldList({ + form, + itemConfigList, + onFileUploaded, + isEditing, + externalDependentValues, + systemContext, + renderItem, +}: DynamicFormFieldsProps) { + type FormValues = Record; + const { t, i18n } = useTranslation(); + const dependencies = useMemo( + () => [ + ...new Set( + itemConfigList + .flatMap((item) => [ + item.show_if?.field, + item.disable_if?.field, + ...(item.disabled_tooltip_overrides ?? []).map( + (rule) => rule.when.field, + ), + ...(item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR || + item.type === DynamicFormItemType.RESOURCES_SELECTOR + ? getValueSpecs(item).map((spec) => spec.name) + : []), + ]) + .filter( + (name): name is string => + !!name && !name.startsWith(SYSTEM_FIELD_PREFIX), + ), + ), + ], + [itemConfigList], + ); + const watchedValues = useWatch({ + control: form.control, + // Custom controls can read arbitrary fields; regular controls only need + // their declared conditions and composite-field dependencies here. + compute: (values: Record) => + renderItem + ? values + : Object.fromEntries(dependencies.map((name) => [name, values[name]])), + }); + const setFormValue = useCallback( + (name: string, value: unknown) => { + form.setValue(name, value, { shouldDirty: true, shouldValidate: true }); + }, + [form], + ); + // State for QR code login dialog const [qrDialogOpen, setQrDialogOpen] = useState(false); const [qrDialogPlatform, setQrDialogPlatform] = useState('feishu'); return ( -
-
- {/* QR code login dialog */} - { - for (const [key, value] of Object.entries(credentials)) { - if (value) { - form.setValue(key as keyof FormValues, value as never); - } - } - }} - /> - - {itemConfigList.map((config, index) => { - // Create a normalized config with type converted to frontend format - const normalizedConfig = { - ...config, - type: normalizeItemType(config.type), - }; - const fieldKey = config.id || config.name || `field-${index}`; - - let isHiddenByCondition = false; - if (config.show_if) { - const dependValue = resolveShowIfValue( - config.show_if.field, - watchedValues as Record, - externalDependentValues, - systemContext, - ); - - if ( - config.show_if.operator === 'eq' && - dependValue !== config.show_if.value - ) { - isHiddenByCondition = true; - } - if ( - config.show_if.operator === 'neq' && - dependValue === config.show_if.value - ) { - isHiddenByCondition = true; - } - if ( - config.show_if.operator === 'in' && - Array.isArray(config.show_if.value) && - !config.show_if.value.includes(dependValue) - ) { - isHiddenByCondition = true; +
+ {/* QR code login dialog */} + { + for (const [key, value] of Object.entries(credentials)) { + if (value) { + form.setValue(key as keyof FormValues, value as never); } } + }} + /> + + {itemConfigList.map((config, index) => { + // Create a normalized config with type converted to frontend format + const normalizedConfig = { + ...config, + type: normalizeItemType(config.type), + }; + const fieldKey = config.id || config.name || `field-${index}`; + + let isHiddenByCondition = false; + if (config.show_if) { + const dependValue = resolveShowIfValue( + config.show_if.field, + watchedValues as Record, + externalDependentValues, + systemContext, + ); - // Keep structured drafts mounted across conditional hiding so invalid - // JSON cannot disappear from validation when Advanced Settings closes. if ( - isHiddenByCondition && - normalizedConfig.type !== DynamicFormItemType.JSON && - normalizedConfig.type !== DynamicFormItemType.PROMPT_EDITOR - ) - return null; - - // Keep locked fields visible and resolve only the applicable reason. - const { isDisabledByCondition, disabledTooltip: tooltip } = - resolveDisabledState( - config, - watchedValues as Record, - externalDependentValues, - systemContext, - ); - - // All fields are disabled when editing (creation_settings are - // immutable) or when ``disable_if`` matches. - const isFieldDisabled = !!isEditing || isDisabledByCondition; - const disabledTooltip = tooltip ? extractI18nObject(tooltip) : ''; - const renderDisabledTooltipIcon = () => - disabledTooltip ? ( - - ) : null; - - // `__system.*` fields are display-only; their value is resolved - // from systemContext (same namespace as show_if), not user input. - // Hidden entirely when the deployment doesn't provide the value. - if (config.name.startsWith(SYSTEM_FIELD_PREFIX)) { - const rawValue = - systemContext?.[config.name.slice(SYSTEM_FIELD_PREFIX.length)]; - const values = (Array.isArray(rawValue) ? rawValue : [rawValue]) - .filter((v) => v !== undefined && v !== null && v !== '') - .map(String); - if (values.length === 0) return null; - - return ( - - ); + config.show_if.operator === 'eq' && + dependValue !== config.show_if.value + ) { + isHiddenByCondition = true; } - - // Webhook URL fields are display-only; render outside of form binding - if (normalizedConfig.type === 'webhook-url') { - const webhookUrl = (systemContext?.webhook_url as string) || ''; - const extraWebhookUrl = - (systemContext?.extra_webhook_url as string) || ''; - - if (!webhookUrl) return null; - - return ( - - ); + if ( + config.show_if.operator === 'neq' && + dependValue === config.show_if.value + ) { + isHiddenByCondition = true; } - - if (normalizedConfig.type === 'embed-code') { - const botUuid = (systemContext?.bot_uuid as string) || ''; - if (!botUuid) return null; - - const baseUrl = - import.meta.env.VITE_API_BASE_URL || window.location.origin; - const widgetTitle = - ((systemContext?.adapter_config as Record) - ?.title as string) || 'LangBot'; - const safeTitle = widgetTitle - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>'); - const embedSnippet = `