Update copilot UI and add product tour

This commit is contained in:
akhisud3195 2025-04-14 14:29:50 +05:30 committed by Ramnique Singh
parent 41c3f2cfc6
commit 4cf8d40199
15 changed files with 667 additions and 71 deletions

View file

@ -14,22 +14,31 @@ type FlexibleMessage = {
// Add any other optional fields that might be needed
};
interface ComposeBoxCopilotProps {
handleUserMessage: (message: string) => void;
messages: any[];
loading: boolean;
disabled?: boolean;
initialFocus?: boolean;
}
export function ComposeBoxCopilot({
minRows=3,
disabled=false,
loading=false,
handleUserMessage,
messages,
}: {
minRows?: number;
disabled?: boolean;
loading?: boolean;
handleUserMessage: (prompt: string) => void;
messages: FlexibleMessage[]; // Use the flexible message type
}) {
loading,
disabled = false,
initialFocus = false,
}: ComposeBoxCopilotProps) {
const [input, setInput] = useState('');
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Add effect to handle initial focus
useEffect(() => {
if (initialFocus && textareaRef.current) {
textareaRef.current.focus();
}
}, [initialFocus]);
function handleInput() {
const prompt = input.trim();
@ -47,13 +56,6 @@ export function ComposeBoxCopilot({
}
}
// focus on the input field only when there is at least one message
useEffect(() => {
if (messages.length > 0) {
inputRef.current?.focus();
}
}, [messages]);
return (
<div className="relative group">
{/* Keyboard shortcut hint */}
@ -68,7 +70,7 @@ export function ComposeBoxCopilot({
{/* Textarea */}
<div className="flex-1">
<Textarea
ref={inputRef}
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleInputKeyDown}

View file

@ -0,0 +1,128 @@
import { useState, useRef } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { Button, Spinner } from "@heroui/react";
interface ComposeBoxPlaygroundProps {
handleUserMessage: (message: string) => void;
messages: any[];
loading: boolean;
disabled?: boolean;
}
export function ComposeBoxPlayground({
handleUserMessage,
messages,
loading,
disabled = false,
}: ComposeBoxPlaygroundProps) {
const [input, setInput] = useState('');
const [isFocused, setIsFocused] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
function handleInput() {
const prompt = input.trim();
if (!prompt) {
return;
}
setInput('');
handleUserMessage(prompt);
}
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleInput();
}
};
return (
<div className="relative group">
{/* Keyboard shortcut hint */}
<div className="absolute -top-6 right-0 text-xs text-gray-500 dark:text-gray-400 opacity-0
group-hover:opacity-100 transition-opacity">
Press + Enter to send
</div>
{/* Outer container with padding */}
<div className="rounded-2xl border-[1.5px] border-gray-200 dark:border-[#2a2d31] p-3 relative
bg-white dark:bg-[#1e2023] flex items-end gap-2">
{/* Textarea */}
<div className="flex-1">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleInputKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={disabled || loading}
placeholder="Type a message..."
autoResize={true}
maxHeight={120}
className={`
!min-h-0
!border-0 !shadow-none !ring-0
bg-transparent
resize-none
overflow-y-auto
[&::-webkit-scrollbar]:w-1
[&::-webkit-scrollbar-track]:bg-transparent
[&::-webkit-scrollbar-thumb]:bg-gray-300
[&::-webkit-scrollbar-thumb]:dark:bg-[#2a2d31]
[&::-webkit-scrollbar-thumb]:rounded-full
placeholder:text-gray-500 dark:placeholder:text-gray-400
`}
/>
</div>
{/* Send button */}
<Button
size="sm"
isIconOnly
disabled={disabled || loading || !input.trim()}
onPress={handleInput}
className={`
transition-all duration-200
${input.trim()
? 'bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:hover:bg-indigo-800/60 dark:text-indigo-300'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500'
}
scale-100 hover:scale-105 active:scale-95
disabled:opacity-50 disabled:scale-95
hover:shadow-md dark:hover:shadow-indigo-950/10
mb-0.5
`}
>
{loading ? (
<Spinner size="sm" color={input.trim() ? "primary" : "default"} />
) : (
<SendIcon
size={16}
className={`transform transition-transform ${isFocused ? 'translate-x-0.5' : ''}`}
/>
)}
</Button>
</div>
</div>
);
}
// Custom SendIcon component for better visual alignment
function SendIcon({ size, className }: { size: number, className?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M22 2L11 13" />
<path d="M22 2L15 22L11 13L2 9L22 2Z" />
</svg>
);
}

View file

@ -1,5 +1,6 @@
import clsx from "clsx";
import { Sparkles } from "lucide-react";
import { SHOW_COPILOT_MARQUEE } from "@/app/lib/feature_flags";
export function ActionButton({
icon = null,
@ -34,8 +35,11 @@ interface PanelProps {
actions?: React.ReactNode;
children: React.ReactNode;
maxHeight?: string;
variant?: 'default' | 'copilot' | 'projects';
variant?: 'default' | 'copilot' | 'playground' | 'projects';
showWelcome?: boolean;
className?: string;
onClick?: () => void;
tourTarget?: string;
}
export function Panel({
@ -46,32 +50,43 @@ export function Panel({
maxHeight,
variant = 'default',
showWelcome = true,
className,
onClick,
tourTarget,
}: PanelProps) {
return <div className={clsx(
"flex flex-col overflow-hidden rounded-xl border relative",
"border-zinc-200 dark:border-zinc-800",
"bg-white dark:bg-zinc-900",
maxHeight ? "max-h-[var(--panel-height)]" : "h-full"
)}
style={{
'--panel-height': maxHeight
} as React.CSSProperties}
return <div
className={clsx(
"flex flex-col overflow-hidden rounded-xl border relative",
variant === 'copilot' ? "border-blue-200 dark:border-blue-800" : "border-zinc-200 dark:border-zinc-800",
"bg-white dark:bg-zinc-900",
maxHeight ? "max-h-[var(--panel-height)]" : "h-full",
className
)}
style={{
'--panel-height': maxHeight
} as React.CSSProperties}
onClick={onClick}
data-tour-target={tourTarget}
>
{variant === 'copilot' && showWelcome && (
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none -mt-16">
<Sparkles className="w-32 h-32 text-blue-400/40 dark:text-blue-500/25 animate-sparkle" />
<div className="relative mt-8 max-w-full px-8">
<div className="font-mono text-sm whitespace-nowrap text-blue-400/60 dark:text-blue-500/40 font-small inline-flex">
<div className="overflow-hidden w-0 animate-typing">What can I help you build?</div>
<div className="border-r-2 border-blue-400 dark:border-blue-500 animate-cursor">&nbsp;</div>
{SHOW_COPILOT_MARQUEE && (
<div className="relative mt-8 max-w-full px-8">
<div className="font-mono text-sm whitespace-nowrap text-blue-400/60 dark:text-blue-500/40 font-small inline-flex">
<div className="overflow-hidden w-0 animate-typing">What can I help you build?</div>
<div className="border-r-2 border-blue-400 dark:border-blue-500 animate-cursor">&nbsp;</div>
</div>
</div>
</div>
)}
</div>
)}
<div className={clsx(
"shrink-0 border-b border-zinc-100 dark:border-zinc-800 relative",
variant === 'projects' ? "flex flex-col gap-3 px-4 py-3" : "flex items-center justify-between px-4 py-3"
)}>
<div
className={clsx(
"shrink-0 border-b border-zinc-100 dark:border-zinc-800 relative",
variant === 'projects' ? "flex flex-col gap-3 px-4 py-3" : "flex items-center justify-between px-4 py-3"
)}
>
{variant === 'projects' ? (
<>
<div className="text-sm uppercase tracking-wide text-zinc-500 dark:text-zinc-400">

View file

@ -0,0 +1,251 @@
import { useFloating, offset, flip, shift, arrow, FloatingArrow, FloatingPortal, autoUpdate } from '@floating-ui/react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { XIcon } from 'lucide-react';
interface TourStep {
target: string;
content: string;
title: string;
}
const TOUR_STEPS: TourStep[] = [
{
target: 'copilot',
content: 'Build agents with the help of copilot.\nThis might take a minute.',
title: 'Step 1/6'
},
{
target: 'playground',
content: 'Test your assistant in the playground.\nDebug tool calls and responses.',
title: 'Step 2/6'
},
{
target: 'entity-agents',
content: 'Manage your agents.\nSpecify instructions, examples and tool usage.',
title: 'Step 3/6'
},
{
target: 'entity-tools',
content: 'Create your own tools, import MCP tools or use existing ones.\nMock tools for quick testing.',
title: 'Step 4/6'
},
{
target: 'entity-prompts',
content: 'Manage prompts which will be used by agents.\nConfigure greeting message.',
title: 'Step 5/6'
},
{
target: 'settings',
content: 'Configure project settings\nGet API keys, configure tool webhooks.',
title: 'Step 6/6'
}
];
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(
targetElement.getAttribute('data-tour-target')!
);
// Use smaller padding for panels to prevent overlap
const padding = isPanelTarget ? 12 : 8;
useEffect(() => {
if (targetElement) {
const updateRect = () => {
const newRect = targetElement.getBoundingClientRect();
setRect(newRect);
};
updateRect();
window.addEventListener('resize', updateRect);
window.addEventListener('scroll', updateRect);
return () => {
window.removeEventListener('resize', updateRect);
window.removeEventListener('scroll', updateRect);
};
}
}, [targetElement]);
if (!rect) return null;
return (
<>
{/* Top */}
<div className="fixed z-[100] backdrop-blur-sm bg-black/30" style={{
top: 0,
left: 0,
right: 0,
height: Math.max(0, rect.top - padding)
}} />
{/* Left */}
<div className="fixed z-[100] backdrop-blur-sm bg-black/30" style={{
top: Math.max(0, rect.top - padding),
left: 0,
width: Math.max(0, rect.left - padding),
height: rect.height + padding * 2
}} />
{/* Right */}
<div className="fixed z-[100] backdrop-blur-sm bg-black/30" style={{
top: Math.max(0, rect.top - padding),
left: rect.right + padding,
right: 0,
height: rect.height + padding * 2
}} />
{/* Bottom */}
<div className="fixed z-[100] backdrop-blur-sm bg-black/30" style={{
top: rect.bottom + padding,
left: 0,
right: 0,
bottom: 0
}} />
{/* Highlight border around target */}
<div
className="fixed z-[100] border-2 border-white/50 rounded-lg pointer-events-none"
style={{
top: rect.top - padding,
left: rect.left - padding,
width: rect.width + padding * 2,
height: rect.height + padding * 2,
}}
/>
</>
);
}
export function ProductTour({
projectId,
onComplete
}: {
projectId: string;
onComplete: () => void;
}) {
const [currentStep, setCurrentStep] = useState(0);
const [shouldShow, setShouldShow] = useState(true);
const arrowRef = useRef(null);
// Check if tour has been completed by the user
useEffect(() => {
const tourCompleted = localStorage.getItem('user_product_tour_completed');
if (tourCompleted) {
setShouldShow(false);
}
}, []);
const currentTarget = TOUR_STEPS[currentStep].target;
const targetElement = document.querySelector(`[data-tour-target="${currentTarget}"]`);
// 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 { x, y, strategy, refs, context, middlewareData } = useFloating({
placement: isPanelTarget ? 'right' : 'top',
middleware: [
offset(16),
flip({
fallbackPlacements: isPanelTarget ? ['left', 'top', 'bottom'] : ['bottom', 'left', 'right'],
padding: 16
}),
shift({
padding: 16,
crossAxis: true,
mainAxis: true
}),
arrow({ element: arrowRef })
],
whileElementsMounted: autoUpdate
});
// Update reference element when step changes
useEffect(() => {
if (targetElement) {
refs.setReference(targetElement);
}
}, [currentStep, targetElement, refs]);
const handleNext = useCallback(() => {
if (currentStep < TOUR_STEPS.length - 1) {
setCurrentStep(prev => prev + 1);
} else {
// Mark tour as completed for the user
localStorage.setItem('user_product_tour_completed', 'true');
// Clean up any old project-specific tour flags
localStorage.removeItem(`project_tour_${projectId}`);
setShouldShow(false);
onComplete();
}
}, [currentStep, projectId, onComplete]);
const handleSkip = useCallback(() => {
// Mark tour as completed for the user
localStorage.setItem('user_product_tour_completed', 'true');
// Clean up any old project-specific tour flags
localStorage.removeItem(`project_tour_${projectId}`);
setShouldShow(false);
onComplete();
}, [projectId, onComplete]);
if (!shouldShow) return null;
// Get the actual placement after middleware calculations
const actualPlacement = middlewareData.flip?.overflows?.length ?
middlewareData.flip?.overflows[0].placement :
isPanelTarget ? 'right' : 'top';
return (
<FloatingPortal>
<TourBackdrop targetElement={targetElement} />
<div
ref={refs.setFloating}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
width: 'max-content',
maxWidth: '90vw',
zIndex: 101,
}}
className="bg-white dark:bg-zinc-800 rounded-lg shadow-lg border border-zinc-200 dark:border-zinc-700 p-4 animate-in fade-in duration-200"
>
<button
onClick={handleSkip}
className="absolute right-2 top-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<XIcon size={16} />
</button>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{TOUR_STEPS[currentStep].title}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3 whitespace-pre-line">
{TOUR_STEPS[currentStep].content}
</div>
<div className="flex justify-between items-center">
<button
onClick={handleSkip}
className="text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
Skip tour
</button>
<button
onClick={handleNext}
className="px-4 py-1.5 bg-indigo-600 text-white rounded-md text-sm font-medium hover:bg-indigo-700"
>
{currentStep === TOUR_STEPS.length - 1 ? 'Finish' : 'Next'}
</button>
</div>
<FloatingArrow
ref={arrowRef}
context={context}
fill="white"
className="dark:fill-zinc-800"
/>
</div>
</FloatingPortal>
);
}