From 503fec4d6e796ec19ad91c7db3fae082939d1dcc Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Sat, 19 Sep 2026 22:14:29 +0800 Subject: [PATCH] feat(web): unify guided tours and complete translations --- web/scripts/check-i18n.mjs | 6 +- web/src/app/global.css | 5 + .../app/home/agents/AgentDetailContent.tsx | 1 + .../agents/PluginProcessorDetailContent.tsx | 304 +++++--- .../agents/components/AgentDebugPanel.tsx | 5 +- .../agents/components/AgentFormComponent.tsx | 12 +- .../components/guided-tour/GuidedTour.tsx | 215 +---- .../components/guided-tour/TourOverlay.tsx | 293 +++++++ .../components/home-sidebar/SidebarGuide.tsx | 192 ++--- .../ProcessorDetailWorkbench.tsx | 25 +- .../knowledge/components/kb-form/KBForm.tsx | 2 +- .../home/pipelines/PipelineDetailContent.tsx | 8 + .../pipeline-form/PipelineFormComponent.tsx | 63 ++ web/src/i18n/locales/en-US.ts | 115 ++- web/src/i18n/locales/es-ES.ts | 735 +++++++++++++++++ web/src/i18n/locales/ja-JP.ts | 303 ++++++- web/src/i18n/locales/ru-RU.ts | 738 ++++++++++++++++++ web/src/i18n/locales/th-TH.ts | 701 +++++++++++++++++ web/src/i18n/locales/vi-VN.ts | 713 +++++++++++++++++ web/src/i18n/locales/zh-Hans.ts | 140 +++- web/src/i18n/locales/zh-Hant.ts | 582 +++++++++++++- web/tests/e2e/contextual-guides.spec.ts | 247 +++++- web/tests/e2e/fixtures/langbot-api.ts | 2 + web/tests/e2e/guide-appearance.spec.ts | 119 +++ web/tests/e2e/sidebar-guide.spec.ts | 91 ++- 25 files changed, 5012 insertions(+), 605 deletions(-) create mode 100644 web/src/app/home/components/guided-tour/TourOverlay.tsx create mode 100644 web/tests/e2e/guide-appearance.spec.ts diff --git a/web/scripts/check-i18n.mjs b/web/scripts/check-i18n.mjs index 2d52b6431..fd426046a 100755 --- a/web/scripts/check-i18n.mjs +++ b/web/scripts/check-i18n.mjs @@ -46,8 +46,8 @@ function extractKeys(filePath) { const stack = []; // Matches an object key at the start of a line (identifier or quoted string) - // Captures: [indent, keyName, hasOpenBrace] - const KEY_RE = /^(\s+)([\w]+)\s*:/; + // Captures indentation and an identifier, single-quoted, or double-quoted key. + const KEY_RE = /^(\s+)(?:([\w]+)|'([^']+)'|"([^"]+)")\s*:/; const OPEN_BRACE_RE = /\{\s*$/; const CLOSE_BRACE_RE = /^\s*\},?\s*$/; @@ -65,7 +65,7 @@ function extractKeys(filePath) { if (!m) continue; const indent = m[1].length; - const keyName = m[2]; + const keyName = m[2] ?? m[3] ?? m[4]; // Pop stack entries that are at the same or deeper indent level while (stack.length > 0 && stack[stack.length - 1].indent >= indent) { diff --git a/web/src/app/global.css b/web/src/app/global.css index f177f2b1b..a1be44406 100644 --- a/web/src/app/global.css +++ b/web/src/app/global.css @@ -190,3 +190,8 @@ transform: scale(0.95) rotate(-4deg); } } + +/* Show one tour at a time while the sidebar introduction is open. */ +body:has([data-tour-modal='true']) [data-tour-modal='false'] { + display: none; +} diff --git a/web/src/app/home/agents/AgentDetailContent.tsx b/web/src/app/home/agents/AgentDetailContent.tsx index cd0bc38cb..85184b337 100644 --- a/web/src/app/home/agents/AgentDetailContent.tsx +++ b/web/src/app/home/agents/AgentDetailContent.tsx @@ -262,6 +262,7 @@ export default function AgentDetailContent({ onSupportedEventPatternsChange={setSupportedEventPatterns} onPlatformToolsChange={setPlatformTools} guideEnabled={canManage} + debugGuideEnabled={canOperate} /> } diff --git a/web/src/app/home/agents/PluginProcessorDetailContent.tsx b/web/src/app/home/agents/PluginProcessorDetailContent.tsx index 9b0f4a6a4..53e08f708 100644 --- a/web/src/app/home/agents/PluginProcessorDetailContent.tsx +++ b/web/src/app/home/agents/PluginProcessorDetailContent.tsx @@ -1,5 +1,8 @@ +import GuidedTour, { + type GuidedTourStep, +} from '@/app/home/components/guided-tour/GuidedTour'; import EntityLoadState from '@/components/EntityLoadState'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useSearchParams } from 'react-router-dom'; import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups'; @@ -89,6 +92,65 @@ export default function PluginProcessorDetailContent({ const requestVersion = useRef(0); const component = components.find((item) => item.id === componentRef); const available = Boolean(component); + const hasParameters = Boolean(component?.config_schema.length); + const guideSteps = useMemo( + () => [ + { + id: 'select', + target: '#event-processor-component', + title: t('guidedTour.pluginProcessor.select.title'), + description: t('guidedTour.pluginProcessor.select.description'), + onEnter: () => setActiveTab('config'), + action: { + href: 'https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=event', + label: t('guidedTour.runner.select.action'), + }, + }, + ...(hasParameters + ? [ + { + id: 'parameters', + target: '[data-guide="event-processor-form-config"]', + title: t('guidedTour.pluginProcessor.parameters.title'), + description: t( + 'guidedTour.pluginProcessor.parameters.description', + ), + onEnter: () => setActiveTab('config'), + }, + ] + : []), + ...(canOperate && available + ? [ + { + id: 'debug', + target: '[data-guide="event-processor-form-debug"]', + title: t('guidedTour.pluginProcessor.debug.title'), + description: t('guidedTour.eventDebugDescription'), + onEnter: () => setActiveTab('config'), + }, + ] + : []), + { + id: 'logs', + target: '[data-guide="event-processor-form-tab-logs"]', + title: t('guidedTour.pluginProcessor.logs.title'), + description: t('guidedTour.pluginProcessor.logs.description'), + onEnter: () => setActiveTab('logs'), + }, + ...(available + ? [ + { + id: 'save', + target: '[data-guide="event-processor-form-save"]', + title: t('guidedTour.pluginProcessor.save.title'), + description: t('guidedTour.pluginProcessor.save.description'), + onEnter: () => setActiveTab('config'), + }, + ] + : []), + ], + [t, available, hasParameters, canOperate], + ); const load = useCallback(async () => { setLoading(true); @@ -393,122 +455,130 @@ export default function PluginProcessorDetailContent({ return void load()} />; return ( - : undefined - } - titleControls={ - { - setComponentRef(value); - setActiveTab('config'); - const descriptor = components.find((item) => item.id === value); - setParameters( - Object.fromEntries( - (descriptor?.config_schema ?? []) - .filter((field) => field.default !== undefined) - .map((field) => [field.name, field.default]), + <> + + : undefined + } + titleControls={ + { + setComponentRef(value); + setActiveTab('config'); + const descriptor = components.find((item) => item.id === value); + setParameters( + Object.fromEntries( + (descriptor?.config_schema ?? []) + .filter((field) => field.default !== undefined) + .map((field) => [field.name, field.default]), + ), + ); + validate.current = null; + }} + /> + } + status={ + !loading && componentRef && !available + ? { label: t('agents.eventProcessor.unavailable'), tone: 'error' } + : undefined + } + saveLabel={t('common.save')} + saveFormId="event-processor-form" + canSave={canManage && available} + isDirty={dirty} + isSaving={saving} + headerActions={ + canManage ? ( + + ) : undefined + } + configTitle={t('agents.eventProcessor.type')} + configTabs={{ + value: activeTab, + onValueChange: setActiveTab, + items: [ + { + value: 'config', + label: t('agents.eventProcessor.configTab'), + icon: , + content: ( +
+ {!component ? ( +

+ {t('agents.eventProcessor.selectComponent')} +

+ ) : component.config_schema.length === 0 ? ( +

+ {t('agents.eventProcessor.noSettings')} +

+ ) : ( +
+ + setParameters(values as Record) + } + onValidate={(fn) => { + validate.current = fn; + }} + /> +
+ )} +
), - ); - validate.current = null; - }} - /> - } - status={ - !loading && componentRef && !available - ? { label: t('agents.eventProcessor.unavailable'), tone: 'error' } - : undefined - } - saveLabel={t('common.save')} - saveFormId="event-processor-form" - canSave={canManage && available} - isDirty={dirty} - isSaving={saving} - headerActions={ - canManage ? ( - - ) : undefined - } - configTitle={t('agents.eventProcessor.type')} - configTabs={{ - value: activeTab, - onValueChange: setActiveTab, - items: [ - { - value: 'config', - label: t('agents.eventProcessor.configTab'), - icon: , - content: ( -
- {!component ? ( -

- {t('agents.eventProcessor.selectComponent')} -

- ) : component.config_schema.length === 0 ? ( -

- {t('agents.eventProcessor.noSettings')} -

- ) : ( -
- - setParameters(values as Record) - } - onValidate={(fn) => { - validate.current = fn; - }} - /> -
- )} -
- ), - }, - { - value: 'logs', - label: t('agents.eventProcessor.logsTab'), - icon: , - content: logsContent, - }, - ], - }} - debugTitle={canOperate ? t('agents.debugTab') : undefined} - debugDescription={t('agents.eventProcessor.debugNotice')} - debugContent={ - canOperate ? ( - !component ? ( - - - {t('agents.eventProcessor.selectToDebug')} - - - ) : ( - { - setActiveTab('logs'); - void refreshLatestRun(); - }} - supportedEventPatterns={component.supported_event_patterns} - availableEventTypes={availableEventTypes} - /> - ) - ) : undefined - } - unsavedLabel={t('pipelines.unsavedChanges')} - /> + }, + { + value: 'logs', + label: t('agents.eventProcessor.logsTab'), + icon: , + content: logsContent, + }, + ], + }} + debugTitle={canOperate ? t('agents.debugTab') : undefined} + debugDescription={t('agents.eventProcessor.debugNotice')} + debugContent={ + canOperate ? ( + !component ? ( + + + {t('agents.eventProcessor.selectToDebug')} + + + ) : ( + { + setActiveTab('logs'); + void refreshLatestRun(); + }} + supportedEventPatterns={component.supported_event_patterns} + availableEventTypes={availableEventTypes} + /> + ) + ) : undefined + } + unsavedLabel={t('pipelines.unsavedChanges')} + /> + ); } diff --git a/web/src/app/home/agents/components/AgentDebugPanel.tsx b/web/src/app/home/agents/components/AgentDebugPanel.tsx index 51efafdb9..40fd8b862 100644 --- a/web/src/app/home/agents/components/AgentDebugPanel.tsx +++ b/web/src/app/home/agents/components/AgentDebugPanel.tsx @@ -383,7 +383,10 @@ export default function AgentDebugPanel({ } return ( -
+

{t('agents.debugTranscript')}

diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx index 9b67f5f97..6f7f93014 100644 --- a/web/src/app/home/agents/components/AgentFormComponent.tsx +++ b/web/src/app/home/agents/components/AgentFormComponent.tsx @@ -74,6 +74,7 @@ interface AgentFormComponentProps { onSupportedEventPatternsChange?: (patterns: string[]) => void; onPlatformToolsChange?: (tools: AgentPlatformTool[]) => void; guideEnabled?: boolean; + debugGuideEnabled?: boolean; } export type AgentConfigSection = 'runner' | 'events_and_tools'; @@ -129,6 +130,7 @@ function AgentFormComponent( onSupportedEventPatternsChange, onPlatformToolsChange, guideEnabled = true, + debugGuideEnabled = false, }: AgentFormComponentProps, ref: ForwardedRef, ) { @@ -503,8 +505,16 @@ function AgentFormComponent( title: t('guidedTour.runner.events.title'), description: t('guidedTour.runner.events.description'), }); + if (debugGuideEnabled) { + steps.push({ + id: 'debug', + target: '[data-guide="agent-event-debug"]', + title: t('guidedTour.runner.debug.title'), + description: t('guidedTour.eventDebugDescription'), + }); + } return steps; - }, [activeRunnerStage, t]); + }, [activeRunnerStage, debugGuideEnabled, t]); useEffect(() => { onRunnerStatusChange?.(runnerStatus); diff --git a/web/src/app/home/components/guided-tour/GuidedTour.tsx b/web/src/app/home/components/guided-tour/GuidedTour.tsx index fdc22fdf3..7ffdc6ab0 100644 --- a/web/src/app/home/components/guided-tour/GuidedTour.tsx +++ b/web/src/app/home/components/guided-tour/GuidedTour.tsx @@ -1,29 +1,16 @@ -import { createPortal } from 'react-dom'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Check, ChevronRight, ExternalLink, X } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { Button } from '@/components/ui/button'; - -const VIEWPORT_GAP = 12; -const MIN_POPOVER_WIDTH = 220; -const MAX_POPOVER_WIDTH = 340; - -type TargetRect = Pick< - DOMRect, - 'top' | 'right' | 'bottom' | 'left' | 'width' | 'height' ->; - -type PopoverPosition = { - left: number; - top: number; - width: number; -}; +import TourOverlay, { + getTourPosition, + type TargetRect, + type PopoverPosition, +} from './TourOverlay'; export interface GuidedTourStep { id: string; target: string; title: string; description: string; + onEnter?: () => void; action?: { href: string; label: string; @@ -54,17 +41,12 @@ function storeProgress(storageKey: string, value: string) { } } -function clamp(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), Math.max(min, max)); -} - export default function GuidedTour({ enabled = true, storageKey, steps, testId = 'guided-tour', }: GuidedTourProps) { - const { t } = useTranslation(); const initialProgress = useMemo(() => readProgress(storageKey), [storageKey]); const [finished, setFinished] = useState(initialProgress === 'completed'); const [activeStepId, setActiveStepId] = useState(() => @@ -91,10 +73,7 @@ export default function GuidedTour({ }, [storageKey]); useEffect(() => { - if (!enabled || steps.length === 0 || finished) { - setActiveStepId(null); - return; - } + if (!enabled || steps.length === 0 || finished) return; const currentIndex = steps.findIndex((step) => step.id === activeStepId); const nextIndex = currentIndex < 0 ? 0 : currentIndex; @@ -106,6 +85,10 @@ export default function GuidedTour({ } }, [activeStepId, enabled, finished, steps, storageKey]); + useEffect(() => { + if (enabled && !finished) activeStep?.onEnter?.(); + }, [activeStep, enabled, finished]); + const measure = useCallback(() => { if (!activeStep) return; const target = document.querySelector(activeStep.target); @@ -127,44 +110,9 @@ export default function GuidedTour({ }; setTargetRect(nextRect); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const popoverHeight = popoverRef.current?.offsetHeight ?? 250; - const rightSpace = viewportWidth - rect.right - VIEWPORT_GAP * 2; - const leftSpace = rect.left - VIEWPORT_GAP * 2; - let width = Math.min(MAX_POPOVER_WIDTH, viewportWidth - VIEWPORT_GAP * 2); - let left = VIEWPORT_GAP; - let top = rect.bottom + VIEWPORT_GAP; - - if (rightSpace >= MIN_POPOVER_WIDTH) { - width = Math.min(MAX_POPOVER_WIDTH, rightSpace); - left = rect.right + VIEWPORT_GAP; - top = clamp( - rect.top, - VIEWPORT_GAP, - viewportHeight - popoverHeight - VIEWPORT_GAP, - ); - } else if (leftSpace >= MIN_POPOVER_WIDTH) { - width = Math.min(MAX_POPOVER_WIDTH, leftSpace); - left = rect.left - VIEWPORT_GAP - width; - top = clamp( - rect.top, - VIEWPORT_GAP, - viewportHeight - popoverHeight - VIEWPORT_GAP, - ); - } else { - top = - rect.bottom + VIEWPORT_GAP + popoverHeight <= viewportHeight - ? rect.bottom + VIEWPORT_GAP - : rect.top - popoverHeight - VIEWPORT_GAP; - top = clamp( - top, - VIEWPORT_GAP, - viewportHeight - popoverHeight - VIEWPORT_GAP, - ); - } - - setPopoverPosition({ left, top, width }); + setPopoverPosition( + getTourPosition(nextRect, popoverRef.current?.offsetHeight), + ); }, [activeStep]); useEffect(() => { @@ -200,11 +148,16 @@ export default function GuidedTour({ setActiveStepId(null); }, [storageKey]); + const handlePrevious = useCallback(() => { + const previousStep = steps[activeIndex - 1]; + if (!previousStep) return; + storeProgress(storageKey, previousStep.id); + setActiveStepId(previousStep.id); + }, [activeIndex, steps, storageKey]); + const handleNext = useCallback(() => { if (!activeStep) return; const nextStep = steps[activeIndex + 1]; - setTargetRect(null); - setPopoverPosition(null); if (!nextStep) { finishTour(); return; @@ -213,33 +166,6 @@ export default function GuidedTour({ setActiveStepId(nextStep.id); }, [activeIndex, activeStep, finishTour, steps, storageKey]); - useEffect(() => { - const handleNativeClick = (event: MouseEvent) => { - const target = event.target; - if (!(target instanceof Element)) return; - const button = target.closest( - '[data-guided-tour-action]', - ); - if ( - !button || - button.dataset.guidedTourId !== testId || - button.disabled - ) { - return; - } - if (button.dataset.guidedTourAction === 'skip') { - finishTour(); - } else if (button.dataset.guidedTourAction === 'next') { - handleNext(); - } - }; - - // Translation extensions can rewrite nodes inside the popover and detach - // React's delegated handler. Capture the command by its stable data marker. - document.addEventListener('click', handleNativeClick, true); - return () => document.removeEventListener('click', handleNativeClick, true); - }, [finishTour, handleNext, testId]); - if ( !enabled || !activeStep || @@ -250,88 +176,21 @@ export default function GuidedTour({ return null; } - const isLastStep = activeIndex === steps.length - 1; - const titleId = `${testId}-${activeStep.id}-title`; - - return createPortal( -
- , - document.body, + return ( + 0 ? handlePrevious : undefined} + onNext={handleNext} + onSkip={finishTour} + /> ); } diff --git a/web/src/app/home/components/guided-tour/TourOverlay.tsx b/web/src/app/home/components/guided-tour/TourOverlay.tsx new file mode 100644 index 000000000..e1a05476f --- /dev/null +++ b/web/src/app/home/components/guided-tour/TourOverlay.tsx @@ -0,0 +1,293 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { createPortal } from 'react-dom'; +import { useEffect, useRef, type RefObject } from 'react'; +import { + Check, + ChevronLeft, + ChevronRight, + ExternalLink, + X, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +export type TargetRect = Pick< + DOMRect, + 'top' | 'right' | 'bottom' | 'left' | 'width' | 'height' +>; +export type PopoverPosition = { left: number; top: number; width: number }; + +const VIEWPORT_GAP = 12; +const MIN_POPOVER_WIDTH = 220; +const MAX_POPOVER_WIDTH = 340; + +export function getTourPosition( + rect: TargetRect, + height = 250, +): PopoverPosition { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const rightSpace = viewportWidth - rect.right - VIEWPORT_GAP * 2; + const leftSpace = rect.left - VIEWPORT_GAP * 2; + let width = Math.min(MAX_POPOVER_WIDTH, viewportWidth - VIEWPORT_GAP * 2); + let left = VIEWPORT_GAP; + let top = rect.top; + if (rightSpace >= MIN_POPOVER_WIDTH) { + width = Math.min(MAX_POPOVER_WIDTH, rightSpace); + left = rect.right + VIEWPORT_GAP; + } else if (leftSpace >= MIN_POPOVER_WIDTH) { + width = Math.min(MAX_POPOVER_WIDTH, leftSpace); + left = rect.left - VIEWPORT_GAP - width; + } else { + top = + rect.bottom + VIEWPORT_GAP + height <= viewportHeight + ? rect.bottom + VIEWPORT_GAP + : rect.top - height - VIEWPORT_GAP; + } + top = Math.max( + VIEWPORT_GAP, + Math.min(top, viewportHeight - height - VIEWPORT_GAP), + ); + return { left, top, width }; +} + +interface TourOverlayProps { + testId: string; + stepId: string; + current: number; + total: number; + title: string; + description: string; + action?: { href: string; label: string }; + targetRect: TargetRect; + position: PopoverPosition; + popoverRef: RefObject; + modal?: boolean; + onPrevious?: () => void; + onNext: () => void; + onSkip: () => void; +} + +/** Shared presentation and controls for sidebar and contextual tours. */ +export default function TourOverlay({ + testId, + stepId, + current, + total, + title, + description, + action, + targetRect, + position, + popoverRef, + modal = false, + onPrevious, + onNext, + onSkip, +}: TourOverlayProps) { + const { t } = useTranslation(); + const nextButtonRef = useRef(null); + const titleId = `${testId}-${stepId}-title`; + const descriptionId = `${testId}-${stepId}-description`; + + useEffect(() => { + const previousFocus = document.activeElement; + return () => { + if (previousFocus instanceof HTMLElement && previousFocus.isConnected) { + previousFocus.focus({ preventScroll: true }); + } + }; + }, []); + + useEffect(() => { + nextButtonRef.current?.focus({ preventScroll: true }); + }, [stepId]); + + useEffect(() => { + const handleClick = (event: MouseEvent) => { + if (!(event.target instanceof Element)) return; + const button = event.target.closest( + '[data-guided-tour-action]', + ); + if (!button || button.dataset.guidedTourId !== testId || button.disabled) + return; + const action = button.dataset.guidedTourAction; + if (action === 'skip') onSkip(); + else if (action === 'previous') onPrevious?.(); + else if (action === 'next') onNext(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if ( + !event.defaultPrevented && + event.key === 'Escape' && + (modal || popoverRef.current?.contains(event.target as Node)) + ) { + event.preventDefault(); + event.stopPropagation(); + onSkip(); + } + }; + // Capture stable commands even when browser translation rewrites button children. + document.addEventListener('click', handleClick, true); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('click', handleClick, true); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [modal, onNext, onPrevious, onSkip, popoverRef, testId]); + + const card = ( +
+
+ + {t('guidedTour.label')} + + + {t('guidedTour.progress', { current, total })} + + +
+
+ {modal ? ( + + {title} + + ) : ( +

+ {title} +

+ )} + {modal ? ( + + {description} + + ) : ( +

+ {description} +

+ )} + {action && ( + + {action.label} + + + )} +
+
+ {onPrevious && ( + + )} + +
+
+ ); + + return createPortal( +
+ {modal && ( +