From 9f9b11252699e34844fb371d8b26d4cd411c82fb Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Wed, 20 May 2026 17:42:17 +0800 Subject: [PATCH] refactor(pipeline-form): swap Box banner for field-level disable_if + tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit hard-coded a BoxUnavailableNotice banner above the ``local-agent`` stage card. That works, but it shouts at the user about every field in that stage when in reality only one field — ``box-session-id-template`` — depends on the sandbox. Use the dynamic-form schema's existing variable-injection mechanism (``__system.*`` references via ``systemContext``) and add a sibling to ``show_if``: ``disable_if`` + ``disabled_tooltip``. The field stays visible, becomes inert, and an info icon next to its label exposes the reason on hover. The rest of the AI tab is left untouched. - entities/form/dynamic.ts: extend IDynamicFormItemSchema with ``disable_if: IShowIfCondition`` and ``disabled_tooltip: I18nObject`` - DynamicFormComponent: evaluate disable_if with the same resolver as show_if; OR the result into isFieldDisabled; render an Info tooltip trigger next to the label when the condition matches - ai.yaml metadata: attach disable_if (__system.box_available eq false) and a localized disabled_tooltip to box-session-id-template - PipelineFormComponent: drop the BoxUnavailableNotice import and the per-stage banner; pass ``systemContext={ box_available: boxAvailable }`` only for the local-agent stage so other stages aren't paying the re-render cost Co-Authored-By: Claude Opus 4.7 (1M context) --- .../templates/metadata/pipeline/ai.yaml | 16 +++++ .../dynamic-form/DynamicFormComponent.tsx | 71 +++++++++++++++++-- .../pipeline-form/PipelineFormComponent.tsx | 67 +++++++++-------- web/src/app/infra/entities/form/dynamic.ts | 11 +++ 4 files changed, 124 insertions(+), 41 deletions(-) diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml index ce25d5f1..32f4115f 100644 --- a/src/langbot/templates/metadata/pipeline/ai.yaml +++ b/src/langbot/templates/metadata/pipeline/ai.yaml @@ -143,6 +143,22 @@ stages: th_TH: กำหนดวิธีแชร์สภาพแวดล้อม Sandbox ระหว่างข้อความ es_ES: Determina cómo se comparten los entornos sandbox entre mensajes. ru_RU: Определяет, как песочницы используются совместно между сообщениями. + disable_if: + field: __system.box_available + operator: eq + value: false + disabled_tooltip: + en_US: >- + Box sandbox is disabled or unavailable. Enable it in config.yaml + (box.enabled = true) and ensure the runtime is reachable to change + this setting. + zh_Hans: Box 沙箱已禁用或不可用。请在配置中启用(box.enabled = true)并确认运行时连接正常,才能修改此项。 + zh_Hant: Box 沙箱已停用或無法使用。請在設定中啟用(box.enabled = true)並確認執行時連線正常,才能修改此項。 + ja_JP: Box サンドボックスが無効または利用できません。設定で有効化(box.enabled = true)し、ランタイムが接続できることを確認してから変更してください。 + vi_VN: Sandbox Box đã tắt hoặc không khả dụng. Hãy bật trong cấu hình (box.enabled = true) và đảm bảo runtime hoạt động để chỉnh sửa. + th_TH: Sandbox Box ถูกปิดใช้งานหรือไม่พร้อมใช้งาน กรุณาเปิดใช้งานในการตั้งค่า (box.enabled = true) และตรวจสอบว่ารันไทม์เชื่อมต่อปกติก่อนปรับค่า + es_ES: El sandbox de Box está desactivado o no disponible. Actívelo en la configuración (box.enabled = true) y asegúrese de que el runtime esté conectado para modificar este ajuste. + ru_RU: Песочница Box отключена или недоступна. Включите её в конфигурации (box.enabled = true) и убедитесь, что среда выполнения работает, чтобы изменить эту настройку. type: select required: false default: "{launcher_type}_{launcher_id}" diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 75c9f47f..86f48ad1 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -17,8 +17,14 @@ import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; -import { Copy, Check, Globe } from 'lucide-react'; +import { Copy, Check, Globe, Info } from 'lucide-react'; import { copyToClipboard } from '@/app/utils/clipboard'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { systemInfo } from '@/app/infra/http'; /** @@ -467,8 +473,53 @@ export default function DynamicFormComponent({ } } - // All fields are disabled when editing (creation_settings are immutable) - const isFieldDisabled = !!isEditing; + // ``disable_if`` mirrors ``show_if``'s evaluator but instead of + // hiding the field, leaves it visible and inert. Use it when the + // operator needs to see that the field exists yet cannot edit it + // under the current runtime state (e.g. sandbox-bound fields when + // Box is disabled). + let isDisabledByCondition = false; + if (config.disable_if) { + const dependValue = resolveShowIfValue( + config.disable_if.field, + watchedValues as Record, + externalDependentValues, + systemContext, + ); + const cond = config.disable_if; + if (cond.operator === 'eq' && dependValue === cond.value) { + isDisabledByCondition = true; + } else if (cond.operator === 'neq' && dependValue !== cond.value) { + isDisabledByCondition = true; + } else if ( + cond.operator === 'in' && + Array.isArray(cond.value) && + cond.value.includes(dependValue) + ) { + isDisabledByCondition = true; + } + } + + // All fields are disabled when editing (creation_settings are + // immutable) or when ``disable_if`` matches. + const isFieldDisabled = !!isEditing || isDisabledByCondition; + const disabledTooltip = + isDisabledByCondition && config.disabled_tooltip + ? extractI18nObject(config.disabled_tooltip) + : ''; + const renderDisabledTooltipIcon = () => + disabledTooltip ? ( + + + + + + + {disabledTooltip} + + + + ) : null; // Webhook URL fields are display-only; render outside of form binding if (config.type === 'webhook-url') { @@ -539,8 +590,9 @@ export default function DynamicFormComponent({ )} >
- + {extractI18nObject(config.label)} + {renderDisabledTooltipIcon()} {config.description && (

@@ -570,9 +622,14 @@ export default function DynamicFormComponent({ name={config.name as keyof FormValues} render={({ field }) => ( - - {extractI18nObject(config.label)}{' '} - {config.required && *} + + + {extractI18nObject(config.label)}{' '} + {config.required && ( + * + )} + + {renderDisabledTooltipIcon()}

(false); - const { available: boxAvailable, hint: boxHint } = useBoxStatus(); + const { available: boxAvailable } = useBoxStatus(); const formSchema = isEditMode ? z.object({ @@ -416,40 +415,40 @@ export default function PipelineFormComponent({ } } - // The local-agent stage carries sandbox-bound fields (e.g. - // ``box-session-id-template``). When Box is disabled / unavailable, show - // a banner so operators know those fields will have no effect. We render - // the banner above the card rather than per-field because the field set - // is driven by yaml metadata and a single notice keeps the UI calm. - const showBoxNoticeForStage = stage.name === 'local-agent' && !boxAvailable; + // Box availability is exposed through ``systemContext.__system.box_available`` + // so individual yaml-driven fields (e.g. ``box-session-id-template``) can + // opt-in via ``disable_if`` + ``disabled_tooltip`` rather than every page + // hard-coding a banner. Field-level gating keeps unrelated fields + // untouched. + const stageSystemContext = + stage.name === 'local-agent' + ? { box_available: boxAvailable } + : undefined; return ( -
- {showBoxNoticeForStage && } - - - {extractI18nObject(stage.label)} - {stage.description && ( - - {extractI18nObject(stage.description)} - - )} - - - )?.[stage.name] || - {} - } - onSubmit={(values) => { - handleDynamicFormEmit(formName, stage.name, values); - }} - /> - - -
+ + + {extractI18nObject(stage.label)} + {stage.description && ( + + {extractI18nObject(stage.description)} + + )} + + + )?.[stage.name] || {} + } + onSubmit={(values) => { + handleDynamicFormEmit(formName, stage.name, values); + }} + systemContext={stageSystemContext} + /> + + ); } diff --git a/web/src/app/infra/entities/form/dynamic.ts b/web/src/app/infra/entities/form/dynamic.ts index 854f50b1..00a3cc54 100644 --- a/web/src/app/infra/entities/form/dynamic.ts +++ b/web/src/app/infra/entities/form/dynamic.ts @@ -16,7 +16,18 @@ export interface IDynamicFormItemSchema { type: DynamicFormItemType; description?: I18nObject; options?: IDynamicFormItemOption[]; + /** When the condition matches, the field is rendered. Same evaluator as + * ``disable_if`` — supports the ``__system.*`` namespace via + * ``DynamicFormComponent.systemContext``. */ show_if?: IShowIfCondition; + /** When the condition matches, the field is rendered as read-only/disabled + * but stays visible. Use this when the operator needs to see that the + * field exists but can't be edited under the current runtime state (e.g. + * a sandbox-bound field when Box is disabled). Pair with + * ``disabled_tooltip`` to explain why. */ + disable_if?: IShowIfCondition; + /** Tooltip shown next to the field label when ``disable_if`` is active. */ + disabled_tooltip?: I18nObject; /** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */ scopes?: string[];