diff --git a/screenshots/timeline-creating.png b/screenshots/timeline-creating.png new file mode 100644 index 000000000..5258a06ec Binary files /dev/null and b/screenshots/timeline-creating.png differ diff --git a/screenshots/timeline-dark.png b/screenshots/timeline-dark.png new file mode 100644 index 000000000..a127a02e8 Binary files /dev/null and b/screenshots/timeline-dark.png differ diff --git a/screenshots/timeline-json.png b/screenshots/timeline-json.png new file mode 100644 index 000000000..f660af895 Binary files /dev/null and b/screenshots/timeline-json.png differ diff --git a/screenshots/timeline-light.png b/screenshots/timeline-light.png new file mode 100644 index 000000000..bf29e153e Binary files /dev/null and b/screenshots/timeline-light.png differ diff --git a/screenshots/timeline-rise-of-ai.png b/screenshots/timeline-rise-of-ai.png new file mode 100644 index 000000000..730cc189b Binary files /dev/null and b/screenshots/timeline-rise-of-ai.png differ diff --git a/src/components/default-tool-icon.tsx b/src/components/default-tool-icon.tsx index 6676e4d13..129afb244 100644 --- a/src/components/default-tool-icon.tsx +++ b/src/components/default-tool-icon.tsx @@ -10,6 +10,7 @@ import { CodeIcon, HammerIcon, TableOfContents, + LayoutList, } from "lucide-react"; import { useMemo } from "react"; @@ -38,6 +39,9 @@ export function DefaultToolIcon({ ); } + if (name === DefaultToolName.CreateTimeline) { + return ; + } if (name === DefaultToolName.WebSearch) { return ; } diff --git a/src/components/message-parts.tsx b/src/components/message-parts.tsx index fedec6317..10decc400 100644 --- a/src/components/message-parts.tsx +++ b/src/components/message-parts.tsx @@ -695,6 +695,14 @@ const InteractiveTable = dynamic( }, ); +const Timeline = dynamic( + () => import("./tool-invocation/timeline").then((mod) => mod.Timeline), + { + ssr: false, + loading, + }, +); + const WebSearchToolInvocation = dynamic( () => import("./tool-invocation/web-search").then( @@ -926,6 +934,10 @@ export const ToolMessagePart = memo( {...(input as any)} /> ); + case DefaultToolName.CreateTimeline: + return ( + + ); } } return null; diff --git a/src/components/tool-invocation/timeline.tsx b/src/components/tool-invocation/timeline.tsx new file mode 100644 index 000000000..a24487b35 --- /dev/null +++ b/src/components/tool-invocation/timeline.tsx @@ -0,0 +1,186 @@ +"use client"; + +import * as React from "react"; +import { motion } from "framer-motion"; +import { format, parseISO, formatDistanceToNow } from "date-fns"; +import * as LucideIcons from "lucide-react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { JsonViewPopup } from "../json-view-popup"; + +// Timeline component props interface matching tool schema +export interface TimelineProps { + title: string; + description?: string; + events: Array<{ + title: string; + description?: string; + timestamp: string; + status: "pending" | "in-progress" | "complete"; + icon?: string; + }>; +} + +// Status configurations +const statusConfig = { + complete: { + color: "bg-green-500", + ringColor: "ring-green-500/20", + textColor: "text-green-600 dark:text-green-400", + label: "Complete", + badgeVariant: "default" as const, + }, + "in-progress": { + color: "bg-blue-500", + ringColor: "ring-blue-500/20", + textColor: "text-blue-600 dark:text-blue-400", + label: "In Progress", + badgeVariant: "secondary" as const, + }, + pending: { + color: "bg-transparent border-2 border-gray-400 dark:border-gray-500", + ringColor: "ring-gray-400/20", + textColor: "text-gray-600 dark:text-gray-400", + label: "Pending", + badgeVariant: "outline" as const, + }, +}; + +// Animation variants for stagger effect +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1, // 100ms delay between items + }, + }, +}; + +const itemVariants = { + hidden: { opacity: 0, x: -20 }, + visible: { + opacity: 1, + x: 0, + transition: { + duration: 0.4, + ease: "easeOut", + }, + }, +}; + +// Helper to format timestamp +function formatTimestamp(timestamp: string): string { + try { + const date = parseISO(timestamp); + const distance = formatDistanceToNow(date, { addSuffix: true }); + return distance; + } catch (_error) { + // If not ISO format, return as-is (e.g., "Yesterday", "2 hours ago") + return timestamp; + } +} + +// Helper to get Lucide icon component +function getIconComponent(iconName?: string) { + if (!iconName) return null; + + // Convert iconName to PascalCase if needed and get from lucide-react + const IconComponent = (LucideIcons as any)[iconName]; + return IconComponent || null; +} + +export function Timeline(props: TimelineProps) { + const { title, description, events } = props; + + return ( + + + + Timeline - {title} +
+ +
+
+ {description && {description}} +
+ + + {/* Vertical connecting line */} +
+ + {/* Timeline events */} +
+ {events.map((event, index) => { + const config = statusConfig[event.status]; + const IconComponent = getIconComponent(event.icon); + const _isLast = index === events.length - 1; + + return ( + + {/* Status indicator dot */} +
+ {IconComponent && ( + + )} +
+ + {/* Event content */} +
+
+

+ {event.title} +

+ + {config.label} + +
+ + {event.description && ( +

+ {event.description} +

+ )} + +

+ {formatTimestamp(event.timestamp)} +

+
+
+ ); + })} +
+ + + + ); +} diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts index 233683d7a..1bd631386 100644 --- a/src/lib/ai/tools/index.ts +++ b/src/lib/ai/tools/index.ts @@ -10,6 +10,7 @@ export enum DefaultToolName { CreateBarChart = "createBarChart", CreateLineChart = "createLineChart", CreateTable = "createTable", + CreateTimeline = "createTimeline", WebSearch = "webSearch", WebContent = "webContent", Http = "http", diff --git a/src/lib/ai/tools/tool-kit.ts b/src/lib/ai/tools/tool-kit.ts index 22623a8e6..c40642309 100644 --- a/src/lib/ai/tools/tool-kit.ts +++ b/src/lib/ai/tools/tool-kit.ts @@ -2,6 +2,7 @@ import { createPieChartTool } from "./visualization/create-pie-chart"; import { createBarChartTool } from "./visualization/create-bar-chart"; import { createLineChartTool } from "./visualization/create-line-chart"; import { createTableTool } from "./visualization/create-table"; +import { createTimelineTool } from "./visualization/create-timeline"; import { exaSearchTool, exaContentsTool } from "./web/web-search"; import { AppDefaultToolkit, DefaultToolName } from "."; import { Tool } from "ai"; @@ -18,6 +19,7 @@ export const APP_DEFAULT_TOOL_KIT: Record< [DefaultToolName.CreateBarChart]: createBarChartTool, [DefaultToolName.CreateLineChart]: createLineChartTool, [DefaultToolName.CreateTable]: createTableTool, + [DefaultToolName.CreateTimeline]: createTimelineTool, }, [AppDefaultToolkit.WebSearch]: { [DefaultToolName.WebSearch]: exaSearchTool, diff --git a/src/lib/ai/tools/visualization/create-timeline.ts b/src/lib/ai/tools/visualization/create-timeline.ts new file mode 100644 index 000000000..30e2fec9a --- /dev/null +++ b/src/lib/ai/tools/visualization/create-timeline.ts @@ -0,0 +1,45 @@ +import { tool as createTool } from "ai"; + +import { z } from "zod"; + +export const createTimelineTool = createTool({ + description: + "Create a timeline visualization showing chronological events with status indicators. Use for project milestones, event chronologies, process steps, audit trails, or any time-based sequential data with status tracking (pending/in-progress/complete).", + inputSchema: z.object({ + title: z.string().describe("Title for the timeline"), + description: z + .string() + .optional() + .describe("Optional description or context for the timeline"), + events: z + .array( + z.object({ + title: z.string().describe("Event title"), + description: z + .string() + .optional() + .describe("Optional detailed description of the event"), + timestamp: z + .string() + .describe( + "ISO 8601 timestamp or relative time string (e.g., '2024-01-15T10:00:00Z' or 'Yesterday')", + ), + status: z + .enum(["pending", "in-progress", "complete"]) + .describe( + "Event status: pending (gray outline), in-progress (blue), complete (green)", + ), + icon: z + .string() + .optional() + .describe( + "Optional Lucide icon name (e.g., 'CheckCircle', 'Clock', 'Zap')", + ), + }), + ) + .describe("Array of timeline events in chronological order"), + }), + execute: async () => { + return "Success"; + }, +});