feat: Add page-level entrance animations using motion library
Subtle fade-in + y-offset animations on mount for all main pages (initiatives list, initiative detail, agents, inbox) and staggered card animations for initiative and agent lists.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { AlertCircle, Plus } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
@@ -83,12 +84,22 @@ export function InitiativeList({
|
||||
// Populated state
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{initiatives.map((initiative) => (
|
||||
<InitiativeCard
|
||||
{initiatives.map((initiative, i) => (
|
||||
<motion.div
|
||||
key={initiative.id}
|
||||
initiative={initiative}
|
||||
onClick={() => onViewInitiative(initiative.id)}
|
||||
/>
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: Math.min(i * 0.05, 0.3),
|
||||
ease: [0, 0, 0.2, 1],
|
||||
}}
|
||||
>
|
||||
<InitiativeCard
|
||||
initiative={initiative}
|
||||
onClick={() => onViewInitiative(initiative.id)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -182,7 +183,12 @@ function AgentsPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
|
||||
className="flex h-full flex-col gap-4"
|
||||
>
|
||||
{/* Header + Filters */}
|
||||
<div className="shrink-0 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -195,7 +201,12 @@ function AgentsPage() {
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.05, ease: [0, 0, 0.2, 1] }}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{filterOptions.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
@@ -213,11 +224,16 @@ function AgentsPage() {
|
||||
</Badge>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Two-panel layout */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr] min-h-0 flex-1">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1, ease: [0, 0, 0.2, 1] }}
|
||||
className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr] min-h-0 flex-1"
|
||||
>
|
||||
{/* Left: Agent List */}
|
||||
<div className="overflow-y-auto min-h-0 space-y-2">
|
||||
{filtered.length === 0 ? (
|
||||
@@ -227,60 +243,70 @@ function AgentsPage() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((agent) => (
|
||||
<Card
|
||||
filtered.map((agent, i) => (
|
||||
<motion.div
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
"cursor-pointer p-3 transition-colors hover:bg-muted/50",
|
||||
selectedAgentId === agent.id && "bg-muted"
|
||||
)}
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: Math.min(i * 0.05, 0.3),
|
||||
ease: [0, 0, 0.2, 1],
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot status={agent.status} size="sm" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{agent.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{agent.provider}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{modeLabel(agent.mode)}
|
||||
</Badge>
|
||||
{/* Action dropdown */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<AgentActions
|
||||
agentId={agent.id}
|
||||
status={agent.status}
|
||||
isDismissed={!!agent.userDismissedAt}
|
||||
onStop={handleStop}
|
||||
onDelete={handleDelete}
|
||||
onDismiss={handleDismiss}
|
||||
onGoToInbox={handleGoToInbox}
|
||||
/>
|
||||
<Card
|
||||
className={cn(
|
||||
"cursor-pointer p-3 transition-colors hover:bg-muted/50",
|
||||
selectedAgentId === agent.id && "bg-muted"
|
||||
)}
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot status={agent.status} size="sm" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{agent.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{agent.provider}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{modeLabel(agent.mode)}
|
||||
</Badge>
|
||||
{/* Action dropdown */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<AgentActions
|
||||
agentId={agent.id}
|
||||
status={agent.status}
|
||||
isDismissed={!!agent.userDismissedAt}
|
||||
onStop={handleStop}
|
||||
onDelete={handleDelete}
|
||||
onDismiss={handleDismiss}
|
||||
onGoToInbox={handleGoToInbox}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(String(agent.updatedAt))}
|
||||
</span>
|
||||
{agent.status === "waiting_for_input" && (
|
||||
<span
|
||||
className="text-xs text-status-warning-fg hover:underline cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleGoToInbox();
|
||||
}}
|
||||
>
|
||||
Answer questions →
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(String(agent.updatedAt))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
{agent.status === "waiting_for_input" && (
|
||||
<span
|
||||
className="text-xs text-status-warning-fg hover:underline cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleGoToInbox();
|
||||
}}
|
||||
>
|
||||
Answer questions →
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -302,7 +328,7 @@ function AgentsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -176,10 +177,20 @@ function InboxPage() {
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]">
|
||||
{/* Left: Inbox List -- hidden on mobile when agent selected */}
|
||||
<div className={selectedAgent ? "hidden lg:block" : undefined}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.05, ease: [0, 0, 0.2, 1] }}
|
||||
className={selectedAgent ? "hidden lg:block" : undefined}
|
||||
>
|
||||
<InboxList
|
||||
agents={serializedAgents}
|
||||
messages={serializedMessages}
|
||||
@@ -187,7 +198,7 @@ function InboxPage() {
|
||||
onSelectAgent={setSelectedAgentId}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Right: Detail Panel */}
|
||||
{selectedAgent && (
|
||||
@@ -251,6 +262,6 @@ function InboxPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
@@ -99,7 +100,12 @@ function InitiativeDetailPage() {
|
||||
const phases = phasesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
|
||||
className="space-y-3"
|
||||
>
|
||||
{/* Header */}
|
||||
<InitiativeHeader
|
||||
initiative={serializedInitiative}
|
||||
@@ -108,7 +114,12 @@ function InitiativeDetailPage() {
|
||||
/>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-border">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.05, ease: [0, 0, 0.2, 1] }}
|
||||
className="flex gap-1 border-b border-border"
|
||||
>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
@@ -127,28 +138,34 @@ function InitiativeDetailPage() {
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />}
|
||||
{activeTab === "plan" && (
|
||||
<ExecutionTab
|
||||
initiativeId={id}
|
||||
phases={phases}
|
||||
phasesLoading={phasesQuery.isLoading}
|
||||
phasesLoaded={phasesQuery.isSuccess}
|
||||
dependencyEdges={depsQuery.data ?? []}
|
||||
branch={serializedInitiative.branch}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "execution" && (
|
||||
<PipelineTab
|
||||
initiativeId={id}
|
||||
phases={phases}
|
||||
phasesLoading={phasesQuery.isLoading}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "review" && <ReviewTab initiativeId={id} />}
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1, ease: [0, 0, 0.2, 1] }}
|
||||
>
|
||||
{activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />}
|
||||
{activeTab === "plan" && (
|
||||
<ExecutionTab
|
||||
initiativeId={id}
|
||||
phases={phases}
|
||||
phasesLoading={phasesQuery.isLoading}
|
||||
phasesLoaded={phasesQuery.isSuccess}
|
||||
dependencyEdges={depsQuery.data ?? []}
|
||||
branch={serializedInitiative.branch}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "execution" && (
|
||||
<PipelineTab
|
||||
initiativeId={id}
|
||||
phases={phases}
|
||||
phasesLoading={phasesQuery.isLoading}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "review" && <ReviewTab initiativeId={id} />}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InitiativeList } from "@/components/InitiativeList";
|
||||
@@ -31,7 +32,12 @@ function DashboardPage() {
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
|
||||
className="mx-auto max-w-6xl space-y-6"
|
||||
>
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Initiatives</h1>
|
||||
@@ -57,19 +63,25 @@ function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Initiative list */}
|
||||
<InitiativeList
|
||||
statusFilter={statusFilter}
|
||||
onCreateNew={() => setCreateDialogOpen(true)}
|
||||
onViewInitiative={(id) =>
|
||||
navigate({ to: "/initiatives/$id", params: { id } })
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1, ease: [0, 0, 0.2, 1] }}
|
||||
>
|
||||
<InitiativeList
|
||||
statusFilter={statusFilter}
|
||||
onCreateNew={() => setCreateDialogOpen(true)}
|
||||
onViewInitiative={(id) =>
|
||||
navigate({ to: "/initiatives/$id", params: { id } })
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Create initiative dialog */}
|
||||
<CreateInitiativeDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user