Progressbar (#231)

* added basic progress bar

* step 1 turns green when any agent instructio is changed

* step 2 is done after playground chat

* step 3 turns green on publish

* step 4 turns green on use assistant

* step 1 turns green on copilot changes too

* reduced font size of the live workflow warning

* better hover texts for steps

* change progress bar style

* better tool tips

* reverted styling of use assistant button

* chat with assistant option collapses the left pane

* remove hide left panel button

* made progress bar hover text more prominent

* add labels to progress bar

* added tour for build

* added tour for test

* added tour for publish

* added tour for use step

* added tool tip for each step to click for tour

* refined wording in product tours

* added jobs and conversations to the product tour
This commit is contained in:
arkml 2025-09-07 19:12:50 +05:30 committed by GitHub
parent c793f0a344
commit d899966107
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 449 additions and 38 deletions

View file

@ -19,6 +19,7 @@ export function App({
isLiveWorkflow,
activePanel,
onTogglePanel,
onMessageSent,
}: {
hidden?: boolean;
projectId: string;
@ -29,6 +30,7 @@ export function App({
isLiveWorkflow: boolean;
activePanel: 'playground' | 'copilot';
onTogglePanel: () => void;
onMessageSent?: () => void;
}) {
const [counter, setCounter] = useState<number>(0);
const [showDebugMessages, setShowDebugMessages] = useState<boolean>(true);
@ -142,6 +144,7 @@ export function App({
showDebugMessages={showDebugMessages}
triggerCopilotChat={triggerCopilotChat}
isLiveWorkflow={isLiveWorkflow}
onMessageSent={onMessageSent}
/>
</div>
</Panel>

View file

@ -22,6 +22,7 @@ export function Chat({
showJsonMode = false,
triggerCopilotChat,
isLiveWorkflow,
onMessageSent,
}: {
projectId: string;
workflow: z.infer<typeof Workflow>;
@ -31,6 +32,7 @@ export function Chat({
showJsonMode?: boolean;
triggerCopilotChat?: (message: string) => void;
isLiveWorkflow: boolean;
onMessageSent?: () => void;
}) {
const conversationId = useRef<string | null>(null);
const [messages, setMessages] = useState<z.infer<typeof Message>[]>([]);
@ -158,6 +160,11 @@ export function Chat({
setMessages(updatedMessages);
setError(null);
setIsLastInteracted(true);
// Mark playground as tested when user sends a message
if (onMessageSent) {
onMessageSent();
}
}
// clean up event source on component unmount

View file

@ -2,8 +2,9 @@
import React from "react";
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Input } from "@heroui/react";
import { Button as CustomButton } from "@/components/ui/button";
import { RadioIcon, RedoIcon, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, SettingsIcon, ChevronDownIcon, ZapIcon, Clock, Plug } from "lucide-react";
import { RadioIcon, RedoIcon, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, SettingsIcon, ChevronDownIcon, ZapIcon, Clock, Plug, MessageCircleIcon } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { ProgressBar, ProgressStep } from "@/components/ui/progress-bar";
interface TopBarProps {
localProjectName: string;
@ -17,6 +18,10 @@ interface TopBarProps {
canUndo: boolean;
canRedo: boolean;
activePanel: 'playground' | 'copilot';
hasAgentInstructionChanges: boolean;
hasPlaygroundTested: boolean;
hasPublished: boolean;
hasClickedUse: boolean;
onUndo: () => void;
onRedo: () => void;
onDownloadJSON: () => void;
@ -24,6 +29,12 @@ interface TopBarProps {
onChangeMode: (mode: 'draft' | 'live') => void;
onRevertToLive: () => void;
onTogglePanel: () => void;
onUseAssistantClick: () => void;
onStartNewChatAndFocus: () => void;
onStartBuildTour?: () => void;
onStartTestTour?: () => void;
onStartPublishTour?: () => void;
onStartUseTour?: () => void;
}
export function TopBar({
@ -38,6 +49,10 @@ export function TopBar({
canUndo,
canRedo,
activePanel,
hasAgentInstructionChanges,
hasPlaygroundTested,
hasPublished,
hasClickedUse,
onUndo,
onRedo,
onDownloadJSON,
@ -45,10 +60,32 @@ export function TopBar({
onChangeMode,
onRevertToLive,
onTogglePanel,
onUseAssistantClick,
onStartNewChatAndFocus,
onStartBuildTour,
onStartTestTour,
onStartPublishTour,
onStartUseTour,
}: TopBarProps) {
const router = useRouter();
const params = useParams();
const projectId = typeof (params as any).projectId === 'string' ? (params as any).projectId : (params as any).projectId?.[0];
// Progress bar steps with completion logic and current step detection
const step1Complete = hasAgentInstructionChanges;
const step2Complete = hasPlaygroundTested && hasAgentInstructionChanges;
const step3Complete = hasPublished && hasPlaygroundTested && hasAgentInstructionChanges;
const step4Complete = hasClickedUse && hasPublished && hasPlaygroundTested && hasAgentInstructionChanges;
// Determine current step (first incomplete step)
const currentStep = !step1Complete ? 1 : !step2Complete ? 2 : !step3Complete ? 3 : !step4Complete ? 4 : null;
const progressSteps: ProgressStep[] = [
{ id: 1, label: "Build: Ask the copilot to create your assistant. Add tools and connect data sources.", completed: step1Complete, isCurrent: currentStep === 1 },
{ id: 2, label: "Test: Test out your assistant by chatting with it. Use 'Fix' and 'Explain' to improve it.", completed: step2Complete, isCurrent: currentStep === 2 },
{ id: 3, label: "Publish: Make it live with the Publish button. You can always switch back to draft.", completed: step3Complete, isCurrent: currentStep === 3 },
{ id: 4, label: "Use: Click the 'Use Assistant' button to chat, set triggers (like emails), or connect via API.", completed: step4Complete, isCurrent: currentStep === 4 },
];
return (
<div className="rounded-xl bg-white/70 dark:bg-zinc-800/70 shadow-sm backdrop-blur-sm border border-zinc-200 dark:border-zinc-800 px-5 py-2">
<div className="flex justify-between items-center">
@ -92,16 +129,33 @@ export function TopBar({
</Button>
) : null}
</div>
{showCopySuccess && <div className="flex items-center gap-2">
<div className="text-green-500">Copied to clipboard</div>
</div>}
{showBuildModeBanner && <div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<div className="text-blue-700 dark:text-blue-300 text-sm">
Switched to draft mode. You can now make changes to your workflow.
</div>
</div>}
{/* Progress Bar - Center */}
<div className="flex-1 flex justify-center">
<ProgressBar
steps={progressSteps}
onStepClick={(step) => {
if (step.id === 1 && onStartBuildTour) onStartBuildTour();
if (step.id === 2 && onStartTestTour) onStartTestTour();
if (step.id === 3 && onStartPublishTour) onStartPublishTour();
if (step.id === 4 && onStartUseTour) onStartUseTour();
}}
/>
</div>
{/* Right side buttons */}
<div className="flex items-center gap-2">
{showCopySuccess && <div className="flex items-center gap-2 mr-4">
<div className="text-green-500">Copied to clipboard</div>
</div>}
{showBuildModeBanner && <div className="flex items-center gap-2 mr-4">
<AlertTriangle className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<div className="text-blue-700 dark:text-blue-300 text-sm">
Switched to draft mode. You can now make changes to your workflow.
</div>
</div>}
{!isLive && <>
<CustomButton
@ -139,24 +193,41 @@ export function TopBar({
size="md"
className="gap-2 px-4 bg-blue-50 hover:bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-400 font-semibold text-sm border border-blue-200 dark:border-blue-700 shadow-sm"
startContent={<Plug size={16} />}
onPress={onUseAssistantClick}
>
Use Assistant
<ChevronDownIcon size={14} />
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Assistant access options">
<DropdownItem
key="chat"
startContent={<MessageCircleIcon size={16} />}
onPress={() => {
onUseAssistantClick();
onStartNewChatAndFocus();
}}
>
Chat with Assistant
</DropdownItem>
<DropdownItem
key="api-sdk"
startContent={<SettingsIcon size={16} />}
onPress={() => { if (projectId) { router.push(`/projects/${projectId}/config`); } }}
onPress={() => {
onUseAssistantClick();
if (projectId) { router.push(`/projects/${projectId}/config`); }
}}
>
API & SDK Settings
</DropdownItem>
<DropdownItem
key="manage-triggers"
startContent={<ZapIcon size={16} />}
onPress={() => { if (projectId) { router.push(`/projects/${projectId}/manage-triggers`); } }}
>
onPress={() => {
onUseAssistantClick();
if (projectId) { router.push(`/projects/${projectId}/manage-triggers`); }
}}
>
Manage Triggers
</DropdownItem>
</DropdownMenu>

View file

@ -61,7 +61,7 @@ interface StateItem {
chatKey: number;
lastUpdatedAt: string;
isLive: boolean;
agentInstructionsChanged: boolean;
}
interface State {
@ -656,6 +656,10 @@ function reducer(state: State, action: Action): State {
break;
}
case "update_agent": {
// Check if instructions are being changed
if (action.agent.instructions !== undefined) {
draft.agentInstructionsChanged = true;
}
// update agent data
draft.workflow.agents = draft.workflow.agents.map((agent) =>
@ -930,7 +934,7 @@ export function WorkflowEditor({
chatKey: 0,
lastUpdatedAt: workflow.lastUpdatedAt,
isLive,
agentInstructionsChanged: false,
}
});
@ -944,11 +948,16 @@ export function WorkflowEditor({
const [activePanel, setActivePanel] = useState<'playground' | 'copilot'>('copilot');
const [isInitialState, setIsInitialState] = useState(true);
const [showBuildModeBanner, setShowBuildModeBanner] = useState(false);
const [isLeftPanelCollapsed, setIsLeftPanelCollapsed] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [pendingAction, setPendingAction] = useState<Action | null>(null);
const [configKey, setConfigKey] = useState(0);
const [lastWorkflowId, setLastWorkflowId] = useState<string | null>(null);
const [showTour, setShowTour] = useState(true);
const [showBuildTour, setShowBuildTour] = useState(false);
const [showTestTour, setShowTestTour] = useState(false);
const [showPublishTour, setShowPublishTour] = useState(false);
const [showUseTour, setShowUseTour] = useState(false);
// Centralized mode transition handler
const handleModeTransition = useCallback((newMode: 'draft' | 'live', reason: 'publish' | 'view_live' | 'switch_draft' | 'modal_switch') => {
@ -994,6 +1003,71 @@ export function WorkflowEditor({
const [projectNameError, setProjectNameError] = useState<string | null>(null);
const [isEditingProjectName, setIsEditingProjectName] = useState<boolean>(false);
const [pendingProjectName, setPendingProjectName] = useState<string | null>(null);
// Build progress tracking - persists once set to true
const [hasAgentInstructionChanges, setHasAgentInstructionChanges] = useState<boolean>(() => {
return localStorage.getItem(`agent_instructions_changed_${projectId}`) === 'true';
});
// Test progress tracking - persists once set to true
const [hasPlaygroundTested, setHasPlaygroundTested] = useState<boolean>(() => {
return localStorage.getItem(`playground_tested_${projectId}`) === 'true';
});
// Publish progress tracking - persists once set to true
const [hasPublished, setHasPublished] = useState<boolean>(() => {
return localStorage.getItem(`has_published_${projectId}`) === 'true';
});
// Use progress tracking - persists once set to true
const [hasClickedUse, setHasClickedUse] = useState<boolean>(() => {
return localStorage.getItem(`has_clicked_use_${projectId}`) === 'true';
});
// Function to mark agent instructions as changed (persists in localStorage)
const markAgentInstructionsChanged = useCallback(() => {
if (!hasAgentInstructionChanges) {
setHasAgentInstructionChanges(true);
localStorage.setItem(`agent_instructions_changed_${projectId}`, 'true');
}
}, [hasAgentInstructionChanges, projectId]);
// Function to mark playground as tested (persists in localStorage)
const markPlaygroundTested = useCallback(() => {
if (!hasPlaygroundTested && hasAgentInstructionChanges) { // Only mark if step 1 is complete
setHasPlaygroundTested(true);
localStorage.setItem(`playground_tested_${projectId}`, 'true');
}
}, [hasPlaygroundTested, hasAgentInstructionChanges, projectId]);
// Function to mark as published (persists in localStorage)
const markAsPublished = useCallback(() => {
if (!hasPublished) {
setHasPublished(true);
localStorage.setItem(`has_published_${projectId}`, 'true');
}
}, [hasPublished, projectId]);
// Function to mark Use Assistant button as clicked (persists in localStorage)
const markUseAssistantClicked = useCallback(() => {
if (!hasClickedUse) {
setHasClickedUse(true);
localStorage.setItem(`has_clicked_use_${projectId}`, 'true');
}
}, [hasClickedUse, projectId]);
// Reference to start new chat function from playground
const startNewChatRef = useRef<(() => void) | null>(null);
// Function to start new chat and focus
const handleStartNewChatAndFocus = useCallback(() => {
if (startNewChatRef.current) {
startNewChatRef.current();
}
// Switch to playground (chat) mode and collapse left panel
setActivePanel('playground');
setIsLeftPanelCollapsed(true);
}, []);
// Load agent order from localStorage on mount
// useEffect(() => {
@ -1061,6 +1135,13 @@ export function WorkflowEditor({
}
}, [state.present.workflow, state.present.pendingChanges]);
// Track agent instruction changes from copilot
useEffect(() => {
if (state.present.agentInstructionsChanged) {
markAgentInstructionsChanged();
}
}, [state.present.agentInstructionsChanged, markAgentInstructionsChanged]);
function handleSelectAgent(name: string) {
dispatch({ type: "select_agent", name });
}
@ -1158,6 +1239,10 @@ export function WorkflowEditor({
}
function handleUpdateAgent(name: string, agent: Partial<z.infer<typeof WorkflowAgent>>) {
// Check if instructions are being changed
if (agent.instructions !== undefined) {
markAgentInstructionsChanged();
}
dispatch({ type: "update_agent", name, agent });
}
@ -1236,6 +1321,7 @@ export function WorkflowEditor({
dispatch({ type: 'set_publishing', publishing: true });
try {
await publishWorkflow(projectId, state.present.workflow);
markAsPublished(); // Mark step 3 as completed when user publishes
// Use centralized mode transition for publish
handleModeTransition('live', 'publish');
// reflect live mode both internally and externally in one go
@ -1438,6 +1524,10 @@ export function WorkflowEditor({
}
}
function handleToggleLeftPanel() {
setIsLeftPanelCollapsed(!isLeftPanelCollapsed);
}
const validateProjectName = (value: string) => {
if (value.length === 0) {
setProjectNameError("Project name cannot be empty");
@ -1544,6 +1634,10 @@ export function WorkflowEditor({
canUndo={state.currentIndex > 0}
canRedo={state.currentIndex < state.patches.length}
activePanel={activePanel}
hasAgentInstructionChanges={hasAgentInstructionChanges}
hasPlaygroundTested={hasPlaygroundTested}
hasPublished={hasPublished}
hasClickedUse={hasClickedUse}
onUndo={() => dispatchGuarded({ type: "undo" })}
onRedo={() => dispatchGuarded({ type: "redo" })}
onDownloadJSON={handleDownloadJSON}
@ -1551,6 +1645,17 @@ export function WorkflowEditor({
onChangeMode={onChangeMode}
onRevertToLive={handleRevertToLive}
onTogglePanel={handleTogglePanel}
onUseAssistantClick={markUseAssistantClicked}
onStartNewChatAndFocus={handleStartNewChatAndFocus}
onStartBuildTour={() => setShowBuildTour(true)}
onStartTestTour={() => setShowTestTour(true)}
onStartPublishTour={() => {
if (isLive) {
handleModeTransition('draft', 'switch_draft');
}
setShowPublishTour(true);
}}
onStartUseTour={() => setShowUseTour(true)}
/>
{/* Content Area */}
@ -1559,6 +1664,7 @@ export function WorkflowEditor({
key={`entity-list-${state.present.selection ? '3-pane' : '2-pane'}`}
minSize={10}
defaultSize={PANEL_RATIOS.entityList}
className={isLeftPanelCollapsed ? 'hidden' : ''}
>
<div className="flex flex-col h-full">
<EntityList
@ -1612,7 +1718,7 @@ export function WorkflowEditor({
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle className={`w-[3px] bg-transparent ${!state.present.selection ? 'hidden' : ''}`} />
<ResizableHandle withHandle className={`w-[3px] bg-transparent ${(isLeftPanelCollapsed || !state.present.selection) ? 'hidden' : ''}`} />
{/* Config Panel - always rendered, visibility controlled */}
<ResizablePanel
@ -1705,8 +1811,8 @@ export function WorkflowEditor({
</Panel>
)}
</ResizablePanel>
{/* Second handle - always show (between config and chat panels) */}
<ResizableHandle withHandle className="w-[3px] bg-transparent" />
{/* Second handle - between config and chat panels */}
<ResizableHandle withHandle className={`w-[3px] bg-transparent ${(isLeftPanelCollapsed && !state.present.selection) ? 'hidden' : ''}`} />
{/* ChatApp/Copilot Panel - always visible */}
<ResizablePanel
@ -1726,6 +1832,7 @@ export function WorkflowEditor({
isLiveWorkflow={isLive}
activePanel={activePanel}
onTogglePanel={handleTogglePanel}
onMessageSent={markPlaygroundTested}
/>
</div>
<div className={(activePanel === 'copilot') ? 'block h-full' : 'hidden h-full'}>
@ -1761,6 +1868,65 @@ export function WorkflowEditor({
onComplete={() => setShowTour(false)}
/>
)}
{showBuildTour && (
<ProductTour
projectId={projectId}
forceStart
stepsOverride={[
{ target: 'copilot', title: 'Step 1/5', content: 'Use Copilot to create and refine agents. Describe what you need, then iterate with its suggestions.' },
{ target: 'entity-agents', title: 'Step 2/5', content: 'All your agents appear here. Adjust instructions, switch models, and fine-tune their behavior.' },
{ target: 'entity-tools', title: 'Step 3/5', content: 'Pick from thousands of ready-made tools or connect your own MCP servers.' },
{ target: 'entity-data', title: 'Step 4/5', content: 'Upload files, scrape websites, or add free-text knowledge to guide your agents.' },
{ target: 'entity-prompts', title: 'Step 5/5', content: 'Define reusable context variables automatically shared across all agents.' },
]}
onStepChange={(_, step) => {
if (step.target === 'copilot') setActivePanel('copilot');
}}
onComplete={() => setShowBuildTour(false)}
/>
)}
{showTestTour && (
<ProductTour
projectId={projectId}
forceStart
stepsOverride={[
{ target: 'playground', title: 'Step 1/2', content: 'Chat with your assistant to test it. Send messages, watch tool calls in action, and debug agent flows.' },
{ target: 'copilot', title: 'Step 2/2', content: 'Ask Copilot to improve your agents based on test results. Use “Fix” and “Explain” to iterate quickly.' },
]}
onStepChange={(index) => {
if (index === 0) setActivePanel('playground');
if (index === 1) setActivePanel('copilot');
}}
onComplete={() => setShowTestTour(false)}
/>
)}
{showUseTour && (
<ProductTour
projectId={projectId}
forceStart
stepsOverride={[
{ target: 'playground', title: 'Step 1/5', content: 'Chat: you can chat with your assistant here.' },
{ target: 'triggers', title: 'Step 2/5', content: 'Triggers: set up external (webhook/integration) or time-based schedules.' },
{ target: 'jobs', title: 'Step 3/5', content: 'Jobs: monitor your trigger runs and scheduled tasks here.' },
{ target: 'settings', title: 'Step 4/5', content: 'Settings: find API keys to connect with the API and SDK.' },
{ target: 'conversations', title: 'Step 5/5', content: 'Conversations: see all past interactions in one place, including manual chats, trigger activity, and API calls.' },
]}
onStepChange={(index) => {
if (index === 0) setActivePanel('playground');
}}
onComplete={() => setShowUseTour(false)}
/>
)}
{showPublishTour && (
<ProductTour
projectId={projectId}
forceStart
stepsOverride={[
{ target: 'deploy', title: 'Publish', content: 'Click Publish to make your workflow live, enabling triggers and API/SDK access. You can revert to a draft at any time.' },
]}
onComplete={() => setShowPublishTour(false)}
/>
)}
{/* Revert to Live Confirmation Modal */}
<Modal isOpen={isRevertModalOpen} onClose={onRevertModalClose}>

View file

@ -193,7 +193,19 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
: 'text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800/50 hover:text-zinc-900 dark:hover:text-zinc-300'
}
`}
data-tour-target={item.href === 'config' ? 'settings' : item.href === 'sources' ? 'entity-data-sources' : undefined}
data-tour-target={
item.href === 'config'
? 'settings'
: item.href === 'sources'
? 'entity-data-sources'
: item.href === 'manage-triggers'
? 'triggers'
: item.href === 'jobs'
? 'jobs'
: item.href === 'conversations'
? 'conversations'
: undefined
}
>
<Icon
size={COLLAPSED_ICON_SIZE}
@ -218,7 +230,19 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
: 'text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800/50 hover:text-zinc-900 dark:hover:text-zinc-300'
}
`}
data-tour-target={item.href === 'config' ? 'settings' : item.href === 'sources' ? 'entity-data-sources' : undefined}
data-tour-target={
item.href === 'config'
? 'settings'
: item.href === 'sources'
? 'entity-data-sources'
: item.href === 'manage-triggers'
? 'triggers'
: item.href === 'jobs'
? 'jobs'
: item.href === 'conversations'
? 'conversations'
: undefined
}
>
<Icon
size={EXPANDED_ICON_SIZE}
@ -375,4 +399,4 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
</>
);
}
}

View file

@ -2,7 +2,7 @@ import { useFloating, offset, flip, shift, arrow, FloatingArrow, FloatingPortal,
import { useCallback, useEffect, useRef, useState } from 'react';
import { XIcon } from 'lucide-react';
interface TourStep {
export interface TourStep {
target: string;
content: string;
title: string;
@ -59,7 +59,7 @@ const TOUR_STEPS: TourStep[] = [
function TourBackdrop({ targetElement }: { targetElement: Element | null }) {
const [rect, setRect] = useState<DOMRect | null>(null);
const isPanelTarget = targetElement?.getAttribute('data-tour-target') &&
['entity-agents', 'entity-tools', 'entity-prompts', 'copilot', 'playground'].includes(
['entity-agents', 'entity-tools', 'entity-prompts', 'copilot', 'playground', 'settings', 'triggers', 'jobs', 'conversations'].includes(
targetElement.getAttribute('data-tour-target')!
);
@ -136,28 +136,36 @@ function TourBackdrop({ targetElement }: { targetElement: Element | null }) {
export function ProductTour({
projectId,
onComplete
onComplete,
stepsOverride,
forceStart = false,
onStepChange,
}: {
projectId: string;
onComplete: () => void;
stepsOverride?: TourStep[];
forceStart?: boolean;
onStepChange?: (index: number, step: TourStep) => void;
}) {
const steps = stepsOverride && stepsOverride.length > 0 ? stepsOverride : TOUR_STEPS;
const [currentStep, setCurrentStep] = useState(0);
const [shouldShow, setShouldShow] = useState(true);
const arrowRef = useRef(null);
// Check if tour has been completed by the user
// Check if tour has been completed by the user, unless forced
useEffect(() => {
if (forceStart) return;
const tourCompleted = localStorage.getItem('user_product_tour_completed');
if (tourCompleted) {
setShouldShow(false);
}
}, []);
}, [forceStart]);
const currentTarget = TOUR_STEPS[currentStep].target;
const targetElement = document.querySelector(`[data-tour-target="${currentTarget}"]`);
const currentTarget = steps[currentStep].target;
const [targetElement, setTargetElement] = useState<Element | null>(null);
// Determine if the target is a panel that should have the hint on the side
const isPanelTarget = ['entity-agents', 'entity-tools', 'entity-prompts', 'copilot', 'playground'].includes(currentTarget);
const isPanelTarget = ['entity-agents', 'entity-tools', 'entity-prompts', 'copilot', 'playground', 'entity-data', 'settings', 'triggers', 'jobs', 'conversations'].includes(currentTarget);
const { x, y, strategy, refs, context, middlewareData } = useFloating({
placement: isPanelTarget ? 'right' : 'top',
@ -177,15 +185,33 @@ export function ProductTour({
whileElementsMounted: autoUpdate
});
// Update reference element when step changes
// Update reference element when step changes and notify parent first, then resolve target element
useEffect(() => {
if (targetElement) {
refs.setReference(targetElement);
let raf1: number | undefined;
let raf2: number | undefined;
if (onStepChange) {
onStepChange(currentStep, steps[currentStep]);
}
}, [currentStep, targetElement, refs]);
// Give the parent a frame to update DOM (e.g., switching panels), then query element
raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
const el = document.querySelector(`[data-tour-target="${currentTarget}"]`);
setTargetElement(el);
if (el) refs.setReference(el as any);
});
});
return () => {
if (raf1) cancelAnimationFrame(raf1);
if (raf2) cancelAnimationFrame(raf2);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep, currentTarget]);
const handleNext = useCallback(() => {
if (currentStep < TOUR_STEPS.length - 1) {
if (currentStep < steps.length - 1) {
setCurrentStep(prev => prev + 1);
} else {
// Mark tour as completed for the user
@ -195,7 +221,7 @@ export function ProductTour({
setShouldShow(false);
onComplete();
}
}, [currentStep, projectId, onComplete]);
}, [currentStep, projectId, onComplete, steps.length]);
const handleSkip = useCallback(() => {
// Mark tour as completed for the user
@ -235,10 +261,10 @@ export function ProductTour({
<XIcon size={16} />
</button>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{TOUR_STEPS[currentStep].title}
{steps[currentStep].title}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3 whitespace-pre-line [&>a]:underline"
dangerouslySetInnerHTML={{ __html: TOUR_STEPS[currentStep].content }}
dangerouslySetInnerHTML={{ __html: steps[currentStep].content }}
/>
<div className="flex justify-between items-center">
<button
@ -263,4 +289,4 @@ export function ProductTour({
</div>
</FloatingPortal>
);
}
}

View file

@ -0,0 +1,114 @@
"use client";
import React from 'react';
import { cn } from "../../lib/utils";
import { Tooltip } from "@heroui/react";
export interface ProgressStep {
id: number;
label: string;
completed: boolean;
icon?: string; // The icon/symbol to show instead of number
isCurrent?: boolean; // Whether this is the current step
shortLabel?: string; // Optional short label to show inline on larger screens
}
interface ProgressBarProps {
steps: ProgressStep[];
className?: string;
onStepClick?: (step: ProgressStep, index: number) => void;
}
export function ProgressBar({ steps, className, onStepClick }: ProgressBarProps) {
const getShortLabel = (label: string) => {
if (!label) return "";
const beforeColon = label.split(":")[0]?.trim();
if (beforeColon) return beforeColon;
const firstWord = label.split(" ")[0]?.trim();
return firstWord || label;
};
return (
<nav aria-label="Workflow progress" className={cn("flex items-center gap-4", className)}>
{/* Progress Label */}
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 mr-2">
Progress:
</span>
{/* Steps */}
<ol role="list" className="flex items-center gap-2">
{steps.map((step, index) => {
const isLast = index === steps.length - 1;
const tooltipText = (() => {
switch (step.id) {
case 1:
return 'Build your assistant - click for tour';
case 2:
return 'Test your assistant - click for tour';
case 3:
return 'Make assistant live - click for tour';
case 4:
return 'Interact with your assistant - click for tour';
default:
return 'Click for tour';
}
})();
return (
<li key={step.id} className="flex items-center">
{/* Step Circle with Tooltip */}
<div className="flex flex-col items-center">
<Tooltip
content={tooltipText}
size="lg"
delay={100}
placement="bottom"
classNames={{ content: "text-base" }}
>
<div
tabIndex={0}
aria-label={`${step.completed ? "Completed" : step.isCurrent ? "Current" : "Pending"} step ${step.id}: ${step.label}`}
aria-current={step.isCurrent ? "step" : undefined}
role={onStepClick ? 'button' as const : undefined}
onClick={onStepClick ? () => onStepClick(step, index) : undefined}
onKeyDown={onStepClick ? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onStepClick(step, index);
}
} : undefined}
className={cn(
"w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-semibold transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400",
step.completed
? "bg-green-500 border-green-500 text-white"
: step.isCurrent
? "bg-yellow-500 border-yellow-500 text-white ring-2 ring-yellow-300/60 shadow-sm"
: "bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400"
, onStepClick ? "cursor-pointer hover:scale-105" : "cursor-default")}
>
{step.completed ? "✓" : step.isCurrent ? "⚡" : "○"}
</div>
</Tooltip>
<span className="hidden md:block mt-1 text-[11px] leading-none text-gray-700 dark:text-gray-300 font-medium">
{step.shortLabel ?? getShortLabel(step.label)}
</span>
</div>
{/* Connecting Line */}
{!isLast && (
<div
aria-hidden
className={cn(
"h-0.5 w-8 mx-2 transition-all duration-300 motion-reduce:transition-none",
step.completed
? "bg-green-500"
: "border-t-2 border-dashed border-gray-300 dark:border-gray-600"
)}
/>
)}
</li>
);
})}
</ol>
</nav>
);
}