feat(web): unify guided tours and complete translations

This commit is contained in:
RockChinQ
2026-09-19 22:14:29 +08:00
parent e7ad051cf6
commit 503fec4d6e
25 changed files with 5012 additions and 605 deletions
+5
View File
@@ -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;
}
@@ -262,6 +262,7 @@ export default function AgentDetailContent({
onSupportedEventPatternsChange={setSupportedEventPatterns}
onPlatformToolsChange={setPlatformTools}
guideEnabled={canManage}
debugGuideEnabled={canOperate}
/>
</fieldset>
}
@@ -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<GuidedTourStep[]>(
() => [
{
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 <EntityLoadState error={failed} onRetry={() => void load()} />;
return (
<ProcessorDetailWorkbench
title={`${agent.emoji || '🧩'} ${agent.name}`}
titleAction={
canManage ? <EntityTitleEditButton onClick={onEdit} /> : undefined
}
titleControls={
<PluginProcessorSettings
components={components}
value={componentRef}
disabled={!canManage || saving || loading}
onChange={(value) => {
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]),
<>
<GuidedTour
enabled={canManage}
storageKey="langbot_plugin_processor_setup_guide_v1"
steps={guideSteps}
testId="plugin-processor-setup-guide"
/>
<ProcessorDetailWorkbench
title={`${agent.emoji || '🧩'} ${agent.name}`}
titleAction={
canManage ? <EntityTitleEditButton onClick={onEdit} /> : undefined
}
titleControls={
<PluginProcessorSettings
components={components}
value={componentRef}
disabled={!canManage || saving || loading}
onChange={(value) => {
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 ? (
<Button variant="destructive" disabled={saving} onClick={onDelete}>
<Trash2 className="size-4" />
{t('common.delete')}
</Button>
) : undefined
}
configTitle={t('agents.eventProcessor.type')}
configTabs={{
value: activeTab,
onValueChange: setActiveTab,
items: [
{
value: 'config',
label: t('agents.eventProcessor.configTab'),
icon: <Settings2 className="size-4" />,
content: (
<div className="h-full overflow-y-auto">
{!component ? (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.selectComponent')}
</p>
) : component.config_schema.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.noSettings')}
</p>
) : (
<fieldset disabled={!canManage || saving}>
<DynamicFormComponent
key={componentRef}
itemConfigList={component.config_schema}
initialValues={parameters}
onSubmit={(values) =>
setParameters(values as Record<string, unknown>)
}
onValidate={(fn) => {
validate.current = fn;
}}
/>
</fieldset>
)}
</div>
),
);
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 ? (
<Button variant="destructive" disabled={saving} onClick={onDelete}>
<Trash2 className="size-4" />
{t('common.delete')}
</Button>
) : undefined
}
configTitle={t('agents.eventProcessor.type')}
configTabs={{
value: activeTab,
onValueChange: setActiveTab,
items: [
{
value: 'config',
label: t('agents.eventProcessor.configTab'),
icon: <Settings2 className="size-4" />,
content: (
<div className="h-full overflow-y-auto">
{!component ? (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.selectComponent')}
</p>
) : component.config_schema.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.noSettings')}
</p>
) : (
<fieldset disabled={!canManage || saving}>
<DynamicFormComponent
key={componentRef}
itemConfigList={component.config_schema}
initialValues={parameters}
onSubmit={(values) =>
setParameters(values as Record<string, unknown>)
}
onValidate={(fn) => {
validate.current = fn;
}}
/>
</fieldset>
)}
</div>
),
},
{
value: 'logs',
label: t('agents.eventProcessor.logsTab'),
icon: <ScrollText className="size-4" />,
content: logsContent,
},
],
}}
debugTitle={canOperate ? t('agents.debugTab') : undefined}
debugDescription={t('agents.eventProcessor.debugNotice')}
debugContent={
canOperate ? (
!component ? (
<Alert className="m-3 w-auto">
<AlertDescription>
{t('agents.eventProcessor.selectToDebug')}
</AlertDescription>
</Alert>
) : (
<AgentDebugPanel
agentId={id}
processor
platformTools={platformTools}
hasUnsavedChanges={dirty}
beforeRun={save}
onRunFinished={() => {
setActiveTab('logs');
void refreshLatestRun();
}}
supportedEventPatterns={component.supported_event_patterns}
availableEventTypes={availableEventTypes}
/>
)
) : undefined
}
unsavedLabel={t('pipelines.unsavedChanges')}
/>
},
{
value: 'logs',
label: t('agents.eventProcessor.logsTab'),
icon: <ScrollText className="size-4" />,
content: logsContent,
},
],
}}
debugTitle={canOperate ? t('agents.debugTab') : undefined}
debugDescription={t('agents.eventProcessor.debugNotice')}
debugContent={
canOperate ? (
!component ? (
<Alert className="m-3 w-auto">
<AlertDescription>
{t('agents.eventProcessor.selectToDebug')}
</AlertDescription>
</Alert>
) : (
<AgentDebugPanel
agentId={id}
processor
platformTools={platformTools}
hasUnsavedChanges={dirty}
beforeRun={save}
onRunFinished={() => {
setActiveTab('logs');
void refreshLatestRun();
}}
supportedEventPatterns={component.supported_event_patterns}
availableEventTypes={availableEventTypes}
/>
)
) : undefined
}
unsavedLabel={t('pipelines.unsavedChanges')}
/>
</>
);
}
@@ -383,7 +383,10 @@ export default function AgentDebugPanel({
}
return (
<div className="flex h-full min-h-0 min-w-0 flex-col">
<div
data-guide={processor ? undefined : 'agent-event-debug'}
className="flex h-full min-h-0 min-w-0 flex-col"
>
<div ref={transcriptRef} className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="mb-3">
<p className="text-sm font-medium">{t('agents.debugTranscript')}</p>
@@ -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<AgentFormHandle>,
) {
@@ -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);
@@ -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<string | null>(() =>
@@ -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<HTMLElement>(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<HTMLButtonElement>(
'[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(
<div
data-testid={testId}
data-active-step={activeStep.id}
translate="no"
className="notranslate pointer-events-none"
>
<div
aria-hidden="true"
className="fixed z-[61] rounded-md ring-2 ring-blue-500 ring-offset-2 ring-offset-background transition-[top,left,width,height] duration-200"
style={{
top: targetRect.top,
left: targetRect.left,
width: targetRect.width,
height: targetRect.height,
boxShadow: '0 0 0 9999px rgb(15 23 42 / 0.32)',
}}
/>
<div
ref={popoverRef}
role="dialog"
aria-labelledby={titleId}
className="pointer-events-auto fixed z-[80] rounded-lg border bg-popover p-4 text-popover-foreground shadow-xl"
style={popoverPosition}
>
<div className="mb-3 flex items-center gap-2">
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
{t('guidedTour.label')}
</span>
<span className="ml-auto text-xs tabular-nums text-muted-foreground">
{t('guidedTour.progress', {
current: activeIndex + 1,
total: steps.length,
})}
</span>
<Button
type="button"
variant="ghost"
size="sm"
data-guided-tour-action="skip"
data-guided-tour-id={testId}
className="-my-2 -mr-2 h-7 gap-1 px-2 text-xs text-muted-foreground"
>
<X className="size-3.5" />
{t('guidedTour.skip')}
</Button>
</div>
<h2 id={titleId} className="text-base font-semibold">
{activeStep.title}
</h2>
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
{activeStep.description}
</p>
{activeStep.action && (
<a
href={activeStep.action.href}
target="_blank"
rel="noreferrer"
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
{activeStep.action.label}
<ExternalLink className="size-3.5" />
</a>
)}
<Button
type="button"
data-guided-tour-action="next"
data-guided-tour-id={testId}
className="mt-4 w-full"
>
{isLastStep ? (
<Check className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
{isLastStep ? t('guidedTour.finish') : t('guidedTour.next')}
</Button>
</div>
</div>,
document.body,
return (
<TourOverlay
testId={testId}
stepId={activeStep.id}
current={activeIndex + 1}
total={steps.length}
title={activeStep.title}
description={activeStep.description}
action={activeStep.action}
targetRect={targetRect}
position={popoverPosition}
popoverRef={popoverRef}
onPrevious={activeIndex > 0 ? handlePrevious : undefined}
onNext={handleNext}
onSkip={finishTour}
/>
);
}
@@ -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<HTMLDivElement | null>;
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<HTMLButtonElement | null>(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<HTMLButtonElement>(
'[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 = (
<div
key={stepId}
ref={popoverRef}
role="dialog"
aria-modal={modal || undefined}
{...(!modal && {
'aria-labelledby': titleId,
'aria-describedby': descriptionId,
})}
className={cn(
'pointer-events-auto fixed flex max-h-[calc(100dvh-24px)] flex-col rounded-lg border bg-popover p-4 text-popover-foreground shadow-xl dark:border-white/20 dark:bg-zinc-900 dark:shadow-[0_12px_40px_rgb(0_0_0_/_0.6)] animate-in fade-in-0 slide-in-from-bottom-1 duration-150 ease-out motion-reduce:animate-none',
modal ? 'z-[92]' : 'z-[80]',
)}
style={position}
>
<div className="mb-3 flex shrink-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
{t('guidedTour.label')}
</span>
<span className="ml-auto text-xs tabular-nums text-muted-foreground">
{t('guidedTour.progress', { current, total })}
</span>
<Button
type="button"
variant="ghost"
size="sm"
data-guided-tour-action="skip"
data-guided-tour-id={testId}
className="-my-2 -mr-2 h-7 gap-1 px-2 text-xs text-muted-foreground focus-visible:ring-blue-500/60"
>
<X className="size-3.5" />
{t('guidedTour.skip')}
</Button>
</div>
<div className="min-h-0 overflow-y-auto break-words">
{modal ? (
<DialogPrimitive.Title className="text-base font-semibold">
{title}
</DialogPrimitive.Title>
) : (
<h2 id={titleId} className="text-base font-semibold">
{title}
</h2>
)}
{modal ? (
<DialogPrimitive.Description className="mt-1.5 text-sm leading-6 text-muted-foreground">
{description}
</DialogPrimitive.Description>
) : (
<p
id={descriptionId}
className="mt-1.5 text-sm leading-6 text-muted-foreground"
>
{description}
</p>
)}
{action && (
<a
href={action.href}
target="_blank"
rel="noreferrer"
className="mt-3 inline-flex items-center gap-1.5 rounded-sm text-sm font-medium text-blue-600 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60 dark:text-blue-400"
>
{action.label}
<ExternalLink className="size-3.5 shrink-0" />
</a>
)}
</div>
<div className="mt-4 flex shrink-0 gap-2">
{onPrevious && (
<Button
type="button"
variant="outline"
data-guided-tour-action="previous"
data-guided-tour-id={testId}
className="h-auto min-h-9 min-w-0 flex-1 whitespace-normal focus-visible:ring-blue-500/60"
>
<ChevronLeft className="size-4" />
{t('guidedTour.previous')}
</Button>
)}
<Button
ref={nextButtonRef}
type="button"
data-guided-tour-action="next"
data-guided-tour-id={testId}
className="h-auto min-h-9 min-w-0 flex-1 whitespace-normal focus-visible:ring-blue-500/60"
>
{current === total ? (
<Check className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
{current === total ? t('guidedTour.finish') : t('guidedTour.next')}
</Button>
</div>
</div>
);
return createPortal(
<div
data-testid={testId}
data-active-step={stepId}
data-tour-modal={modal}
translate="no"
className="notranslate pointer-events-none"
>
{modal && (
<div
className="pointer-events-auto fixed inset-0 z-[90] cursor-default"
aria-hidden="true"
onClick={(event) => event.preventDefault()}
/>
)}
<div
data-testid={`${testId}-highlight`}
aria-hidden="true"
className={cn(
'pointer-events-none fixed rounded-md ring-2 ring-blue-500 dark:ring-blue-400 ring-offset-2 ring-offset-background shadow-[0_0_0_9999px_rgb(15_23_42_/_0.4)] dark:shadow-[0_0_0_9999px_rgb(0_0_0_/_0.72)] transition-[top,left,width,height] duration-200 ease-out motion-reduce:transition-none',
modal ? 'z-[91]' : 'z-[61]',
)}
style={{
top: targetRect.top,
left: targetRect.left,
width: targetRect.width,
height: targetRect.height,
}}
/>
{modal ? (
<DialogPrimitive.Root
open
onOpenChange={(open) => {
if (!open) onSkip();
}}
>
<DialogPrimitive.Content
asChild
onPointerDownOutside={(event) => event.preventDefault()}
onOpenAutoFocus={(event) => {
event.preventDefault();
nextButtonRef.current?.focus({ preventScroll: true });
}}
>
{card}
</DialogPrimitive.Content>
</DialogPrimitive.Root>
) : (
card
)}
</div>,
document.body,
);
}
@@ -1,14 +1,13 @@
import { createPortal } from 'react-dom';
import TourOverlay, {
getTourPosition,
type TargetRect,
type PopoverPosition,
} from '../guided-tour/TourOverlay';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { useSidebar } from '@/components/ui/sidebar';
const SIDEBAR_GUIDE_STORAGE_KEY = 'langbot_sidebar_guide_v1';
const MIN_POPOVER_WIDTH = 196;
const MAX_POPOVER_WIDTH = 320;
const VIEWPORT_GAP = 12;
const GUIDE_STEP_IDS = [
'monitoring',
@@ -23,21 +22,6 @@ const GUIDE_STEP_IDS = [
type GuideStepId = (typeof GUIDE_STEP_IDS)[number];
type TargetRect = {
top: number;
right: number;
bottom: number;
left: number;
width: number;
height: number;
};
type PopoverPosition = {
left: number;
top: number;
width: number;
};
function loadStoredStep(): number | null {
if (typeof window === 'undefined') return null;
@@ -68,18 +52,18 @@ function getTarget(stepId: GuideStepId): HTMLElement | null {
);
}
function findAvailableStep(startIndex: number): number | null {
for (let index = startIndex; index < GUIDE_STEP_IDS.length; index += 1) {
function findAvailableStep(startIndex: number, direction = 1): number | null {
for (
let index = startIndex;
index >= 0 && index < GUIDE_STEP_IDS.length;
index += direction
) {
const target = getTarget(GUIDE_STEP_IDS[index]);
if (target) return index;
}
return null;
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), Math.max(min, max));
}
export function SidebarGuide() {
const { t } = useTranslation();
const { isMobile, open, openMobile, setOpen, setOpenMobile } = useSidebar();
@@ -88,7 +72,6 @@ export function SidebarGuide() {
const [popoverPosition, setPopoverPosition] =
useState<PopoverPosition | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const confirmButtonRef = useRef<HTMLButtonElement | null>(null);
const originalSidebarStateRef = useRef<{
open: boolean;
openMobile: boolean;
@@ -144,45 +127,9 @@ export function SidebarGuide() {
};
setTargetRect(nextRect);
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const popoverHeight = popoverRef.current?.offsetHeight ?? 210;
const rightSpace = viewportWidth - nextRect.right - VIEWPORT_GAP * 2;
const leftSpace = nextRect.left - VIEWPORT_GAP * 2;
let width = Math.min(MAX_POPOVER_WIDTH, viewportWidth - VIEWPORT_GAP * 2);
let left = VIEWPORT_GAP;
let top = nextRect.bottom + VIEWPORT_GAP;
if (rightSpace >= MIN_POPOVER_WIDTH) {
width = Math.min(MAX_POPOVER_WIDTH, rightSpace);
left = nextRect.right + VIEWPORT_GAP;
top = clamp(
nextRect.top,
VIEWPORT_GAP,
viewportHeight - popoverHeight - VIEWPORT_GAP,
);
} else if (leftSpace >= MIN_POPOVER_WIDTH) {
width = Math.min(MAX_POPOVER_WIDTH, leftSpace);
left = nextRect.left - VIEWPORT_GAP - width;
top = clamp(
nextRect.top,
VIEWPORT_GAP,
viewportHeight - popoverHeight - VIEWPORT_GAP,
);
} else {
top =
nextRect.bottom + VIEWPORT_GAP + popoverHeight <= viewportHeight
? nextRect.bottom + VIEWPORT_GAP
: nextRect.top - popoverHeight - VIEWPORT_GAP;
top = clamp(
top,
VIEWPORT_GAP,
viewportHeight - popoverHeight - VIEWPORT_GAP,
);
}
setPopoverPosition({ left, top, width });
setPopoverPosition(
getTourPosition(nextRect, popoverRef.current?.offsetHeight),
);
}, [activeStepId]);
useEffect(() => {
@@ -224,6 +171,7 @@ export function SidebarGuide() {
target.scrollIntoView({ block: 'nearest' });
const frame = window.requestAnimationFrame(measure);
const delayedMeasure = window.setTimeout(measure, 260);
const resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(target);
@@ -231,6 +179,7 @@ export function SidebarGuide() {
window.addEventListener('scroll', measure, true);
return () => {
window.cancelAnimationFrame(frame);
window.clearTimeout(delayedMeasure);
resizeObserver.disconnect();
window.removeEventListener('resize', measure);
@@ -241,7 +190,6 @@ export function SidebarGuide() {
useEffect(() => {
if (!isPopoverReady) return;
measure();
confirmButtonRef.current?.focus();
}, [activeStepId, isPopoverReady, measure]);
useEffect(() => {
@@ -249,24 +197,19 @@ export function SidebarGuide() {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const blockKeyboardNavigation = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
}
if (event.key === 'Tab') {
event.preventDefault();
confirmButtonRef.current?.focus();
}
};
window.addEventListener('keydown', blockKeyboardNavigation, true);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', blockKeyboardNavigation, true);
};
}, [stepIndex]);
function handlePrevious() {
if (stepIndex === null) return;
const previousIndex = findAvailableStep(stepIndex - 1, -1);
if (previousIndex === null) return;
storeGuideProgress(String(previousIndex));
setStepIndex(previousIndex);
}
function handleConfirm() {
if (stepIndex === null) return;
const nextIndex = findAvailableStep(stepIndex + 1);
@@ -276,8 +219,6 @@ export function SidebarGuide() {
}
storeGuideProgress(String(nextIndex));
setTargetRect(null);
setPopoverPosition(null);
setStepIndex(nextIndex);
}
@@ -290,70 +231,25 @@ export function SidebarGuide() {
return null;
}
const isLastVisibleStep =
findAvailableStep((stepIndex ?? GUIDE_STEP_IDS.length - 1) + 1) === null;
const titleId = `sidebar-guide-${activeStepId}-title`;
return createPortal(
<div data-testid="sidebar-guide">
<div
className="fixed inset-0 z-[90] cursor-default"
aria-hidden="true"
onClick={(event) => event.preventDefault()}
/>
<div
className="pointer-events-none fixed z-[91] rounded-md ring-2 ring-blue-500 ring-offset-2 ring-offset-background transition-[top,left,width,height] duration-200"
aria-hidden="true"
style={{
top: targetRect.top,
left: targetRect.left,
width: targetRect.width,
height: targetRect.height,
boxShadow: '0 0 0 9999px rgb(15 23 42 / 0.56)',
}}
/>
<div
ref={popoverRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="fixed z-[92] rounded-lg border bg-popover p-4 text-popover-foreground shadow-xl"
style={popoverPosition}
>
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
{t('sidebarGuide.label')}
</span>
<span className="text-xs tabular-nums text-muted-foreground">
{t('sidebarGuide.progress', {
current: visibleStepNumber,
total: visibleStepCount,
})}
</span>
</div>
<h2 id={titleId} className="text-base font-semibold">
{t(`sidebarGuide.steps.${activeStepId}.title`)}
</h2>
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
{t(`sidebarGuide.steps.${activeStepId}.description`)}
</p>
<Button
ref={confirmButtonRef}
type="button"
className="mt-4 w-full"
onClick={handleConfirm}
>
{isLastVisibleStep ? (
<Check className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
{isLastVisibleStep
? t('sidebarGuide.finish')
: t('sidebarGuide.confirm')}
</Button>
</div>
</div>,
document.body,
return (
<TourOverlay
testId="sidebar-guide"
stepId={activeStepId}
current={visibleStepNumber}
total={visibleStepCount}
title={t(`sidebarGuide.steps.${activeStepId}.title`)}
description={t(`sidebarGuide.steps.${activeStepId}.description`)}
targetRect={targetRect}
position={popoverPosition}
popoverRef={popoverRef}
modal
onPrevious={
findAvailableStep((stepIndex ?? 0) - 1, -1) !== null
? handlePrevious
: undefined
}
onNext={handleConfirm}
onSkip={completeGuide}
/>
);
}
@@ -56,6 +56,7 @@ interface ProcessorDetailWorkbenchProps {
debugDisconnectedLabel?: string;
unsavedLabel?: string;
monitoring?: ProcessorMonitoringView;
onViewChange?: (view: 'workbench' | 'monitoring') => void;
}
export default function ProcessorDetailWorkbench({
@@ -82,6 +83,7 @@ export default function ProcessorDetailWorkbench({
debugDisconnectedLabel,
unsavedLabel,
monitoring,
onViewChange,
}: ProcessorDetailWorkbenchProps) {
const [activeView, setActiveView] = useState<'workbench' | 'monitoring'>(
'workbench',
@@ -92,13 +94,18 @@ export default function ProcessorDetailWorkbench({
<Card
role="region"
aria-label={configTitle}
data-guide={`${saveFormId}-config`}
className="h-full min-h-[36rem] min-w-0 gap-0 overflow-hidden py-0 lg:min-h-0"
>
<CardHeader className="flex h-12 shrink-0 flex-row items-center gap-2 border-b px-4 font-medium [.border-b]:pb-0">
{configTabs ? (
<TabsList aria-label={configTitle}>
{configTabs.items.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
<TabsTrigger
key={tab.value}
value={tab.value}
data-guide={`${saveFormId}-tab-${tab.value}`}
>
{tab.icon}
{tab.label}
</TabsTrigger>
@@ -137,9 +144,11 @@ export default function ProcessorDetailWorkbench({
return (
<Tabs
value={activeView}
onValueChange={(value) =>
setActiveView(value as 'workbench' | 'monitoring')
}
onValueChange={(value) => {
const view = value as 'workbench' | 'monitoring';
setActiveView(view);
onViewChange?.(view);
}}
className="flex h-full min-h-0 min-w-0 flex-col gap-0"
>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-4">
@@ -162,7 +171,11 @@ export default function ProcessorDetailWorkbench({
</span>
)}
</TabsTrigger>
<TabsTrigger value="monitoring" className="gap-1.5 px-3">
<TabsTrigger
value="monitoring"
className="gap-1.5 px-3"
data-guide={`${saveFormId}-monitoring`}
>
<BarChart3 className="size-4" />
{monitoring.label}
</TabsTrigger>
@@ -217,6 +230,7 @@ export default function ProcessorDetailWorkbench({
<Button
type="submit"
form={saveFormId}
data-guide={`${saveFormId}-save`}
disabled={!isDirty || isSaving}
>
{saveLabel}
@@ -258,6 +272,7 @@ export default function ProcessorDetailWorkbench({
<Card
role="region"
aria-label={debugTitle}
data-guide={`${saveFormId}-debug`}
className="min-h-[32rem] min-w-0 gap-0 overflow-hidden py-0 lg:min-h-0"
>
<CardHeader className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4 [.border-b]:pb-0">
@@ -347,7 +347,7 @@ export default function KBForm({
const steps: GuidedTourStep[] = [
{
id: 'engine',
target: '[data-guide="knowledge-engine"]',
target: '[data-guide="knowledge-engine"] [role="combobox"]',
title: t('guidedTour.knowledge.engine.title'),
description: t('guidedTour.knowledge.engine.description'),
action: {
@@ -51,6 +51,10 @@ export default function PipelineDetailContent({
return () => setDetailEntityName(null);
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
const [activeView, setActiveView] = useState<'workbench' | 'monitoring'>(
'workbench',
);
useEffect(() => setActiveView('workbench'), [id]);
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false);
@@ -183,6 +187,7 @@ export default function PipelineDetailContent({
<>
<ProcessorDetailWorkbench
key={id}
onViewChange={setActiveView}
title={`${pipelineEmoji} ${pipelineName}`}
titleAction={
canManage ? (
@@ -199,6 +204,9 @@ export default function PipelineDetailContent({
<fieldset className="contents" disabled={!canManage}>
<PipelineFormComponent
ref={pipelineFormRef}
guideEnabled={canManage && activeView === 'workbench'}
debugGuideEnabled={canOperate}
monitoringGuideEnabled={canViewMonitoring}
onLegacyPipeline={setPipelineDetails}
pipelineId={id}
isEditMode={true}
@@ -1,3 +1,6 @@
import GuidedTour, {
type GuidedTourStep,
} from '@/app/home/components/guided-tour/GuidedTour';
import EntityLoadState from '@/components/EntityLoadState';
import { preserveRunnerConfig } from './RunnerConfigPreservation';
import type { ComponentProps } from 'react';
@@ -112,6 +115,9 @@ interface PipelineFormComponentProps {
onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void;
onLegacyPipeline?: (pipeline: Pipeline) => void;
guideEnabled?: boolean;
debugGuideEnabled?: boolean;
monitoringGuideEnabled?: boolean;
}
export interface PipelineFormHandle {
@@ -133,6 +139,9 @@ const PipelineFormComponent = forwardRef<
isEditMode,
pipelineId,
showButtons = true,
guideEnabled = false,
debugGuideEnabled = false,
monitoringGuideEnabled = false,
onDeletePipeline,
onCancel,
onDirtyChange,
@@ -230,6 +239,52 @@ const PipelineFormComponent = forwardRef<
const [activeSection, setActiveSection] = useState(
isEditMode ? 'trigger' : 'basic',
);
const guideSteps = useMemo<GuidedTourStep[]>(() => {
const sections = [
'trigger',
'ai',
'output',
'safety',
'extensions',
'basic',
];
return [
...sections.map((section) => ({
id: section,
target: `[data-guide="pipeline-section-${section}"]`,
title: t(`guidedTour.pipeline.${section}.title`),
description: t(`guidedTour.pipeline.${section}.description`),
onEnter: () => setActiveSection(section),
})),
...(debugGuideEnabled
? [
{
id: 'debug',
target: '[data-guide="pipeline-form-debug"]',
title: t('guidedTour.pipeline.debug.title'),
description: t('guidedTour.pipeline.debug.description'),
},
]
: []),
...(monitoringGuideEnabled
? [
{
id: 'monitoring',
target: '[data-guide="pipeline-form-monitoring"]',
title: t('guidedTour.pipeline.monitoring.title'),
description: t('guidedTour.pipeline.monitoring.description'),
},
]
: []),
{
id: 'save',
target: '[data-guide="pipeline-form-save"]',
title: t('guidedTour.pipeline.save.title'),
description: t('guidedTour.pipeline.save.description'),
onEnter: () => setActiveSection('trigger'),
},
];
}, [t, debugGuideEnabled, monitoringGuideEnabled]);
const primarySectionNames = ['trigger', 'ai', 'output'];
const primarySections = primarySectionNames
.map((name) => formLabelList.find((section) => section.name === name))
@@ -808,6 +863,12 @@ const PipelineFormComponent = forwardRef<
return (
<>
<GuidedTour
enabled={guideEnabled && isEditMode}
storageKey="langbot_pipeline_setup_guide_v1"
steps={guideSteps}
testId="pipeline-setup-guide"
/>
<div className="h-full p-0 flex flex-col">
<Form {...form}>
<form
@@ -828,6 +889,7 @@ const PipelineFormComponent = forwardRef<
return (
<TabsTrigger
key={section.name}
data-guide={`pipeline-section-${section.name}`}
value={section.name}
>
<Icon />
@@ -843,6 +905,7 @@ const PipelineFormComponent = forwardRef<
return (
<Button
key={section.name}
data-guide={`pipeline-section-${section.name}`}
type="button"
variant={
activeSection === section.name