mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 08:55:44 +08:00
feat(web): unify guided tours and complete translations
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user