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