feat(web): add sidebar onboarding guide

This commit is contained in:
fdc310
2026-09-17 05:05:01 +08:00
parent 03854b5d33
commit c4e14516ed
8 changed files with 642 additions and 5 deletions
@@ -123,6 +123,7 @@ import {
useWorkspaceQuotaStatus,
} from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
import { SidebarGuide } from './SidebarGuide';
// Compare two version strings, returns true if v1 > v2
function compareVersions(v1: string, v2: string): boolean {
@@ -588,7 +589,7 @@ function NavItems({
}
// Non-entity entries (e.g. monitoring and the extension market) render as plain links.
return (
<SidebarMenuItem key={config.id}>
<SidebarMenuItem key={config.id} data-sidebar-guide={config.id}>
<SidebarMenuButton
isActive={selectedChild?.id === config.id}
onClick={() => onChildClick(config)}
@@ -1017,7 +1018,7 @@ function NavItems({
// Popover flyout for collapsed sidebar
if (showPopover) {
return (
<SidebarMenuItem key={config.id}>
<SidebarMenuItem key={config.id} data-sidebar-guide={config.id}>
<Popover
open={popoverOpen[config.id] ?? false}
onOpenChange={(open) =>
@@ -1199,7 +1200,7 @@ function NavItems({
onOpenChange={(open) => onSectionToggle(config.id, open)}
className="group/collapsible"
>
<SidebarMenuItem>
<SidebarMenuItem data-sidebar-guide={config.id}>
<SidebarMenuButton
asChild
isActive={false}
@@ -2210,7 +2211,7 @@ export default function HomeSidebar({
<SidebarFooter>
{/* Models entry */}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuItem data-sidebar-guide="models">
<SidebarMenuButton
onClick={() => openSettings('models')}
tooltip={t('models.title')}
@@ -2224,7 +2225,7 @@ export default function HomeSidebar({
{/* API-key management is available only to authorized Workspace roles. */}
{currentWorkspace?.permissions.includes('api_key.manage') && (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuItem data-sidebar-guide="api-integration">
<SidebarMenuButton
onClick={() => openSettings('apiIntegration')}
tooltip={t('common.apiIntegration')}
@@ -2456,6 +2457,7 @@ export default function HomeSidebar({
onOpenChange={setVersionDialogOpen}
release={latestRelease}
/>
<SidebarGuide />
</>
);
}
@@ -0,0 +1,359 @@
import { createPortal } from 'react-dom';
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',
'bots',
'pipelines',
'knowledge',
'plugins',
'add-extension',
'models',
'api-integration',
] as const;
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;
try {
const stored = localStorage.getItem(SIDEBAR_GUIDE_STORAGE_KEY);
if (stored === 'completed') return null;
const parsed = Number.parseInt(stored ?? '0', 10);
if (!Number.isFinite(parsed) || parsed < 0) return 0;
return Math.min(parsed, GUIDE_STEP_IDS.length - 1);
} catch {
return 0;
}
}
function storeGuideProgress(value: string) {
try {
localStorage.setItem(SIDEBAR_GUIDE_STORAGE_KEY, value);
} catch {
// The guide remains usable when browser storage is unavailable.
}
}
function getTarget(stepId: GuideStepId): HTMLElement | null {
if (typeof document === 'undefined') return null;
return document.querySelector<HTMLElement>(
`[data-sidebar-guide="${stepId}"]`,
);
}
function findAvailableStep(startIndex: number): number | null {
for (let index = startIndex; index < GUIDE_STEP_IDS.length; index += 1) {
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();
const [stepIndex, setStepIndex] = useState<number | null>(loadStoredStep);
const [targetRect, setTargetRect] = useState<TargetRect | null>(null);
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;
} | null>(null);
const activeStepId =
stepIndex === null ? null : (GUIDE_STEP_IDS[stepIndex] ?? null);
const visibleStepCount = GUIDE_STEP_IDS.filter((stepId) =>
getTarget(stepId),
).length;
const visibleStepNumber = activeStepId
? GUIDE_STEP_IDS.slice(0, stepIndex ?? 0).filter((stepId) =>
getTarget(stepId),
).length + 1
: 0;
const isPopoverReady = popoverPosition !== null;
const restoreSidebarState = useCallback(() => {
const original = originalSidebarStateRef.current;
if (!original) return;
if (isMobile) {
setOpenMobile(original.openMobile);
} else {
setOpen(original.open);
}
originalSidebarStateRef.current = null;
}, [isMobile, setOpen, setOpenMobile]);
const completeGuide = useCallback(() => {
storeGuideProgress('completed');
setStepIndex(null);
setTargetRect(null);
setPopoverPosition(null);
restoreSidebarState();
}, [restoreSidebarState]);
const measure = useCallback(() => {
if (!activeStepId) return;
const target = getTarget(activeStepId);
if (!target) return;
const rect = target.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const nextRect: TargetRect = {
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
width: rect.width,
height: rect.height,
};
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 });
}, [activeStepId]);
useEffect(() => {
if (stepIndex === null) return;
if (!originalSidebarStateRef.current) {
originalSidebarStateRef.current = { open, openMobile };
}
if (isMobile) {
setOpenMobile(true);
} else {
setOpen(true);
}
}, [isMobile, open, openMobile, setOpen, setOpenMobile, stepIndex]);
useEffect(() => {
if (stepIndex === null) return;
const frame = window.requestAnimationFrame(() => {
const availableIndex = findAvailableStep(stepIndex);
if (availableIndex === null) {
completeGuide();
return;
}
if (availableIndex !== stepIndex) {
storeGuideProgress(String(availableIndex));
setStepIndex(availableIndex);
}
});
return () => window.cancelAnimationFrame(frame);
}, [completeGuide, stepIndex]);
useEffect(() => {
if (!activeStepId) return;
const target = getTarget(activeStepId);
if (!target) return;
target.scrollIntoView({ block: 'nearest' });
const delayedMeasure = window.setTimeout(measure, 260);
const resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(target);
window.addEventListener('resize', measure);
window.addEventListener('scroll', measure, true);
return () => {
window.clearTimeout(delayedMeasure);
resizeObserver.disconnect();
window.removeEventListener('resize', measure);
window.removeEventListener('scroll', measure, true);
};
}, [activeStepId, measure]);
useEffect(() => {
if (!isPopoverReady) return;
measure();
confirmButtonRef.current?.focus();
}, [activeStepId, isPopoverReady, measure]);
useEffect(() => {
if (stepIndex === null) return;
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 handleConfirm() {
if (stepIndex === null) return;
const nextIndex = findAvailableStep(stepIndex + 1);
if (nextIndex === null) {
completeGuide();
return;
}
storeGuideProgress(String(nextIndex));
setTargetRect(null);
setPopoverPosition(null);
setStepIndex(nextIndex);
}
if (
!activeStepId ||
!targetRect ||
!popoverPosition ||
typeof document === 'undefined'
) {
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,
);
}
+48
View File
@@ -1,4 +1,52 @@
const enUS = {
sidebarGuide: {
label: 'Workspace tour',
progress: '{{current}} of {{total}}',
confirm: 'Got it',
finish: 'Finish tour',
steps: {
monitoring: {
title: 'Dashboard',
description:
'Review bot activity, model usage, message volume, and system performance at a glance.',
},
bots: {
title: 'Bots',
description:
'Connect LangBot to chat platforms and manage each bot connection from here.',
},
pipelines: {
title: 'Processors',
description:
'Build reusable AI pipelines, agents, and event processors that power your bots.',
},
knowledge: {
title: 'Knowledge bases',
description:
'Organize documents and external knowledge sources used to improve model responses.',
},
plugins: {
title: 'Installed extensions',
description:
'Manage installed plugins, MCP servers, and skills, including their runtime status.',
},
'add-extension': {
title: 'Add extensions',
description:
'Install capabilities from the marketplace, GitHub, or a local extension package.',
},
models: {
title: 'Model configuration',
description:
'Configure model providers and choose the language, embedding, and other models LangBot uses.',
},
'api-integration': {
title: 'API integration',
description:
'Create API keys and configure external access to LangBot services and MCP.',
},
},
},
sidebar: {
home: 'Home',
extensions: 'Extensions',
+48
View File
@@ -1,4 +1,52 @@
const jaJP = {
sidebarGuide: {
label: 'ワークスペースツアー',
progress: '{{current}} / {{total}}',
confirm: '確認しました',
finish: 'ツアーを完了',
steps: {
monitoring: {
title: 'ダッシュボード',
description:
'ボットの稼働状況、モデルの使用量、メッセージ数、システムの状態をまとめて確認できます。',
},
bots: {
title: 'ボット',
description:
'チャットプラットフォームに接続し、各ボットを作成・管理します。',
},
pipelines: {
title: 'プロセッサー',
description:
'ボットを動かす AI パイプライン、エージェント、イベントプロセッサーを作成します。',
},
knowledge: {
title: 'ナレッジベース',
description:
'ドキュメントや外部ナレッジを管理し、モデルの回答精度を高めます。',
},
plugins: {
title: 'インストール済み拡張機能',
description:
'プラグイン、MCP サーバー、スキルと、それぞれの実行状態を管理します。',
},
'add-extension': {
title: '拡張機能を追加',
description:
'マーケット、GitHub、ローカルパッケージから新しい機能を追加します。',
},
models: {
title: 'モデル設定',
description:
'モデルプロバイダーと、LangBot が使用する言語・埋め込みモデルなどを設定します。',
},
'api-integration': {
title: 'API 連携',
description:
'API キーを作成し、LangBot サービスや MCP への外部アクセスを設定します。',
},
},
},
sidebar: {
home: 'ホーム',
extensions: '拡張機能',
+44
View File
@@ -1,4 +1,48 @@
const zhHans = {
sidebarGuide: {
label: '工作台导览',
progress: '第 {{current}} 项,共 {{total}} 项',
confirm: '我知道了',
finish: '完成引导',
steps: {
monitoring: {
title: '仪表盘',
description: '集中查看机器人活动、模型调用、消息量和系统运行情况。',
},
bots: {
title: '机器人',
description: '连接聊天平台,并在这里创建和管理每一个机器人。',
},
pipelines: {
title: '处理器',
description:
'创建可复用的 AI 流水线、智能体和事件处理器,为机器人提供能力。',
},
knowledge: {
title: '知识库',
description:
'管理文档和外部知识源,帮助模型生成更准确、更贴合业务的回复。',
},
plugins: {
title: '已安装扩展',
description: '管理已安装的插件、MCP 服务和技能,并查看它们的运行状态。',
},
'add-extension': {
title: '添加扩展',
description: '从扩展市场、GitHub 或本地安装包为 LangBot 添加新能力。',
},
models: {
title: '模型配置',
description:
'配置模型供应商,以及 LangBot 使用的语言模型、嵌入模型等。',
},
'api-integration': {
title: 'API 集成',
description:
'创建 API 密钥,并配置外部系统访问 LangBot 服务和 MCP 的方式。',
},
},
},
sidebar: {
home: '首页',
extensions: '扩展',
+44
View File
@@ -1,4 +1,48 @@
const zhHant = {
sidebarGuide: {
label: '工作台導覽',
progress: '第 {{current}} 項,共 {{total}} 項',
confirm: '我知道了',
finish: '完成導覽',
steps: {
monitoring: {
title: '儀表板',
description: '集中查看機器人活動、模型呼叫、訊息量和系統運行情況。',
},
bots: {
title: '機器人',
description: '連接聊天平台,並在這裡建立和管理每一個機器人。',
},
pipelines: {
title: '處理器',
description:
'建立可重複使用的 AI 流程、代理和事件處理器,為機器人提供能力。',
},
knowledge: {
title: '知識庫',
description:
'管理文件和外部知識來源,協助模型產生更準確、更符合業務的回覆。',
},
plugins: {
title: '已安裝擴充功能',
description: '管理已安裝的外掛、MCP 服務和技能,並查看它們的運行狀態。',
},
'add-extension': {
title: '新增擴充功能',
description: '從擴充功能市集、GitHub 或本機安裝包為 LangBot 新增能力。',
},
models: {
title: '模型設定',
description:
'設定模型供應商,以及 LangBot 使用的語言模型、嵌入模型等。',
},
'api-integration': {
title: 'API 整合',
description:
'建立 API 金鑰,並設定外部系統存取 LangBot 服務和 MCP 的方式。',
},
},
},
sidebar: {
home: '首頁',
extensions: '擴展',
+3
View File
@@ -1295,6 +1295,9 @@ export async function installLangBotApiMocks(
({ authenticated, language, storage }) => {
localStorage.setItem('langbot_language', language);
localStorage.setItem('extensions_group_by_type', 'false');
if (!Object.hasOwn(storage, 'langbot_sidebar_guide_v1')) {
localStorage.setItem('langbot_sidebar_guide_v1', 'completed');
}
if (authenticated) {
localStorage.setItem('token', 'playwright-token');
+89
View File
@@ -0,0 +1,89 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
const guideSteps = [
'Dashboard',
'Bots',
'Processors',
'Knowledge bases',
'Installed extensions',
'Add extensions',
'Model configuration',
'API integration',
];
test('sidebar guide blocks navigation until every step is confirmed', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_sidebar_guide_v1: '0' },
});
await page.goto('/home/monitoring');
const guide = page.getByTestId('sidebar-guide');
await expect(guide).toHaveCount(1);
await expect(guide.getByRole('dialog')).toBeVisible();
await expect(guide.getByText('1 of 8')).toBeVisible();
const dashboardTarget = page.locator('[data-sidebar-guide="monitoring"]');
const targetBox = await dashboardTarget.boundingBox();
expect(targetBox).not.toBeNull();
const elementAtTargetCenter = await page.evaluate(
({ x, y }) => {
const element = document.elementFromPoint(x, y);
return element?.closest('[data-testid="sidebar-guide"]') !== null;
},
{
x: targetBox!.x + targetBox!.width / 2,
y: targetBox!.y + targetBox!.height / 2,
},
);
expect(elementAtTargetCenter).toBe(true);
for (const [index, title] of guideSteps.entries()) {
await expect(
guide.getByRole('heading', { name: title, exact: true }),
).toBeVisible();
const buttonName =
index === guideSteps.length - 1 ? 'Finish tour' : 'Got it';
await guide.getByRole('button', { name: buttonName }).click();
}
await expect(guide).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() => localStorage.getItem('langbot_sidebar_guide_v1')),
)
.toBe('completed');
await page.getByRole('button', { name: 'Add Extension' }).click();
await expect(page).toHaveURL(/\/home\/add-extension$/);
await page.reload();
await expect(guide).toHaveCount(0);
});
test('sidebar guide popover stays inside a narrow viewport', async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 760 });
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_sidebar_guide_v1: '0' },
});
await page.goto('/home/monitoring');
const dialog = page.getByRole('dialog', { name: 'Dashboard' });
await expect(dialog).toBeVisible();
const dialogBox = await dialog.boundingBox();
expect(dialogBox).not.toBeNull();
expect(dialogBox!.x).toBeGreaterThanOrEqual(0);
expect(dialogBox!.y).toBeGreaterThanOrEqual(0);
expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(390);
expect(dialogBox!.y + dialogBox!.height).toBeLessThanOrEqual(760);
});