Introduce a footer-triggered Version History drawer with categorized release notes, hardcoded release data, and semver tracking at v0.3.0. Also ships blog blockquote/table styling, site-wide typography, home page image updates, header refinements, and related bug fixes. Co-authored-by: Cursor <cursoragent@cursor.com>
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
BarChart3,
|
||
Check,
|
||
ChevronDown,
|
||
FileText,
|
||
GitBranch,
|
||
Loader2,
|
||
Megaphone,
|
||
Sparkles,
|
||
Target,
|
||
} from "lucide-react";
|
||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||
import { useState } from "react";
|
||
import {
|
||
AGENT_AUDIENCE_TAGS,
|
||
AGENT_PROGRESS_STAGES,
|
||
} from "./playground-content";
|
||
import {
|
||
agentEase,
|
||
agentPanelTransition,
|
||
agentSoftSpring,
|
||
agentStaggerFast,
|
||
agentThreadItem,
|
||
} from "./agent-motion";
|
||
import { cn } from "@/lib/utils";
|
||
import { type } from "@/lib/typography";
|
||
import type { ChatMessage } from "./types";
|
||
|
||
interface AgentThreadProps {
|
||
messages: ChatMessage[];
|
||
isTyping?: boolean;
|
||
bottomRef?: React.RefObject<HTMLDivElement | null>;
|
||
}
|
||
|
||
function ProgressCard() {
|
||
const activeStage = 2;
|
||
const reduceMotion = useReducedMotion();
|
||
|
||
return (
|
||
<motion.div
|
||
className="rounded-2xl border bg-card p-4 shadow-sm"
|
||
variants={agentThreadItem}
|
||
initial="hidden"
|
||
animate="visible"
|
||
>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<h3 className={type["card-title"]}>تولید سناریو آغاز شد</h3>
|
||
<span className={cn(type.caption, "text-muted-foreground")}>۵ دقیقه</span>
|
||
</div>
|
||
|
||
<div className="mt-4 flex gap-1">
|
||
{AGENT_PROGRESS_STAGES.map((stage, index) => {
|
||
const isComplete = index < activeStage;
|
||
const isActive = index === activeStage;
|
||
|
||
return (
|
||
<div key={stage} className="flex-1 space-y-1.5">
|
||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||
<motion.div
|
||
className="h-full rounded-full bg-primary"
|
||
initial={{ scaleX: 0 }}
|
||
animate={{
|
||
scaleX: isComplete || isActive ? 1 : 0,
|
||
opacity: isComplete || isActive ? 1 : 0,
|
||
}}
|
||
style={{ transformOrigin: "right center" }}
|
||
transition={{
|
||
duration: reduceMotion ? 0 : 0.55,
|
||
delay: reduceMotion ? 0 : index * 0.1,
|
||
ease: agentEase,
|
||
}}
|
||
/>
|
||
</div>
|
||
<motion.p
|
||
className={cn(
|
||
type.caption,
|
||
"text-center text-[10px] uppercase tracking-wide",
|
||
isComplete || isActive
|
||
? "text-primary"
|
||
: "text-muted-foreground"
|
||
)}
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ delay: reduceMotion ? 0 : 0.15 + index * 0.06 }}
|
||
>
|
||
{stage}
|
||
</motion.p>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
function TaskRow({
|
||
title,
|
||
status,
|
||
expanded,
|
||
onToggle,
|
||
children,
|
||
index = 0,
|
||
}: {
|
||
title: string;
|
||
status: "done" | "active" | "pending";
|
||
expanded?: boolean;
|
||
onToggle?: () => void;
|
||
children?: React.ReactNode;
|
||
index?: number;
|
||
}) {
|
||
const reduceMotion = useReducedMotion();
|
||
|
||
return (
|
||
<motion.div
|
||
className="overflow-hidden rounded-xl border bg-card"
|
||
variants={agentThreadItem}
|
||
initial="hidden"
|
||
animate="visible"
|
||
transition={{ ...agentPanelTransition, delay: reduceMotion ? 0 : 0.08 + index * 0.07 }}
|
||
layout
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={onToggle}
|
||
className="flex w-full items-center gap-3 px-4 py-3 text-start"
|
||
>
|
||
<motion.span
|
||
className={cn(
|
||
"flex size-5 shrink-0 items-center justify-center rounded-full",
|
||
status === "done" && "bg-primary/15 text-primary",
|
||
status === "active" && "bg-primary text-primary-foreground",
|
||
status === "pending" && "border text-muted-foreground"
|
||
)}
|
||
initial={{ scale: 0.6, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 1 }}
|
||
transition={agentSoftSpring}
|
||
>
|
||
{status === "done" ? (
|
||
<Check className="size-3" aria-hidden />
|
||
) : status === "active" ? (
|
||
<Loader2 className="size-3 animate-spin" aria-hidden />
|
||
) : (
|
||
<span className="size-1.5 rounded-full bg-muted-foreground/50" />
|
||
)}
|
||
</motion.span>
|
||
<span className={cn("flex-1", type["body-sm"], status === "pending" && "text-muted-foreground")}>
|
||
{title}
|
||
</span>
|
||
{onToggle && (
|
||
<motion.span
|
||
animate={{ rotate: expanded ? 180 : 0 }}
|
||
transition={{ duration: 0.28, ease: agentEase }}
|
||
>
|
||
<ChevronDown
|
||
className="size-4 shrink-0 text-muted-foreground"
|
||
aria-hidden
|
||
/>
|
||
</motion.span>
|
||
)}
|
||
</button>
|
||
<AnimatePresence initial={false}>
|
||
{expanded && children && (
|
||
<motion.div
|
||
key="content"
|
||
initial={{ height: 0, opacity: 0 }}
|
||
animate={{ height: "auto", opacity: 1 }}
|
||
exit={{ height: 0, opacity: 0 }}
|
||
transition={{ duration: reduceMotion ? 0 : 0.32, ease: agentEase }}
|
||
className="overflow-hidden"
|
||
>
|
||
<div className="border-t px-4 pb-4 pt-3">{children}</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
function AudienceTags() {
|
||
const allTags = [
|
||
...AGENT_AUDIENCE_TAGS.companyType,
|
||
...AGENT_AUDIENCE_TAGS.stage,
|
||
...AGENT_AUDIENCE_TAGS.region,
|
||
];
|
||
const reduceMotion = useReducedMotion();
|
||
|
||
return (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{allTags.map((tag, index) => (
|
||
<motion.span
|
||
key={tag}
|
||
className={cn(
|
||
"rounded-md border bg-muted/50 px-2.5 py-1 text-foreground",
|
||
type.caption
|
||
)}
|
||
initial={{ opacity: 0, scale: 0.85, y: 6 }}
|
||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||
transition={{
|
||
...agentSoftSpring,
|
||
delay: reduceMotion ? 0 : index * 0.05,
|
||
}}
|
||
>
|
||
{tag}
|
||
</motion.span>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AssistantBubble({ content }: { content: string }) {
|
||
return (
|
||
<motion.div
|
||
className="rounded-2xl border bg-card p-4 shadow-sm"
|
||
initial={{ opacity: 0, y: 16 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={agentPanelTransition}
|
||
>
|
||
<p className={cn(type.chat, "whitespace-pre-wrap break-words text-card-foreground")}>
|
||
{content}
|
||
</p>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
export function AgentThread({
|
||
messages,
|
||
isTyping = false,
|
||
bottomRef,
|
||
}: AgentThreadProps) {
|
||
const [expandedTask, setExpandedTask] = useState(true);
|
||
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
|
||
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
||
const lastAssistant = assistantMessages[assistantMessages.length - 1];
|
||
|
||
return (
|
||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||
<motion.div
|
||
className="flex flex-col gap-4 p-4 sm:p-5"
|
||
variants={agentStaggerFast}
|
||
initial="hidden"
|
||
animate="visible"
|
||
>
|
||
<AnimatePresence mode="popLayout">
|
||
{lastUserMessage && (
|
||
<motion.div
|
||
key="user-prompt"
|
||
className="rounded-2xl bg-muted/50 px-4 py-3.5"
|
||
layout
|
||
initial={{ opacity: 0, y: -12, scale: 0.98 }}
|
||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||
exit={{ opacity: 0, y: -8, scale: 0.98 }}
|
||
transition={agentPanelTransition}
|
||
>
|
||
<p className={cn(type["body-sm"], "whitespace-pre-wrap break-words text-foreground")}>
|
||
{lastUserMessage.content}
|
||
</p>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<AnimatePresence mode="popLayout">
|
||
{(isTyping || assistantMessages.length > 0) && (
|
||
<motion.div
|
||
key="agent-workflow"
|
||
className="space-y-4"
|
||
layout
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.35, ease: agentEase }}
|
||
>
|
||
<AnimatePresence>
|
||
{isTyping && (
|
||
<motion.div
|
||
key="progress"
|
||
initial={{ opacity: 0, height: 0 }}
|
||
animate={{ opacity: 1, height: "auto" }}
|
||
exit={{ opacity: 0, height: 0 }}
|
||
transition={{ duration: 0.38, ease: agentEase }}
|
||
>
|
||
<ProgressCard />
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<div className="space-y-2">
|
||
<TaskRow title="تحلیل نیازمندیها" status="done" index={0} />
|
||
<TaskRow
|
||
title="کشف حسابها و سیگنالها"
|
||
status={isTyping ? "active" : "done"}
|
||
expanded={expandedTask}
|
||
onToggle={() => setExpandedTask((v) => !v)}
|
||
index={1}
|
||
>
|
||
<AudienceTags />
|
||
</TaskRow>
|
||
<TaskRow
|
||
title="ساخت پیامهای شخصیسازیشده"
|
||
status={isTyping ? "pending" : "done"}
|
||
index={2}
|
||
/>
|
||
<TaskRow
|
||
title="تنظیم دنباله ارتباطی"
|
||
status="pending"
|
||
index={3}
|
||
/>
|
||
</div>
|
||
|
||
<AnimatePresence>
|
||
{!isTyping && lastAssistant && (
|
||
<motion.div
|
||
key="assistant"
|
||
initial={{ opacity: 0, y: 14 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: 8 }}
|
||
transition={agentPanelTransition}
|
||
>
|
||
<AssistantBubble content={lastAssistant.content} />
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<AnimatePresence>
|
||
{!isTyping && assistantMessages.length > 0 && (
|
||
<motion.div
|
||
key="ready-banner"
|
||
className="flex items-center justify-between rounded-xl bg-muted/40 px-4 py-2.5"
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ ...agentPanelTransition, delay: 0.1 }}
|
||
>
|
||
<span className={cn(type["body-sm"], "text-muted-foreground")}>
|
||
سناریوی شما آماده است!
|
||
</span>
|
||
<motion.button
|
||
type="button"
|
||
className={cn(
|
||
"rounded-lg bg-foreground px-3 py-1.5 text-background",
|
||
type.caption
|
||
)}
|
||
whileHover={{ scale: 1.03 }}
|
||
whileTap={{ scale: 0.97 }}
|
||
>
|
||
پیشنمایش
|
||
</motion.button>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<div ref={bottomRef} className="h-px shrink-0" aria-hidden />
|
||
</motion.div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function FeatureCardIcon({
|
||
icon,
|
||
className,
|
||
}: {
|
||
icon: (typeof import("./playground-content").AGENT_FEATURE_CARDS)[number]["icon"];
|
||
className?: string;
|
||
}) {
|
||
const iconClass = cn("size-4", className);
|
||
|
||
switch (icon) {
|
||
case "megaphone":
|
||
return <Megaphone className={iconClass} aria-hidden />;
|
||
case "document":
|
||
return <FileText className={iconClass} aria-hidden />;
|
||
case "sparkles":
|
||
return <Sparkles className={iconClass} aria-hidden />;
|
||
case "chart":
|
||
return <BarChart3 className={iconClass} aria-hidden />;
|
||
case "target":
|
||
return <Target className={iconClass} aria-hidden />;
|
||
case "steps":
|
||
return <GitBranch className={iconClass} aria-hidden />;
|
||
}
|
||
}
|