Merge pull request #3 from dograh-hq/improve-oss-onboarding

Improve we call experience and add tooltips
This commit is contained in:
Abhishek 2025-09-15 16:33:56 +05:30 committed by GitHub
commit 7501a3fb5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 535 additions and 242 deletions

View file

@ -4,6 +4,7 @@ WORKDIR /app
ENV NEXT_PUBLIC_NODE_ENV=local
ENV NEXT_PUBLIC_AUTH_PROVIDER=local
ENV NEXT_PUBLIC_DEPLOYMENT_MODE=oss
ENV NEXT_PUBLIC_BACKEND_URL="http://localhost:8000"
ENV BACKEND_URL="http://api:8000"

View file

@ -1,14 +0,0 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function CreateWorkflowLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -79,7 +79,7 @@ export default function CreateWorkflowPage() {
};
return (
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center p-4 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
<div className="min-h-[100vh] flex items-center justify-center p-4 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
<Card className="w-full max-w-4xl shadow-xl border-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur">
<CardHeader className="text-center pb-4 pt-6">
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
@ -115,11 +115,8 @@ export default function CreateWorkflowPage() {
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
Use Case
</label>
<div className="flex items-start flex-col gap-2">
<span className="text-base font-medium text-gray-700 dark:text-gray-300">Which serves the use case:</span>
<span className="text-base font-medium text-gray-700 dark:text-gray-300">For the use case of</span>
<Input
className="w-full h-10 text-sm px-3 border-2 focus:ring-2 focus:ring-blue-500 transition-all"
placeholder="e.g., Lead Qualification, HR Screening, Customer Support"
@ -130,14 +127,11 @@ export default function CreateWorkflowPage() {
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
Activity Description
</label>
<div className="flex items-start flex-col gap-2">
<span className="text-base font-medium text-gray-700 dark:text-gray-300">Which can:</span>
<span className="text-base font-medium text-gray-700 dark:text-gray-300">Which can</span>
<textarea
className="w-full min-h-[80px] text-sm px-3 py-2 border-2 rounded-md focus:ring-2 focus:ring-blue-500 transition-all resize-none"
placeholder="Describe what your voice agent will do (e.g., Qualify leads for real estate, Screen candidates for roles, Handle customer support)"
placeholder="Describe briefly what your voice agent will do (e.g., Qualify leads for real estate, Screen candidates for roles, Handle customer support). This will be a prompt to an LLM."
value={activityDescription}
onChange={(e) => setActivityDescription(e.target.value)}
/>

View file

@ -7,6 +7,7 @@ import { Suspense } from "react";
import PostHogIdentify from "@/components/PostHogIdentify";
import SpinLoader from "@/components/SpinLoader";
import { Toaster } from "@/components/ui/sonner";
import { OnboardingProvider } from "@/context/OnboardingContext";
import { UserConfigProvider } from "@/context/UserConfigContext";
import { AuthProvider } from "@/lib/auth";
@ -39,9 +40,11 @@ export default function RootLayout({
<AuthProvider>
<Suspense fallback={<SpinLoader />}>
<UserConfigProvider>
<PostHogIdentify />
{children}
<Toaster />
<OnboardingProvider>
<PostHogIdentify />
{children}
<Toaster />
</OnboardingProvider>
</UserConfigProvider>
</Suspense>
</AuthProvider>

View file

@ -2,17 +2,20 @@ import 'react-international-phone/style.css';
import { ReactFlowInstance, ReactFlowJsonObject } from "@xyflow/react";
import { AlertTriangle, CheckCheck, Download, LoaderCircle, Phone, ShieldCheck } from "lucide-react";
import { useEffect,useState } from "react";
import { useEffect, useRef, useState } from "react";
import { PhoneInput } from 'react-international-phone';
import { initiateCallApiV1TwilioInitiateCallPost } from '@/client/sdk.gen';
import { WorkflowError } from '@/client/types.gen';
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { OnboardingTooltip } from '@/components/onboarding/OnboardingTooltip';
import { Button } from "@/components/ui/button";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useOnboarding } from '@/context/OnboardingContext';
import { useUserConfig } from "@/context/UserConfigContext";
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
interface WorkflowHeaderProps {
isDirty: boolean;
@ -55,6 +58,7 @@ const handleExport = (workflow_name: string, workflow_definition: ReactFlowJsonO
const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, workflowValidationErrors, saveWorkflow }: WorkflowHeaderProps) => {
const { userConfig, saveUserConfig } = useUserConfig();
const { hasSeenTooltip, markTooltipSeen } = useOnboarding();
const [dialogOpen, setDialogOpen] = useState(false);
const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || "");
const [saving, setSaving] = useState(false);
@ -65,10 +69,13 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
const [phoneChanged, setPhoneChanged] = useState(false);
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
const { user, getAccessToken } = useAuth();
const webCallButtonRef = useRef<HTMLButtonElement>(null);
const hasValidationErrors = workflowValidationErrors.length > 0;
const isOSSDeployment = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE === 'oss';
logger.info(`isOSSDeployment: ${isOSSDeployment}`);
// Reset call-related state whenever the dialog is closed so that a new call can be placed
useEffect(() => {
if (!dialogOpen) {
@ -188,9 +195,16 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
Export Pathway
</Button>
<Button
ref={webCallButtonRef}
variant="outline"
size="sm"
onClick={() => onRun("smallwebrtc")} // Don't change the mode since its defined in the database enum
onClick={() => {
// Mark the tooltip as seen when the button is clicked
if (!hasSeenTooltip('web_call')) {
markTooltipSeen('web_call');
}
onRun("smallwebrtc"); // Don't change the mode since its defined in the database enum
}}
disabled={hasValidationErrors}
>
<Phone className="mr-2 h-4 w-4" />
@ -317,6 +331,16 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
{callSuccessMsg && <div className="text-green-600 text-sm mt-2">{callSuccessMsg}</div>}
</DialogContent>
</Dialog>
{/* Onboarding Tooltip */}
<OnboardingTooltip
title='Test your Voice Agent'
targetRef={webCallButtonRef}
message="Test this workflow now in your browser (no phone required)"
onDismiss={() => markTooltipSeen('web_call')}
showNext={false}
isVisible={!hasSeenTooltip('web_call') && !hasValidationErrors}
/>
</div>
);
};

View file

@ -0,0 +1,150 @@
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from "@/client/sdk.gen";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
ApiKeyErrorDialog,
AudioControls,
ConnectionStatus,
WorkflowConfigErrorDialog
} from "./components";
import { useWebRTC } from "./hooks";
const BrowserCall = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: {
workflowId: number,
workflowRunId: number,
accessToken: string | null,
initialContextVariables?: Record<string, string> | null
}) => {
const router = useRouter();
const [checkingForRecording, setCheckingForRecording] = useState(false);
const {
audioRef,
audioInputs,
selectedAudioInput,
setSelectedAudioInput,
connectionActive,
permissionError,
isCompleted,
apiKeyModalOpen,
setApiKeyModalOpen,
apiKeyError,
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,
connectionStatus,
start,
stop,
isStarting
} = useWebRTC({ workflowId, workflowRunId, accessToken, initialContextVariables });
// Poll for recording availability after call ends
useEffect(() => {
if (!isCompleted || !accessToken) return;
setCheckingForRecording(true);
const intervalId = setInterval(async () => {
try {
const response = await getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet({
path: {
workflow_id: workflowId,
run_id: workflowRunId,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (response.data?.transcript_url || response.data?.recording_url) {
setCheckingForRecording(false);
clearInterval(intervalId);
// Refresh the page to show the recording
window.location.reload();
}
} catch (error) {
console.error('Error checking for recording:', error);
}
}, 5000); // Check every 5 seconds
// Clean up after 2 minutes
const timeoutId = setTimeout(() => {
clearInterval(intervalId);
setCheckingForRecording(false);
}, 120000);
return () => {
clearInterval(intervalId);
clearTimeout(timeoutId);
};
}, [isCompleted, accessToken, workflowId, workflowRunId]);
const navigateToApiKeys = () => {
router.push('/api-keys');
};
const navigateToWorkflow = () => {
router.push(`/workflow/${workflowId}`)
}
return (
<>
<Card className="w-full max-w-4xl mx-auto">
<CardHeader>
<CardTitle>Agent Run</CardTitle>
</CardHeader>
<CardContent>
{isCompleted && checkingForRecording ? (
<div className="flex flex-col items-center justify-center space-y-4 p-8">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
<div className="text-center space-y-2">
<p className="text-gray-700 font-medium">Processing your call</p>
<p className="text-sm text-gray-500">Fetching transcript and recording...</p>
</div>
</div>
) : (
<>
<AudioControls
audioInputs={audioInputs}
selectedAudioInput={selectedAudioInput}
setSelectedAudioInput={setSelectedAudioInput}
isCompleted={isCompleted}
connectionActive={connectionActive}
permissionError={permissionError}
start={start}
stop={stop}
isStarting={isStarting}
/>
<ConnectionStatus
connectionStatus={connectionStatus}
/>
</>
)}
</CardContent>
<audio ref={audioRef} autoPlay playsInline className="hidden" />
</Card>
<ApiKeyErrorDialog
open={apiKeyModalOpen}
onOpenChange={setApiKeyModalOpen}
error={apiKeyError}
onNavigateToApiKeys={navigateToApiKeys}
/>
<WorkflowConfigErrorDialog
open={workflowConfigModalOpen}
onOpenChange={setWorkflowConfigModalOpen}
error={workflowConfigError}
onNavigateToWorkflow={navigateToWorkflow}
/>
</>
);
};
export default BrowserCall;

View file

@ -1,114 +0,0 @@
import { useRouter } from "next/navigation";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import {
ApiKeyErrorDialog,
AudioControls,
ConnectionStatus,
ContextVariablesSection,
WorkflowConfigErrorDialog
} from "./components";
import { useWebRTC } from "./hooks";
const Pipecat = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: {
workflowId: number,
workflowRunId: number,
accessToken: string | null,
initialContextVariables?: Record<string, string> | null
}) => {
const router = useRouter();
const {
audioRef,
audioInputs,
selectedAudioInput,
setSelectedAudioInput,
connectionActive,
permissionError,
isCompleted,
apiKeyModalOpen,
setApiKeyModalOpen,
apiKeyError,
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,
iceGatheringState,
iceConnectionState,
start,
stop,
isStarting,
initialContext,
setInitialContext
} = useWebRTC({ workflowId, workflowRunId, accessToken, initialContextVariables });
const navigateToApiKeys = () => {
router.push('/api-keys');
};
const navigateToWorkflow = () => {
router.push(`/workflow/${workflowId}`)
}
return (
<>
<Card className="w-full max-w-4xl mx-auto">
<CardHeader>
<CardTitle>Workflow Run</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4">
<>
<ContextVariablesSection
initialContext={initialContext}
setInitialContext={setInitialContext}
disabled={connectionActive || isCompleted}
/>
<AudioControls
audioInputs={audioInputs}
selectedAudioInput={selectedAudioInput}
setSelectedAudioInput={setSelectedAudioInput}
isCompleted={isCompleted}
connectionActive={connectionActive}
permissionError={permissionError}
start={start}
stop={stop}
isStarting={isStarting}
/>
<ConnectionStatus
iceGatheringState={iceGatheringState}
iceConnectionState={iceConnectionState}
/>
</>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<p className="text-xs text-muted-foreground">
WebRTC connection status: {connectionActive ? 'Active' : 'Inactive'}
</p>
<audio ref={audioRef} autoPlay playsInline className="hidden" />
</CardFooter>
</Card>
<ApiKeyErrorDialog
open={apiKeyModalOpen}
onOpenChange={setApiKeyModalOpen}
error={apiKeyError}
onNavigateToApiKeys={navigateToApiKeys}
/>
<WorkflowConfigErrorDialog
open={workflowConfigModalOpen}
onOpenChange={setWorkflowConfigModalOpen}
error={workflowConfigError}
onNavigateToWorkflow={navigateToWorkflow}
/>
</>
);
};
export default Pipecat;

View file

@ -1,7 +1,6 @@
import { Mic, MicOff } from "lucide-react";
import { Mic, Phone, PhoneOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface AudioControlsProps {
audioInputs: MediaDeviceInfo[];
@ -28,7 +27,6 @@ export const AudioControls = ({
}: AudioControlsProps) => {
// Check if we have valid audio devices (permissions granted)
const hasValidDevices = audioInputs.length > 0 && audioInputs.some(device => device.deviceId && device.deviceId.trim() !== '');
const validAudioInputs = audioInputs.filter(device => device.deviceId && device.deviceId.trim() !== '');
const requestAudioPermissions = async () => {
try {
@ -40,64 +38,73 @@ export const AudioControls = ({
}
};
return (
<>
<div className="space-y-2">
<h3 className="text-sm font-medium">Audio Input</h3>
// Handle auto-selection of first device if none selected
if (hasValidDevices && !selectedAudioInput) {
const firstValidDevice = audioInputs.find(device => device.deviceId && device.deviceId.trim() !== '');
if (firstValidDevice) {
setSelectedAudioInput(firstValidDevice.deviceId);
}
}
{!hasValidDevices ? (
<div className="space-y-3">
<div className="flex items-center space-x-2 text-amber-600 bg-amber-50 p-3 rounded-md border border-amber-200">
<MicOff className="h-4 w-4" />
<span className="text-sm">Audio permissions are required to start the call</span>
</div>
<Button
onClick={requestAudioPermissions}
variant="outline"
className="w-full"
>
<Mic className="h-4 w-4 mr-2" />
Grant Audio Permissions
</Button>
</div>
) : (
<Select value={selectedAudioInput} onValueChange={setSelectedAudioInput}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select audio input" />
</SelectTrigger>
<SelectContent>
{validAudioInputs.map((device, index) => (
<SelectItem key={device.deviceId} value={device.deviceId}>
{device.label || `Audio Device #${index + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
)}
if (isCompleted) {
return null; // The parent component will handle showing the loading state
}
if (!hasValidDevices) {
return (
<div className="flex flex-col items-center justify-center space-y-4 p-8">
<div className="text-center space-y-2">
<p className="text-gray-700 font-medium">Audio permissions required</p>
<p className="text-sm text-gray-500">Click below to grant microphone access</p>
</div>
<Button
onClick={requestAudioPermissions}
size="lg"
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<Mic className="h-5 w-5 mr-2" />
Grant Audio Permissions
</Button>
</div>
);
}
{isCompleted && (
<div className="flex items-center space-x-4">
<p className="text-red-500">
Workflow run completed. Please refresh the page in a while to see the recording and transcript.
</p>
</div>
return (
<div className="flex flex-col items-center justify-center space-y-6 p-8">
{!connectionActive ? (
<>
<p className="text-sm text-gray-600">Ready to start your call</p>
<button
onClick={start}
disabled={isStarting}
className="group relative h-20 w-20 rounded-full bg-green-600 hover:bg-green-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
aria-label="Start Call"
>
<div className="absolute inset-0 rounded-full bg-green-600 animate-ping opacity-25"></div>
<div className="relative flex items-center justify-center h-full">
<Phone className="h-8 w-8 text-white" />
</div>
</button>
<p className="text-sm font-medium text-gray-700">Start Call</p>
</>
) : (
<>
<p className="text-sm text-gray-600">Call in progress</p>
<button
onClick={stop}
className="group relative h-20 w-20 rounded-full bg-red-600 hover:bg-red-700 transition-all duration-200 shadow-lg hover:shadow-xl"
aria-label="End Call"
>
<div className="relative flex items-center justify-center h-full">
<PhoneOff className="h-8 w-8 text-white" />
</div>
</button>
<p className="text-sm font-medium text-gray-700">End Call</p>
</>
)}
{!isCompleted && hasValidDevices && (
<div className="flex items-center space-x-4">
{!connectionActive ? (
<Button onClick={start} disabled={isStarting}>
{isStarting ? 'Starting...' : 'Start'}
</Button>
) : (
<Button onClick={stop} variant="destructive">Stop</Button>
)}
{permissionError && (
<p className="text-red-500">{permissionError}</p>
)}
</div>
{permissionError && (
<p className="text-sm text-red-500 text-center">{permissionError}</p>
)}
</>
</div>
);
};

View file

@ -1,22 +1,38 @@
import { Loader2 } from 'lucide-react';
interface ConnectionStatusProps {
iceGatheringState: string;
iceConnectionState: string;
connectionStatus: 'idle' | 'connecting' | 'connected' | 'failed';
}
export const ConnectionStatus = ({
iceGatheringState,
iceConnectionState
}: ConnectionStatusProps) => {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<h3 className="text-sm font-medium">ICE gathering state</h3>
<p className="text-sm text-muted-foreground">{iceGatheringState}</p>
export const ConnectionStatus = ({ connectionStatus }: ConnectionStatusProps) => {
if (connectionStatus === 'idle') return null;
if (connectionStatus === 'connecting') {
return (
<div className="flex items-center justify-center space-x-2 text-blue-600">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="text-sm font-medium">Establishing Connection...</span>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">ICE connection state</h3>
<p className="text-sm text-muted-foreground">{iceConnectionState}</p>
);
}
if (connectionStatus === 'connected') {
return (
<div className="flex items-center justify-center space-x-2 text-green-600">
<div className="h-2 w-2 bg-green-600 rounded-full animate-pulse" />
<span className="text-sm font-medium">Connected</span>
</div>
</div>
);
);
}
if (connectionStatus === 'failed') {
return (
<div className="flex items-center justify-center space-x-2 text-red-600">
<div className="h-2 w-2 bg-red-600 rounded-full" />
<span className="text-sm font-medium">Connection Failed</span>
</div>
);
}
return null;
};

View file

@ -2,5 +2,4 @@ export * from './ApiKeyErrorDialog';
export * from './AudioControls';
export * from './ConnectionStatus';
export * from './ContextDisplay';
export * from './ContextVariablesSection';
export * from './WorkflowConfigErrorDialog'

View file

@ -16,8 +16,7 @@ interface UseWebRTCProps {
}
export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: UseWebRTCProps) => {
const [iceGatheringState, setIceGatheringState] = useState('');
const [iceConnectionState, setIceConnectionState] = useState('');
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'connecting' | 'connected' | 'failed'>('idle');
const [connectionActive, setConnectionActive] = useState(false);
const [isCompleted, setIsCompleted] = useState(false);
const [apiKeyModalOpen, setApiKeyModalOpen] = useState(false);
@ -25,9 +24,8 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
const [workflowConfigModalOpen, setWorkflowConfigModalOpen] = useState(false);
const [workflowConfigError, setWorkflowConfigError] = useState<string | null>(null);
const [isStarting, setIsStarting] = useState(false);
const [initialContext, setInitialContext] = useState<Record<string, string>>(
initialContextVariables || {}
);
// Use initial context variables directly, no UI for editing
const initialContext = initialContextVariables || {};
const {
audioInputs,
@ -55,14 +53,16 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
pc.addEventListener('icegatheringstatechange', () => {
logger.info(`ICE gathering state changed in createPeerConnection, ${pc.iceGatheringState}`);
setIceGatheringState(prevState => prevState + ' -> ' + pc.iceGatheringState);
});
setIceGatheringState(pc.iceGatheringState);
pc.addEventListener('iceconnectionstatechange', () => {
setIceConnectionState(prevState => prevState + ' -> ' + pc.iceConnectionState);
logger.info(`ICE connection state changed: ${pc.iceConnectionState}`);
if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
setConnectionStatus('connected');
} else if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') {
setConnectionStatus('failed');
}
});
setIceConnectionState(pc.iceConnectionState);
pc.addEventListener('track', (evt) => {
if (evt.track.kind === 'audio' && audioRef.current) {
@ -142,6 +142,7 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
const start = async () => {
if (isStarting || !accessToken) return;
setIsStarting(true);
setConnectionStatus('connecting');
try {
const response = await validateUserConfigurationsApiV1UserConfigurationsUserValidateGet({
headers: {
@ -212,6 +213,7 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
} catch (err) {
logger.error(`Could not acquire media: ${err}`);
setPermissionError('Could not acquire media');
setConnectionStatus('failed');
}
} else {
await negotiate();
@ -224,6 +226,7 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
const stop = () => {
setConnectionActive(false);
setIsCompleted(true);
setConnectionStatus('idle');
const pc = pcRef.current;
if (!pc) return;
@ -264,12 +267,10 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,
iceGatheringState,
iceConnectionState,
connectionStatus,
start,
stop,
isStarting,
initialContext,
setInitialContext
initialContext
};
};

View file

@ -5,7 +5,7 @@ import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import Pipecat from '@/app/workflow/[workflowId]/run/[runId]/Pipecat';
import BrowserCall from '@/app/workflow/[workflowId]/run/[runId]/BrowserCall';
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from '@/client/sdk.gen';
import { MediaPreviewButtons, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
@ -15,8 +15,6 @@ import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/lib/auth';
import { downloadFile } from '@/lib/files';
import { ContextDisplay } from './components';
interface WorkflowRunResponse {
is_completed: boolean;
transcript_url: string | null;
@ -125,7 +123,7 @@ export default function WorkflowRunPage() {
<div className="w-full max-w-4xl space-y-6">
<Card className="border-gray-100">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-2xl">Workflow Run Completed</CardTitle>
<CardTitle className="text-2xl">Agent Run Completed</CardTitle>
<div className="h-8 w-8 bg-green-100 rounded-full flex items-center justify-center">
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
@ -133,7 +131,7 @@ export default function WorkflowRunPage() {
</div>
</CardHeader>
<CardContent>
<p className="text-gray-600 mb-8">Your workflow run has been completed successfully. You can preview or download the transcript and recording.</p>
<p className="text-gray-600 mb-8">Your voice agent run has been completed successfully. You can preview or download the transcript and recording.</p>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
@ -171,7 +169,7 @@ export default function WorkflowRunPage() {
</CardContent>
</Card>
<div className="grid gap-6 md:grid-cols-2">
{/* <div className="grid gap-6 md:grid-cols-2">
<ContextDisplay
title="Initial Context"
context={workflowRun?.initial_context}
@ -180,7 +178,7 @@ export default function WorkflowRunPage() {
title="Gathered Context"
context={workflowRun?.gathered_context}
/>
</div>
</div> */}
</div>
</div>
);
@ -188,7 +186,7 @@ export default function WorkflowRunPage() {
else {
returnValue =
<div className="min-h-screen mt-40">
<Pipecat
<BrowserCall
workflowId={Number(params.workflowId)}
workflowRunId={Number(params.runId)}
accessToken={accessToken}

View file

@ -1,6 +1,6 @@
"use client";
import { CircleDollarSign, Loader2 } from 'lucide-react';
import { CircleDollarSign, Loader2, Star } from 'lucide-react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import React from 'react';
@ -126,9 +126,15 @@ export default function BaseHeader({ headerActions, backButton, showFeaturesNav
/>
</React.Suspense>
) : (
<div className="text-sm text-gray-600">
Github Star Link
</div>
<a
href="https://github.com/dograh-hq/dograh"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
Star us on GitHub
</a>
)}
</div>
</nav>

View file

@ -0,0 +1,139 @@
'use client';
import { X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
interface OnboardingTooltipProps {
targetRef: React.RefObject<HTMLElement | HTMLButtonElement | null>;
title?: string;
message: string;
onDismiss: () => void;
onNext?: () => void;
showNext?: boolean;
isVisible: boolean;
}
export const OnboardingTooltip = ({
targetRef,
title = "One more thing...",
message,
onDismiss,
onNext,
showNext = true,
isVisible
}: OnboardingTooltipProps) => {
const tooltipRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
useEffect(() => {
if (!isVisible || !targetRef.current) return;
const calculatePosition = () => {
if (!targetRef.current || !tooltipRef.current) return;
const targetRect = targetRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
// Position tooltip below the target element with some offset for the arrow
const top = targetRect.bottom + 8; // 8px gap for arrow
// Center the tooltip horizontally relative to the target
let left = targetRect.left + (targetRect.width / 2) - (tooltipRect.width / 2);
// Ensure tooltip doesn't go off-screen
const padding = 16;
if (left < padding) {
left = padding;
} else if (left + tooltipRect.width > window.innerWidth - padding) {
left = window.innerWidth - tooltipRect.width - padding;
}
setPosition({ top, left });
};
// Small delay to ensure tooltip is rendered before calculating position
const timer = setTimeout(() => {
calculatePosition();
}, 10);
// Recalculate on window resize
window.addEventListener('resize', calculatePosition);
window.addEventListener('scroll', calculatePosition);
return () => {
clearTimeout(timer);
window.removeEventListener('resize', calculatePosition);
window.removeEventListener('scroll', calculatePosition);
};
}, [isVisible, targetRef]);
if (!mounted || !isVisible) return null;
const tooltipContent = (
<div
ref={tooltipRef}
className="fixed z-[100] animate-in fade-in slide-in-from-top-2 duration-300"
style={{ top: `${position.top}px`, left: `${position.left}px` }}
>
{/* Arrow pointing up */}
<div
className="absolute -top-2 left-1/2 -translate-x-1/2 w-4 h-4 bg-blue-500 rotate-45"
style={{
boxShadow: '-2px -2px 4px rgba(0, 0, 0, 0.1)'
}}
/>
{/* Tooltip content */}
<div className="relative bg-blue-500 text-white rounded-lg shadow-2xl p-6 max-w-sm">
{/* Close button */}
<button
onClick={onDismiss}
className="absolute top-2 right-2 p-1 hover:bg-blue-600 rounded-full transition-colors"
aria-label="Close tooltip"
>
<X className="h-4 w-4" />
</button>
{/* Title */}
<h3 className="text-lg font-semibold mb-3">{title}</h3>
{/* Message */}
<p className="text-sm leading-relaxed mb-4 pr-4">
{message}
</p>
{/* Footer actions */}
<div className="flex items-center justify-end gap-3">
<button
onClick={onDismiss}
className="bg-white text-blue-500 px-4 py-1.5 rounded font-medium text-sm hover:bg-blue-50 transition-colors cursor-pointer"
>
Close
</button>
{showNext && (
<button
onClick={() => {
onNext?.();
onDismiss();
}}
className="bg-white text-blue-500 px-4 py-1.5 rounded font-medium text-sm hover:bg-blue-50 transition-colors"
>
Next
</button>
)}
</div>
</div>
</div>
);
// Use portal to render tooltip at document root
return createPortal(tooltipContent, document.body);
};

View file

@ -0,0 +1,83 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
export type TooltipKey = 'web_call'; // Add more tooltip keys as needed
interface OnboardingState {
seenTooltips: TooltipKey[];
}
interface OnboardingContextType {
hasSeenTooltip: (key: TooltipKey) => boolean;
markTooltipSeen: (key: TooltipKey) => void;
resetOnboarding: () => void;
}
const ONBOARDING_STORAGE_KEY = 'dograh_onboarding_state';
const defaultState: OnboardingState = {
seenTooltips: [],
};
const OnboardingContext = createContext<OnboardingContextType | undefined>(undefined);
export const OnboardingProvider = ({ children }: { children: React.ReactNode }) => {
const [onboardingState, setOnboardingState] = useState<OnboardingState>(defaultState);
// Load state from localStorage on mount
useEffect(() => {
const savedState = localStorage.getItem(ONBOARDING_STORAGE_KEY);
if (savedState) {
try {
const parsed = JSON.parse(savedState);
setOnboardingState({ ...defaultState, ...parsed });
} catch (error) {
console.error('Failed to parse onboarding state:', error);
}
}
}, []);
// Save state to localStorage whenever it changes
useEffect(() => {
localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(onboardingState));
}, [onboardingState]);
const hasSeenTooltip = (key: TooltipKey): boolean => {
return onboardingState.seenTooltips.includes(key);
};
const markTooltipSeen = (key: TooltipKey) => {
setOnboardingState(prev => ({
...prev,
seenTooltips: prev.seenTooltips.includes(key)
? prev.seenTooltips
: [...prev.seenTooltips, key]
}));
};
const resetOnboarding = () => {
setOnboardingState(defaultState);
localStorage.removeItem(ONBOARDING_STORAGE_KEY);
};
return (
<OnboardingContext.Provider
value={{
hasSeenTooltip,
markTooltipSeen,
resetOnboarding
}}
>
{children}
</OnboardingContext.Provider>
);
};
export const useOnboarding = () => {
const context = useContext(OnboardingContext);
if (!context) {
throw new Error('useOnboarding must be used within an OnboardingProvider');
}
return context;
};