Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added screenshots/timeline-creating.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/timeline-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/timeline-json.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/timeline-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/timeline-rise-of-ai.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/components/default-tool-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
CodeIcon,
HammerIcon,
TableOfContents,
LayoutList,
} from "lucide-react";
import { useMemo } from "react";

Expand Down Expand Up @@ -38,6 +39,9 @@ export function DefaultToolIcon({
<TableOfContents className={cn("size-3.5 text-blue-500", className)} />
);
}
if (name === DefaultToolName.CreateTimeline) {
return <LayoutList className={cn("size-3.5 text-blue-500", className)} />;
}
if (name === DefaultToolName.WebSearch) {
return <GlobeIcon className={cn("size-3.5 text-blue-400", className)} />;
}
Expand Down
12 changes: 12 additions & 0 deletions src/components/message-parts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -926,6 +934,10 @@ export const ToolMessagePart = memo(
{...(input as any)}
/>
);
case DefaultToolName.CreateTimeline:
return (
<Timeline key={`${toolCallId}-${toolName}`} {...(input as any)} />
);
}
}
return null;
Expand Down
186 changes: 186 additions & 0 deletions src/components/tool-invocation/timeline.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card className="bg-card">
<CardHeader className="flex flex-col gap-2 relative">
<CardTitle className="flex items-center">
Timeline - {title}
<div className="absolute right-4 top-0">
<JsonViewPopup data={props} />
</div>
</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent>
<motion.div
className="relative"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* Vertical connecting line */}
<div
className="absolute left-[9px] top-0 bottom-0 w-[2px] bg-border"
style={{ height: "calc(100% - 24px)" }}
/>

{/* Timeline events */}
<div className="space-y-6">
{events.map((event, index) => {
const config = statusConfig[event.status];
const IconComponent = getIconComponent(event.icon);
const _isLast = index === events.length - 1;

return (
<motion.div
key={index}
variants={itemVariants}
className="relative pl-8 pb-2"
>
{/* Status indicator dot */}
<div
className={cn(
"absolute left-0 top-1 w-5 h-5 rounded-full flex items-center justify-center ring-4",
config.color,
config.ringColor,
)}
>
{IconComponent && (
<IconComponent className="w-3 h-3 text-white" />
)}
</div>

{/* Event content */}
<div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-semibold text-foreground">
{event.title}
</h4>
<Badge
variant={config.badgeVariant}
className={cn("text-xs", config.textColor)}
>
{config.label}
</Badge>
</div>

{event.description && (
<p className="text-sm text-muted-foreground">
{event.description}
</p>
)}

<p className="text-xs text-muted-foreground">
{formatTimestamp(event.timestamp)}
</p>
</div>
</motion.div>
);
})}
</div>
</motion.div>
</CardContent>
</Card>
);
}
1 change: 1 addition & 0 deletions src/lib/ai/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export enum DefaultToolName {
CreateBarChart = "createBarChart",
CreateLineChart = "createLineChart",
CreateTable = "createTable",
CreateTimeline = "createTimeline",
WebSearch = "webSearch",
WebContent = "webContent",
Http = "http",
Expand Down
2 changes: 2 additions & 0 deletions src/lib/ai/tools/tool-kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions src/lib/ai/tools/visualization/create-timeline.ts
Original file line number Diff line number Diff line change
@@ -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";
},
});