Repository: alexwine36/serpeo
Pull request #41: Alexwine36/per-146-update-url-input
Author: login: alexwine36
Labels: name: Review effort 3/5
Created: 2025-05-20T17:39:52Z | Merged: 2025-05-20T19:02:09Z

### **PR Type**
Enhancement


___

### **Description**
- Introduced a new `UrlInput` component for website analysis input
  - Replaces simple input with a searchable, validated URL input
  - Supports recent URLs and normalization

- Refactored main page to use the new `UrlInput` component
  - Simplifies form logic and improves UX

- Added reusable UI primitives: Command Palette and Popover
  - Enables advanced input and selection interactions

- Updated dependencies to include `@radix-ui/react-popover` and `cmdk`
  - Ensures support for new UI components

- Minor backend robustness: skip empty rule results on insert


___



### **Changes walkthrough** 📝
<table><thead><tr><th></th><th align="left">Relevant files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><details><summary>4 files</summary><table>
<tr>
  <td><strong>index.tsx</strong><dd><code>Refactor to use new advanced URL input component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/alexwine36/serpeo/pull/41/files#diff-98443a365aa1f380a3b7fd790c32f496e1099262ae2351f6ffcc7445b5061db7">+13/-104</a></td>

</tr>

<tr>
  <td><strong>url-input.tsx</strong><dd><code>Add advanced URL input with validation and history</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/alexwine36/serpeo/pull/41/files#diff-781270a359ccff664067a44e39f0322c6c7ace3312d9cf449d4bd799d5def62f">+184/-0</a>&nbsp; </td>

</tr>

<tr>
  <td><strong>command.tsx</strong><dd><code>Add reusable Command Palette UI component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/alexwine36/serpeo/pull/41/files#diff-98e301bb9820ed42062fb6f08b3364e490cc451a434c2da6a8097881fcede5ca">+177/-0</a>&nbsp; </td>

</tr>

<tr>
  <td><strong>popover.tsx</strong><dd><code>Add reusable Popover UI component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/alexwine36/serpeo/pull/41/files#diff-6a35916223d29bf6ee8aa66339e1a92fef228761a6cc683e0da6908c7fcc23a4">+48/-0</a>&nbsp; &nbsp; </td>

</tr>
</table></details></td></tr><tr><td><strong>Dependencies</strong></td><td><details><summary>2 files</summary><table>
<tr>
  <td><strong>package.json</strong><dd><code>Add cmdk and popover dependencies for new UI components</code>&nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/alexwine36/serpeo/pull/41/files#diff-1ae5a5e8c23bd3bc31ea590a9cbe48eb9ad3bbddc0fb5732d08c04a53c8ad51d">+2/-0</a>&nbsp; &nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>pnpm-lock.yaml</strong><dd><code>Update lockfile for new dependencies (cmdk, popover)</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/alexwine36/serpeo/pull/41/files#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bb">+224/-0</a>&nbsp; </td>

</tr>
</table></details></td></tr><tr><td><strong>Documentation</strong></td><td><details><summary>1 files</summary><table>
<tr>
  <td><strong>good-planets-report.md</strong><dd><code>Add changeset for updated URL input feature</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/alexwine36/serpeo/pull/41/files#diff-703f4aee5d0b6e3285902f94ea1a0b8ffab12f0015b49b92b7093f66e05a908d">+6/-0</a>&nbsp; &nbsp; &nbsp; </td>

</tr>
</table></details></td></tr><tr><td><strong>Bug fix</strong></td><td><details><summary>1 files</summary><table>
<tr>
  <td><strong>lib.rs</strong><dd><code>Improve robustness in rule result insertion logic</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/alexwine36/serpeo/pull/41/files#diff-ca5615d91e3f6ca01bdd090f4943a0b54678d4110e34ef29d32e0a9146bac40f">+16/-5</a>&nbsp; &nbsp; </td>

</tr>
</table></details></td></tr></tr></tbody></table>

___

> <details> <summary>  Need help?</summary><li>Type <code>/help how to ...</code> in the comments thread for any questions about Qodo Merge usage.</li><li>Check out the <a href="https://qodo-merge-docs.qodo.ai/usage-guide/">documentation</a> for more information.</li></details>


Diff:
diff --git a/.changeset/good-planets-report.md b/.changeset/good-planets-report.md
new file mode 100644
index 0000000..ac4079b
--- /dev/null
+++ b/.changeset/good-planets-report.md
@@ -0,0 +1,6 @@
+---
+"serpeo": patch
+"@repo/ui": patch
+---
+
+Updated URL input for analyzing
diff --git a/apps/serpeo/src/components/layout/nav-bar/index.tsx b/apps/serpeo/src/components/layout/nav-bar/index.tsx
index 8f60302..7d74544 100644
--- a/apps/serpeo/src/components/layout/nav-bar/index.tsx
+++ b/apps/serpeo/src/components/layout/nav-bar/index.tsx
@@ -82,9 +82,7 @@ export const NavBar = () => {
                     variant="ghost"
                     className="justify-start text-base"
                   >
-                    <Link {...rest} viewTransition>
-                      {label}
-                    </Link>
+                    <Link {...rest}>{label}</Link>
                   </Button>
                 ))}
               </div>
@@ -105,7 +103,7 @@ export const NavBar = () => {
           <NavigationMenuItem className="flex flex-row">
             {routeList.map(({ label, ...rest }) => (
               <NavigationMenuLink key={label} asChild>
-                <Link {...rest} viewTransition className="px-2 text-base">
+                <Link {...rest} className="px-2 text-base">
                   {label}
                 </Link>
               </NavigationMenuLink>
diff --git a/apps/serpeo/src/components/sites/overview/index.tsx b/apps/serpeo/src/components/sites/overview/index.tsx
index cebe74c..11a7075 100644
--- a/apps/serpeo/src/components/sites/overview/index.tsx
+++ b/apps/serpeo/src/components/sites/overview/index.tsx
@@ -41,6 +41,9 @@ const SiteDisplay = ({ site }: { site: SiteWithSiteRuns }) => {
               <Link
                 // disabled
                 to={"/"}
+                viewTransition={{
+                  types: ["slide-left"],
+                }}
                 //   params={{ siteId: site.site.id.toString() }}
               >
                 <RefreshCcw className="h-4 w-4" />
@@ -51,6 +54,9 @@ const SiteDisplay = ({ site }: { site: SiteWithSiteRuns }) => {
                 <Link
                   to={"/analysis/$site-run-id"}
                   params={{ "site-run-id": mostRecentRun.id.toString() }}
+                  viewTransition={{
+                    types: ["slide-left"],
+                  }}
                 >
                   <ScanSearch className="h-4 w-4" />
                 </Link>
diff --git a/apps/serpeo/src/generated/bindings.ts b/apps/serpeo/src/generated/bindings.ts
index 5b6736d..3f59808 100644
--- a/apps/serpeo/src/generated/bindings.ts
+++ b/apps/serpeo/src/generated/bindings.ts
@@ -88,8 +88,8 @@ siteRunIdSet: "site-run-id-set"
 
 /** user-defined constants **/
 
-export const CRAWL_SETTINGS_KEY = "crawl_settings" as const;
 export const STORE_FILE = "store.json" as const;
+export const CRAWL_SETTINGS_KEY = "crawl_settings" as const;
 
 /** user-defined types **/
 
diff --git a/apps/serpeo/src/main.tsx b/apps/serpeo/src/main.tsx
index 2b744cf..61df1bc 100644
--- a/apps/serpeo/src/main.tsx
+++ b/apps/serpeo/src/main.tsx
@@ -7,7 +7,12 @@ import ReactDOM from "react-dom/client";
 import { routeTree } from "./routeTree.gen";
 
 // Create a new router instance
-const router = createRouter({ routeTree });
+const router = createRouter({
+  routeTree,
+  defaultViewTransition: {
+    types: ["warp"],
+  },
+});
 
 // Register the router instance for type safety
 declare module "@tanstack/react-router" {
diff --git a/apps/serpeo/src/routes/analysis/route.tsx b/apps/serpeo/src/routes/analysis/route.tsx
index fae2780..549accf 100644
--- a/apps/serpeo/src/routes/analysis/route.tsx
+++ b/apps/serpeo/src/routes/analysis/route.tsx
@@ -4,7 +4,7 @@ export const Route = createRootRoute({
   component: () => (
     <div>
       <NavBar />
-      <div className="container mx-auto px-8 py-8 [view-transition-name:warp]">
+      <div className="container mx-auto px-8 py-8 [view-transition-name:main-content]">
         {/* <h1>Analysis</h1> */}
         <div>
           <Outlet />
diff --git a/apps/serpeo/src/routes/index.tsx b/apps/serpeo/src/routes/index.tsx
index 5e436df..29a021d 100644
--- a/apps/serpeo/src/routes/index.tsx
+++ b/apps/serpeo/src/routes/index.tsx
@@ -1,4 +1,3 @@
-import { Badge } from "@repo/ui/components/badge";
 import { Button } from "@repo/ui/components/button";
 import {
   Dialog,
@@ -6,9 +5,9 @@ import {
   DialogHeader,
   DialogTitle,
 } from "@repo/ui/components/dialog";
-import { Input } from "@repo/ui/components/input";
 import { Label } from "@repo/ui/components/label";
 import { ShineBorder } from "@repo/ui/components/shine-border";
+import { UrlInput } from "@repo/ui/components/url-input";
 import { WavyBackground } from "@repo/ui/components/wavy-background";
 import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
 import { useSetAtom } from "jotai";
@@ -28,14 +27,12 @@ function Index() {
   const setResult = useSetAtom(crawlResultAtom);
   const navigate = useNavigate();
   const { data: sites } = useSitesQuery();
-  const [baseUrl, setBaseUrl] = useState("");
 
-  console.log(sites);
-  const analyzeSeo = async () => {
+  const analyzeSeo = async (url: string) => {
     try {
       setLoading(true);
       setResult(RESET);
-      const analysis = await commands.analyzeUrlSeo(baseUrl);
+      const analysis = await commands.analyzeUrlSeo(url);
       console.log("Analysis", analysis);
       if (analysis.status === "ok") {
         setResult(analysis.data);
@@ -58,24 +55,9 @@ function Index() {
   }, []);
 
   return (
-    // <WavyBackground
-    //   // rangeY={200}
-    //   //     // particleCount={500}
-    //   //     // baseHue={120}
-    //   //     baseRadius={3}
-    //   //     rangeRadius={2}
-    //   className="max-w-screen overflow-x-hidden"
-    // >
-    <WavyBackground className="flex max-h-dvh max-w-dvh items-center justify-center overflow-hidden [view-transition-name:warp]">
-      {/* <WavyBackground className="max-h-full overflow-hidden"> */}
+    <WavyBackground className="flex max-h-dvh max-w-dvh items-center justify-center overflow-hidden [view-transition-name:main-content]">
       <div className="relative rounded-md bg-background p-4 shadow-lg">
-        {/* <SettingsCard collapsible /> */}
-        <ShineBorder
-          borderWidth={2}
-          // className="bg-background"
-          shineColor={"chart"}
-          // shineColor={["#A07CFE", "#FE8FB5", "#FFBE7B"]}
-        />
+        <ShineBorder borderWidth={2} shineColor={"chart"} />
         <Button
           className="fixed right-4 bottom-4"
           variant="outline"
@@ -88,42 +70,16 @@ function Index() {
           </Link>
         </Button>
         <div className="flex flex-col gap-4">
-          <div className="space-4 grid w-[calc(100vw-4rem)] grid-cols-1 gap-4 sm:w-sm md:w-md md:grid-cols-[1fr_auto]">
-            <div className="flex flex-col gap-2">
-              <Label>Analyze Your Website</Label>
-              <Input
-                type="url"
-                placeholder="https://"
-                value={baseUrl}
-                onChange={(e) => setBaseUrl(e.target.value)}
-                className="flex-1"
-              />
-            </div>
-            <div className="flex items-end">
-              <Button
-                className="min-w-24"
-                onClick={analyzeSeo}
-                disabled={loading || !baseUrl}
-              >
-                {loading ? "Analyzing..." : "Analyze"}
-              </Button>
-            </div>
+          <div className="flex flex-col gap-2">
+            <Label>Analyze Your Website</Label>
+            <UrlInput
+              className="md:w-sm lg:w-md"
+              onSubmit={(url) => {
+                analyzeSeo(url);
+              }}
+              previousUrls={sites?.map(({ site }) => site.url)}
+            />
           </div>
-          {sites && (
-            <div className="flex w-full flex-row flex-wrap gap-2">
-              {sites
-                ?.filter((_, idx) => idx < 5)
-                .map(({ site }) => (
-                  <Badge
-                    key={site.id}
-                    onClick={() => setBaseUrl(site.url)}
-                    className="cursor-pointer "
-                  >
-                    <span className="max-w-40 truncate">{site.url}</span>
-                  </Badge>
-                ))}
-            </div>
-          )}
         </div>
 
         <Dialog open={loading}>
@@ -136,54 +92,7 @@ function Index() {
             </div>
           </DialogContent>
         </Dialog>
-
-        {/* {result.total_pages > 0 && (
-            <div className="mt-8 space-y-6">
-              <LinkDisplay />
-              <IssueCategoryOverview />
-              <IssueCategoryDetail />
-            </div>
-          )} */}
-
-        {/* <Card>
-            <CardHeader>
-              <CardTitle>SEO Analysis Tool</CardTitle>
-              <CardDescription>
-                Run a full SEO analysis on the website configured in settings.
-              </CardDescription>
-            </CardHeader>
-            <CardContent>
-              <div className="space-4 grid grid-cols-1 gap-4 sm:grid-cols-[1fr_auto]">
-                <Input
-                  type="url"
-                  placeholder="Enter website URL..."
-                  value={baseUrl}
-                  onChange={(e) => setBaseUrl(e.target.value)}
-                  className="flex-1"
-                />
-                <Button
-                  className="min-w-24"
-                  onClick={analyzeSeo}
-                  disabled={loading || !baseUrl}
-                >
-                  {loading ? "Analyzing..." : "Analyze"}
-                </Button>
-              </div>
-
-              <AnalysisStatus />
-              {result.total_pages > 0 && (
-                <div className="mt-8 space-y-6">
-                  <LinkDisplay />
-                  <IssueCategoryOverview />
-                  <IssueCategoryDetail />
-                </div>
-              )}
-            </CardContent>
-          </Card> */}
       </div>
-      {/* </WavyBackground> */}
     </WavyBackground>
-
-    // </WavyBackground>
   );
 }
diff --git a/apps/serpeo/src/routes/settings/index.tsx b/apps/serpeo/src/routes/settings/index.tsx
index cdcbc91..d02039d 100644
--- a/apps/serpeo/src/routes/settings/index.tsx
+++ b/apps/serpeo/src/routes/settings/index.tsx
@@ -7,7 +7,7 @@ export const Route = createFileRoute("/settings/")({
 
 function SettingsPage() {
   return (
-    <div className="container mx-auto py-8 [view-transition-name:warp]">
+    <div className="container mx-auto py-8 ">
       <SettingsCard />
     </div>
   );
diff --git a/apps/serpeo/src/routes/settings/route.tsx b/apps/serpeo/src/routes/settings/route.tsx
index 5b95382..dafabf4 100644
--- a/apps/serpeo/src/routes/settings/route.tsx
+++ b/apps/serpeo/src/routes/settings/route.tsx
@@ -5,7 +5,7 @@ export const Route = createRootRoute({
   component: () => (
     <div>
       <NavBar />
-      <div className="container mx-auto px-8 py-8 [view-transition-name:warp]">
+      <div className="container mx-auto px-8 py-8 [view-transition-name:main-content]">
         <Outlet />
       </div>
     </div>
diff --git a/crates/seo-storage/src/lib.rs b/crates/seo-storage/src/lib.rs
index ec5f6cb..be08822 100644
--- a/crates/seo-storage/src/lib.rs
+++ b/crates/seo-storage/src/lib.rs
@@ -101,7 +101,7 @@ impl SeoStorage {
                 ])
                 .to_owned();
 
-            let res = PluginRule::insert(rule)
+            let _ = PluginRule::insert(rule)
                 .on_conflict(on_conflict)
                 .exec(&self.db)
                 .await
@@ -300,11 +300,12 @@ impl SeoStorage {
             .update_column(page_rule_result::Column::Passed)
             .to_owned();
 
-            let res = PageRuleResult::insert_many(rule_results)
-                .on_conflict(on_conflict)
-                .exec(&self.db)
-                .await
-                .unwrap();
+            if !rule_results.is_empty() {
+                let res = PageRuleResult::insert_many(rule_results)
+                    .on_conflict(on_conflict)
+                    .exec(&self.db)
+                    .await?;
+            }
         }
         Ok(())
     }
@@ -330,7 +331,7 @@ impl SeoStorage {
                             &rule_result.rule_id,
                             rule_result.passed,
                         );
-                        let res = PageRuleResult::insert(site_rule_result)
+                        let _ = PageRuleResult::insert(site_rule_result)
                             .exec(&self.db)
                             .await
                             .unwrap();
@@ -350,10 +351,18 @@ impl SeoStorage {
                                 &rule_result_clone.rule_id,
                                 rule_result_clone.passed,
                             );
-                            let res = PageRuleResult::insert(site_rule_result)
+
+                            let on_conflict = OnConflict::columns([
+                                page_rule_result::Column::SitePageId,
+                                page_rule_result::Column::RuleId,
+                            ])
+                            .update_column(page_rule_result::Column::Passed)
+                            .to_owned();
+
+                            let _ = PageRuleResult::insert(site_rule_result)
+                                .on_conflict(on_conflict)
                                 .exec(&self.db)
-                                .await
-                                .unwrap();
+                                .await?;
                         }
                     }
                 }
diff --git a/packages/ui/package.json b/packages/ui/package.json
index e9d1875..fe869ba 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -14,6 +14,7 @@
     "@radix-ui/react-dropdown-menu": "2.1.12",
     "@radix-ui/react-label": "2.1.4",
     "@radix-ui/react-navigation-menu": "1.2.10",
+    "@radix-ui/react-popover": "1.1.14",
     "@radix-ui/react-progress": "1.1.4",
     "@radix-ui/react-scroll-area": "1.2.6",
     "@radix-ui/react-separator": "1.1.4",
@@ -24,6 +25,7 @@
     "@tsparticles/slim": "3.8.1",
     "class-variance-authority": "0.7.1",
     "clsx": "2.1.1",
+    "cmdk": "1.1.1",
     "framer-motion": "12.9.4",
     "lucide-react": "catalog:",
     "motion": "12.9.4",
diff --git a/packages/ui/src/components/command.tsx b/packages/ui/src/components/command.tsx
new file mode 100644
index 0000000..9e2cd1c
--- /dev/null
+++ b/packages/ui/src/components/command.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import { Command as CommandPrimitive } from "cmdk";
+import { SearchIcon } from "lucide-react";
+import type * as React from "react";
+
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from "@repo/ui/components/dialog";
+import { cn } from "@repo/ui/lib/utils";
+
+function Command({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive>) {
+  return (
+    <CommandPrimitive
+      data-slot="command"
+      className={cn(
+        "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
+        className
+      )}
+      {...props}
+    />
+  );
+}
+
+function CommandDialog({
+  title = "Command Palette",
+  description = "Search for a command to run...",
+  children,
+  ...props
+}: React.ComponentProps<typeof Dialog> & {
+  title?: string;
+  description?: string;
+}) {
+  return (
+    <Dialog {...props}>
+      <DialogHeader className="sr-only">
+        <DialogTitle>{title}</DialogTitle>
+        <DialogDescription>{description}</DialogDescription>
+      </DialogHeader>
+      <DialogContent className="overflow-hidden p-0">
+        <Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
+          {children}
+        </Command>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+function CommandInput({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Input>) {
+  return (
+    <div
+      data-slot="command-input-wrapper"
+      className="flex h-9 items-center gap-2 border-b px-3"
+    >
+      <SearchIcon className="size-4 shrink-0 opacity-50" />
+      <CommandPrimitive.Input
+        data-slot="command-input"
+        className={cn(
+          "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
+          className
+        )}
+        {...props}
+      />
+    </div>
+  );
+}
+
+function CommandList({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.List>) {
+  return (
+    <CommandPrimitive.List
+      data-slot="command-list"
+      className={cn(
+        "max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden",
+        className
+      )}
+      {...props}
+    />
+  );
+}
+
+function CommandEmpty({
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
+  return (
+    <CommandPrimitive.Empty
+      data-slot="command-empty"
+      className="py-6 text-center text-sm"
+      {...props}
+    />
+  );
+}
+
+function CommandGroup({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Group>) {
+  return (
+    <CommandPrimitive.Group
+      data-slot="command-group"
+      className={cn(
+        "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:text-xs",
+        className
+      )}
+      {...props}
+    />
+  );
+}
+
+function CommandSeparator({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
+  return (
+    <CommandPrimitive.Separator
+      data-slot="command-separator"
+      className={cn("-mx-1 h-px bg-border", className)}
+      {...props}
+    />
+  );
+}
+
+function CommandItem({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Item>) {
+  return (
+    <CommandPrimitive.Item
+      data-slot="command-item"
+      className={cn(
+        "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
+        className
+      )}
+      {...props}
+    />
+  );
+}
+
+function CommandShortcut({
+  className,
+  ...props
+}: React.ComponentProps<"span">) {
+  return (
+    <span
+      data-slot="command-shortcut"
+      className={cn(
+        "ml-auto text-muted-foreground text-xs tracking-widest",
+        className
+      )}
+      {...props}
+    />
+  );
+}
+
+export {
+  Command,
+  CommandDialog,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+  CommandSeparator,
+  CommandShortcut,
+};
diff --git a/packages/ui/src/components/popover.tsx b/packages/ui/src/components/popover.tsx
new file mode 100644
index 0000000..dc5f3bc
--- /dev/null
+++ b/packages/ui/src/components/popover.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import * as PopoverPrimitive from "@radix-ui/react-popover";
+import type * as React from "react";
+
+import { cn } from "@repo/ui/lib/utils";
+
+function Popover({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
+  return <PopoverPrimitive.Root data-slot="popover" {...props} />;
+}
+
+function PopoverTrigger({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
+  return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
+}
+
+function PopoverContent({
+  className,
+  align = "center",
+  sideOffset = 4,
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
+  return (
+    <PopoverPrimitive.Portal>
+      <PopoverPrimitive.Content
+        data-slot="popover-content"
+        align={align}
+        sideOffset={sideOffset}
+        className={cn(
+          "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
+          className
+        )}
+        {...props}
+      />
+    </PopoverPrimitive.Portal>
+  );
+}
+
+function PopoverAnchor({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
+  return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
+}
+
+export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
diff --git a/packages/ui/src/components/url-input.tsx b/packages/ui/src/components/url-input.tsx
new file mode 100644
index 0000000..540ddc7
--- /dev/null
+++ b/packages/ui/src/components/url-input.tsx
@@ -0,0 +1,177 @@
+"use client";
+import { Button } from "@repo/ui/components/button";
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from "@repo/ui/components/command";
+import { Input } from "@repo/ui/components/input";
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from "@repo/ui/components/popover";
+
+import { Globe } from "lucide-react";
+import type React from "react";
+import { useCallback, useState } from "react";
+import { z } from "zod";
+import { cn } from "../lib/utils.js";
+type UrlInputProps = {
+  onSubmit?: (url: string) => void;
+  placeholder?: string;
+  className?: string;
+  previousUrls?: string[];
+};
+
+const UrlSchema = z.string().url();
+
+export const UrlInput = ({
+  onSubmit,
+  placeholder,
+  className,
+  previousUrls = [],
+}: UrlInputProps) => {
+  const [open, setOpen] = useState(false);
+  const [inputValue, setInputValue] = useState("");
+  const [commandInputValue, setCommandInputValue] = useState("");
+
+  const validateUrl = (url: string) => {
+    if (!url) {
+      return;
+    }
+
+    // Normalize URL (add https:// if missing)
+    let normalizedUrl = url;
+    if (!url.startsWith("http://") && !url.startsWith("https://")) {
+      normalizedUrl = `https://${url}`;
+    }
+
+    const result = UrlSchema.safeParse(normalizedUrl);
+
+    if (!result.success) {
+      return;
+    }
+
+    return normalizedUrl;
+  };
+
+  const handleSubmit = (e?: React.FormEvent) => {
+    if (e) {
+      e.preventDefault();
+    }
+
+    if (!inputValue.trim()) {
+      return;
+    }
+
+    const normalizedUrl = validateUrl(inputValue);
+    if (onSubmit && normalizedUrl) {
+      onSubmit(normalizedUrl);
+    }
+
+    setInputValue("");
+    setOpen(false);
+  };
+
+  const handleSelect = (rawUrl: string) => {
+    const url = validateUrl(rawUrl);
+    if (!url) {
+      return;
+    }
+    setInputValue(url);
+    setOpen(false);
+  };
+
+  // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
+  const AddGroup = useCallback(() => {
+    if (!validateUrl(commandInputValue)) {
+      return null;
+    }
+    return (
+      <CommandGroup
+        heading="Add URL"
+        className={cn(!validateUrl(commandInputValue) && "hidden")}
+      >
+        <CommandItem
+          value={commandInputValue}
+          onSelect={() => handleSelect(commandInputValue)}
+          className={cn("flex items-center")}
+        >
+          <Globe className="mr-2 h-4 w-4" />
+          <span className="truncate">{commandInputValue}</span>
+        </CommandItem>
+      </CommandGroup>
+    );
+  }, [commandInputValue]);
+
+  return (
+    <form
+      onSubmit={handleSubmit}
+      className={cn("flex w-full items-center space-x-2", className)}
+    >
+      <div className="relative flex-1">
+        <Popover open={open} modal onOpenChange={setOpen}>
+          <PopoverTrigger asChild>
+            <div className="relative w-full">
+              <Globe className="-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-muted-foreground" />
+              <Input
+                value={inputValue}
+                onChange={(e) => setInputValue(e.target.value)}
+                placeholder={placeholder}
+                className="w-full pr-10 pl-10"
+                onFocus={() => setOpen(true)}
+              />
+              {inputValue && (
+                <Button
+                  type="button"
+                  variant="ghost"
+                  size="sm"
+                  className="absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent"
+                  onClick={() => setInputValue("")}
+                >
+                  <span className="sr-only">Clear</span>
+                  <span className="text-muted-foreground text-sm">×</span>
+                </Button>
+              )}
+            </div>
+          </PopoverTrigger>
+          <PopoverContent
+            className="w-[var(--radix-popover-trigger-width)] p-0"
+            align="start"
+          >
+            <Command>
+              <CommandInput
+                onValueChange={(v) => {
+                  setCommandInputValue(v);
+                }}
+                placeholder="Search URLs..."
+              />
+              <CommandList>
+                <CommandEmpty>Invalid URL Address</CommandEmpty>
+                <CommandGroup heading="Recent URLs">
+                  {previousUrls.map((url, index) => (
+                    <CommandItem
+                      key={index}
+                      value={url}
+                      onSelect={() => handleSelect(url)}
+                      className="flex items-center"
+                    >
+                      <Globe className="mr-2 h-4 w-4" />
+                      <span className="truncate">{url}</span>
+                    </CommandItem>
+                  ))}
+                </CommandGroup>
+                <AddGroup />
+              </CommandList>
+            </Command>
+          </PopoverContent>
+        </Popover>
+      </div>
+      <Button type="submit">Go</Button>
+    </form>
+  );
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e1c6f02..f3ec721 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -228,6 +228,9 @@ importers:
       '@radix-ui/react-navigation-menu':
         specifier: 1.2.10
         version: 1.2.10(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@radix-ui/react-popover':
+        specifier: 1.1.14
+        version: 1.1.14(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       '@radix-ui/react-progress':
         specifier: 1.1.4
         version: 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -258,6 +261,9 @@ importers:
       clsx:
         specifier: 2.1.1
         version: 2.1.1
+      cmdk:
+        specifier: 1.1.1
+        version: 1.1.1(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       framer-motion:
         specifier: 12.9.4
         version: 12.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -1013,6 +1019,19 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@radix-ui/react-arrow@1.1.7':
+    resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-collapsible@1.1.8':
     resolution: {integrity: sha512-hxEsLvK9WxIAPyxdDRULL4hcaSjMZCfP7fHB0Z1uUnDoDBat1Zh46hwYfa69DeZAbJrPckjf0AGAtEZyvDyJbw==}
     peerDependencies:
@@ -1079,6 +1098,19 @@ packages:
       '@types/react':
         optional: true
 
+  '@radix-ui/react-dismissable-layer@1.1.10':
+    resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-dismissable-layer@1.1.7':
     resolution: {integrity: sha512-j5+WBUdhccJsmH5/H0K6RncjDtoALSEr6jbkaZu+bjw6hOPOhHycr6vEUujl+HBK8kjUfWcoCJXxP6e4lUlMZw==}
     peerDependencies:
@@ -1127,6 +1159,19 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@radix-ui/react-focus-scope@1.1.7':
+    resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-id@1.1.1':
     resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
     peerDependencies:
@@ -1175,6 +1220,19 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@radix-ui/react-popover@1.1.14':
+    resolution: {integrity: sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-popper@1.2.4':
     resolution: {integrity: sha512-3p2Rgm/a1cK0r/UVkx5F/K9v/EplfjAeIFCGOPYPO4lZ0jtg4iSQXt/YGTSLWaf4x7NG6Z4+uKFcylcTZjeqDA==}
     peerDependencies:
@@ -1188,6 +1246,19 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@radix-ui/react-popper@1.2.7':
+    resolution: {integrity: sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-portal@1.1.6':
     resolution: {integrity: sha512-XmsIl2z1n/TsYFLIdYam2rmFwf9OC/Sh2avkbmVMDuBZIe7hSpM0cYnWPAo7nHOVx8zTuwDZGByfcqLdnzp3Vw==}
     peerDependencies:
@@ -1201,6 +1272,19 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@radix-ui/react-portal@1.1.9':
+    resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-presence@1.1.4':
     resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==}
     peerDependencies:
@@ -1227,6 +1311,19 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@radix-ui/react-primitive@2.1.3':
+    resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-progress@1.1.4':
     resolution: {integrity: sha512-8rl9w7lJdcVPor47Dhws9mUHRHLE+8JEgyJRdNWCpGPa6HIlr3eh+Yn9gyx1CnCLbw5naHsI2gaO9dBWO50vzw==}
     peerDependencies:
@@ -1288,6 +1385,15 @@ packages:
       '@types/react':
         optional: true
 
+  '@radix-ui/react-slot@1.2.3':
+    resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
   '@radix-ui/react-use-callback-ref@1.1.1':
     resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
     peerDependencies:
@@ -2351,6 +2457,12 @@ packages:
     resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
     engines: {node: '>=6'}
 
+  cmdk@1.1.1:
+    resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
+    peerDependencies:
+      react: ^18 || ^19 || ^19.0.0-rc
+      react-dom: ^18 || ^19 || ^19.0.0-rc
+
   color-convert@1.9.3:
     resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
 
@@ -5737,6 +5849,15 @@ snapshots:
       '@types/react': 19.1.2
       '@types/react-dom': 19.1.2(@types/react@19.1.2)
 
+  '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+    dependencies:
+      '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      react: 19.1.0
+      react-dom: 19.1.0(react@19.1.0)
+    optionalDependencies:
+      '@types/react': 19.1.2
+      '@types/react-dom': 19.1.2(@types/react@19.1.2)
+
   '@radix-ui/react-collapsible@1.1.8(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
     dependencies:
       '@radix-ui/primitive': 1.1.2
@@ -5805,6 +5926,19 @@ snapshots:
     optionalDependencies:
       '@types/react': 19.1.2
 
+  '@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.2
+      '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+      '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0)
+      '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.2)(react@19.1.0)
+      react: 19.1.0
+      react-dom: 19.1.0(react@19.1.0)
+    optionalDependencies:
+      '@types/react': 19.1.2
+      '@types/react-dom': 19.1.2(@types/react@19.1.2)
+
   '@radix-ui/react-dismissable-layer@1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
     dependencies:
       '@radix-ui/primitive': 1.1.2
@@ -5850,6 +5984,17 @@ snapshots:
       '@types/react': 19.1.2
       '@types/react-dom': 19.1.2(@types/react@19.1.2)
 
+  '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+    dependencies:
+      '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+      '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0)
+      react: 19.1.0
+      react-dom: 19.1.0(react@19.1.0)
+    optionalDependencies:
+      '@types/react': 19.1.2
+      '@types/react-dom': 19.1.2(@types/react@19.1.2)
+
   '@radix-ui/react-id@1.1.1(@types/react@19.1.2)(react@19.1.0)':
     dependencies:
       '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.2)(react@19.1.0)
@@ -5914,6 +6059,29 @@ snapshots:
       '@types/react': 19.1.2
       '@types/react-dom': 19.1.2(@types/react@19.1.2)
 
+  '@radix-ui/react-popover@1.1.14(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.2
+      '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+      '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+      '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+      '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@radix-ui/react-id': 1.1.1(@types/react@19.1.2)(react@19.1.0)
+      '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@radix-ui/react-slot': 1.2.3(@types/react@19.1.2)(react@19.1.0)
+      '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.2)(react@19.1.0)
+      aria-hidden: 1.2.4
+      react: 19.1.0
+      react-dom: 19.1.0(react@19.1.0)
+      react-remove-scroll: 2.6.3(@types/react@19.1.2)(react@19.1.0)
+    optionalDependencies:
+      '@types/react': 19.1.2
+      '@types/react-dom': 19.1.2(@types/react@19.1.2)
+
   '@radix-ui/react-popper@1.2.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
     dependencies:
       '@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -5932,6 +6100,24 @@ snapshots:
       '@types/react': 19.1.2
       '@types/react-dom': 19.1.2(@types/react@19.1.2)
 
+  '@radix-ui/react-popper@1.2.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+    dependencies:
+      '@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+      '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+      '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0)
+      '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.2)(react@19.1.0)
+      '@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.2)(react@19.1.0)
+      '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.2)(react@19.1.0)
+      '@radix-ui/rect': 1.1.1
+      react: 19.1.0
+      react-dom: 19.1.0(react@19.1.0)
+    optionalDependencies:
+      '@types/react': 19.1.2
+      '@types/react-dom': 19.1.2(@types/react@19.1.2)
+
   '@radix-ui/react-portal@1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
     dependencies:
       '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -5942,6 +6128,16 @@ snapshots:
       '@types/react': 19.1.2
       '@types/react-dom': 19.1.2(@types/react@19.1.2)
 
+  '@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+    dependencies:
+      '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.2)(react@19.1.0)
+      react: 19.1.0
+      react-dom: 19.1.0(react@19.1.0)
+    optionalDependencies:
+      '@types/react': 19.1.2
+      '@types/react-dom': 19.1.2(@types/react@19.1.2)
+
   '@radix-ui/react-presence@1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
     dependencies:
       '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0)
@@ -5961,6 +6157,15 @@ snapshots:
       '@types/react': 19.1.2
       '@types/react-dom': 19.1.2(@types/react@19.1.2)
 
+  '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+    dependencies:
+      '@radix-ui/react-slot': 1.2.3(@types/react@19.1.2)(react@19.1.0)
+      react: 19.1.0
+      react-dom: 19.1.0(react@19.1.0)
+    optionalDependencies:
+      '@types/react': 19.1.2
+      '@types/react-dom': 19.1.2(@types/react@19.1.2)
+
   '@radix-ui/react-progress@1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
     dependencies:
       '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0)
@@ -6021,6 +6226,13 @@ snapshots:
     optionalDependencies:
       '@types/react': 19.1.2
 
+  '@radix-ui/react-slot@1.2.3(@types/react@19.1.2)(react@19.1.0)':
+    dependencies:
+      '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+      react: 19.1.0
+    optionalDependencies:
+      '@types/react': 19.1.2
+
   '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.2)(react@19.1.0)':
     dependencies:
       react: 19.1.0
@@ -7193,6 +7405,18 @@ snapshots:
 
   clsx@2.1.1: {}
 
+  cmdk@1.1.1(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+    dependencies:
+      '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+      '@radix-ui/react-dialog': 1.1.11(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@radix-ui/react-id': 1.1.1(@types/react@19.1.2)(react@19.1.0)
+      '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      react: 19.1.0
+      react-dom: 19.1.0(react@19.1.0)
+    transitivePeerDependencies:
+      - '@types/react'
+      - '@types/react-dom'
+
   color-convert@1.9.3:
     dependencies:
       color-name: 1.1.3


Reviews:
user: login: qodo-code-review[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T17:53:46Z
user: login: qodo-code-review[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T17:55:34Z
user: login: qodo-code-review[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T17:55:41Z

Review comments:
diff_hunk: @@ -300,11 +300,13 @@ impl SeoStorage {
             .update_column(page_rule_result::Column::Passed)
             .to_owned();
 
-            let res = PageRuleResult::insert_many(rule_results)
-                .on_conflict(on_conflict)
-                .exec(&self.db)
-                .await
-                .unwrap();
+            if !rule_results.is_empty() {
+                let _ = PageRuleResult::insert_many(rule_results)
+                    .on_conflict(on_conflict)
+                    .exec(&self.db)
+                    .await
+                    .unwrap();
+            }
path: crates/seo-storage/src/lib.rs
user: login: qodo-code-review[bot]
body: **Suggestion:** Handle database errors gracefully
```suggestion
            if !rule_results.is_empty() {
                let res = PageRuleResult::insert_many(rule_results)
                    .on_conflict(on_conflict)
                    .exec(&self.db)
                    .await?;
            }
```

<!-- manually_applied -->
created_at: 2025-05-20T17:53:46Z
author_association: NONE
diff_hunk: @@ -350,7 +352,16 @@ impl SeoStorage {
                                 &rule_result_clone.rule_id,
                                 rule_result_clone.passed,
                             );
-                            let res = PageRuleResult::insert(site_rule_result)
+
+                            let on_conflict = OnConflict::columns([
+                                page_rule_result::Column::SitePageId,
+                                page_rule_result::Column::RuleId,
+                            ])
+                            .update_column(page_rule_result::Column::Passed)
+                            .to_owned();
+
+                            let _ = PageRuleResult::insert(site_rule_result)
+                                .on_conflict(on_conflict)
                                 .exec(&self.db)
                                 .await
                                 .unwrap();
path: crates/seo-storage/src/lib.rs
user: login: qodo-code-review[bot]
body: **Suggestion:** Propagate errors instead of panicking
```suggestion
                            let res = PageRuleResult::insert(site_rule_result)
                                .on_conflict(on_conflict)
                                .exec(&self.db)
                                .await?;
```

<!-- manually_applied -->
created_at: 2025-05-20T17:55:34Z
author_association: NONE
diff_hunk: @@ -0,0 +1,184 @@
+"use client";
+import { Button } from "@repo/ui/components/button";
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from "@repo/ui/components/command";
+import { Input } from "@repo/ui/components/input";
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from "@repo/ui/components/popover";
+
+import { Globe } from "lucide-react";
+import type React from "react";
+import { useCallback, useState } from "react";
+import { z } from "zod";
+import { cn } from "../lib/utils.js";
+type UrlInputProps = {
+  onSubmit?: (url: string) => void;
+  placeholder?: string;
+  className?: string;
+  previousUrls?: string[];
+};
+
+const UrlSchema = z.string().url();
+
+export const UrlInput = ({
+  onSubmit,
+  placeholder,
+  className,
+  previousUrls = [],
+}: UrlInputProps) => {
+  const [open, setOpen] = useState(false);
+  const [inputValue, setInputValue] = useState("");
+  const [commandInputValue, setCommandInputValue] = useState("");
+
+  const validateUrl = (url: string) => {
+    if (!url) {
+      return;
+    }
+
+    // Normalize URL (add https:// if missing)
+    let normalizedUrl = url;
+    if (!url.startsWith("http://") && !url.startsWith("https://")) {
+      normalizedUrl = `https://${url}`;
+    }
+
+    const result = UrlSchema.safeParse(normalizedUrl);
+
+    if (!result.success) {
+      return;
+    }
+
+    return normalizedUrl;
+  };
+
+  const handleSubmit = (e?: React.FormEvent) => {
+    if (e) {
+      e.preventDefault();
+    }
+
+    if (!inputValue.trim()) {
+      return;
+    }
+
+    const normalizedUrl = validateUrl(inputValue);
+    if (onSubmit && normalizedUrl) {
+      onSubmit(normalizedUrl);
+    }
+
+    setInputValue("");
+    setOpen(false);
+  };
+
+  const handleSelect = (rawUrl: string) => {
+    const url = validateUrl(rawUrl);
+    if (!url) {
+      return;
+    }
+    // if (onSubmit && url) {
+    //   onSubmit(url);
+    // }
+    setInputValue(url);
+
+    // if (onSubmit) {
+    //   onSubmit(url);
+    // }
+    setOpen(false);
+  };
path: packages/ui/src/components/url-input.tsx
user: login: qodo-code-review[bot]
body: **Suggestion:** Remove commented code
```suggestion
  const handleSelect = (rawUrl: string) => {
    const url = validateUrl(rawUrl);
    if (!url) {
      return;
    }
    setInputValue(url);
    setOpen(false);
  };
```

<!-- manually_applied -->
created_at: 2025-05-20T17:55:41Z
author_association: NONE

PR comments:
user: login: linear[bot]
created_at: 2025-05-20T17:39:54Z
body: <p><a href="https://linear.app/personal-projects-alex-wine/issue/PER-146/update-url-input">PER-146 Update url input</a></p>
author_association: NONE
user: login: changeset-bot[bot]
created_at: 2025-05-20T17:39:55Z
body: ###  🦋  Changeset detected

Latest commit: 3d71f72a426b2e4366cb42d670bc587124cbd69b

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 2 packages</summary>

  | Name     | Type  |
| -------- | ----- |
| serpeo   | Patch |
| @repo/ui | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/alexwine36/serpeo/new/alexwine36/per-146-update-url-input?filename=.changeset/chatty-years-sip.md&value=---%0A%22serpeo%22%3A%20patch%0A%22%40repo%2Fui%22%3A%20patch%0A---%0A%0AAlexwine36%2Fper-146-update-url-input%0A)


author_association: NONE
user: login: qodo-code-review[bot]
created_at: 2025-05-20T17:40:17Z
body: _Qodo Merge was enabled for this repository. To continue using it, please link your Git account with your Qodo account [here](https://app.qodo.ai/qodo-merge)._

## PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

<table>
<tr><td>⏱️&nbsp;<strong>Estimated effort to review</strong>: 3 🔵🔵🔵⚪⚪</td></tr>
<tr><td>🧪&nbsp;<strong>No relevant tests</strong></td></tr>
<tr><td>🔒&nbsp;<strong>No security concerns identified</strong></td></tr>
<tr><td>⚡&nbsp;<strong>Recommended focus areas for review</strong><br><br>

<details><summary><a href='https://github.com/alexwine36/serpeo/pull/41/files#diff-781270a359ccff664067a44e39f0322c6c7ace3312d9cf449d4bd799d5def62fR85-R92'><strong>Commented Code</strong></a>

There are commented out code blocks in the handleSelect function that should be removed or implemented properly.
</summary>

```typescriptreact
// if (onSubmit && url) {
//   onSubmit(url);
// }
setInputValue(url);

// if (onSubmit) {
//   onSubmit(url);
// }
```

</details>

<details><summary><a href='https://github.com/alexwine36/serpeo/pull/41/files#diff-ca5615d91e3f6ca01bdd090f4943a0b54678d4110e34ef29d32e0a9146bac40fR304-R308'><strong>Unused Variable</strong></a>

The variable `res` is declared but never used in the conditional block, which could indicate a missing operation or error handling.
</summary>

```rust
let res = PageRuleResult::insert_many(rule_results)
    .on_conflict(on_conflict)
    .exec(&self.db)
    .await
    .unwrap();
```

</details>

</td></tr>
</table>

author_association: NONE
user: login: qodo-code-review[bot]
created_at: 2025-05-20T17:40:45Z
body: _Qodo Merge was enabled for this repository. To continue using it, please link your Git account with your Qodo account [here](https://app.qodo.ai/qodo-merge)._

## PR Code Suggestions ✨

<!-- 3ef66a0 -->

Explore these optional code suggestions:

<table><thead><tr><td><strong>Category</strong></td><td align=left><strong>Suggestion&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </strong></td><td align=center><strong>Impact</strong></td></tr><tbody><tr><td rowspan=2>Possible issue</td>
<td>



<details><summary>✅ 
Handle database errors gracefully<!-- not_implemented -->
</summary>

___

<details><summary><b>Suggestion Impact:</b></summary>The commit implemented the suggestion by replacing unwrap() with the ? operator in multiple places, including the exact location mentioned in the suggestion (lines 14-16). The commit also applied similar error handling improvements in other parts of the code (lines 37-39).

code diff:

```diff
@@ -304,8 +304,7 @@
                 let res = PageRuleResult::insert_many(rule_results)
                     .on_conflict(on_conflict)
                     .exec(&self.db)
-                    .await
-                    .unwrap();
+                    .await?;
```

</details>


___


**The code is using <code>unwrap()</code> which will panic if the database operation fails. <br>Consider handling potential errors gracefully by propagating them with the <code>?</code> <br>operator instead of unwrapping.**

[crates/seo-storage/src/lib.rs [303-309]](https://github.com/alexwine36/serpeo/pull/41/files#diff-ca5615d91e3f6ca01bdd090f4943a0b54678d4110e34ef29d32e0a9146bac40fR303-R309)

```diff
 if !rule_results.is_empty() {
     let res = PageRuleResult::insert_many(rule_results)
         .on_conflict(on_conflict)
         .exec(&self.db)
-        .await
-        .unwrap();
+        .await?;
 }
```

`[Suggestion processed]`

<details><summary>Suggestion importance[1-10]: 8</summary>

__

Why: Replacing `unwrap()` with the `?` operator improves error handling by propagating database errors instead of panicking, which is important for robust application behavior. The suggestion is accurate and directly improves the code's reliability.


</details></details></td><td align=center>Medium

</td></tr><tr><td>



<details><summary>✅ 
Propagate errors instead of panicking<!-- not_implemented -->
</summary>

___

<details><summary><b>Suggestion Impact:</b></summary>The commit implemented the suggestion by replacing unwrap() with ? operator in multiple places, including the exact location mentioned in the suggestion (lines 33-39). The commit also made similar changes in other parts of the code.

code diff:

```diff
-                            let res = PageRuleResult::insert(site_rule_result)
+                            let _ = PageRuleResult::insert(site_rule_result)
                                 .on_conflict(on_conflict)
                                 .exec(&self.db)
-                                .await
-                                .unwrap();
+                                .await?;
```

</details>


___


**Using <code>unwrap()</code> on the database operation result will cause the application to <br>panic if the operation fails. Replace with the <code>?</code> operator to properly propagate <br>errors up the call chain.**

[crates/seo-storage/src/lib.rs [363-367]](https://github.com/alexwine36/serpeo/pull/41/files#diff-ca5615d91e3f6ca01bdd090f4943a0b54678d4110e34ef29d32e0a9146bac40fR363-R367)

```diff
 let res = PageRuleResult::insert(site_rule_result)
     .on_conflict(on_conflict)
     .exec(&self.db)
-    .await
-    .unwrap();
+    .await?;
```

`[Suggestion processed]`

<details><summary>Suggestion importance[1-10]: 8</summary>

__

Why: This suggestion correctly replaces `unwrap()` with `?`, ensuring that errors from the database operation are properly propagated rather than causing a panic. This is a best practice in Rust for error handling and increases the robustness of the function.


</details></details></td><td align=center>Medium

</td></tr><tr><td rowspan=1>General</td>
<td>



<details><summary>✅ 
Remove commented code
</summary>

___

<details><summary><b>Suggestion Impact:</b></summary>The commit removed all the commented-out code from the handleSelect function exactly as suggested, which improves code readability and eliminates confusion about when onSubmit should be called

code diff:

```diff
-    // if (onSubmit && url) {
-    //   onSubmit(url);
-    // }
     setInputValue(url);
-
-    // if (onSubmit) {
-    //   onSubmit(url);
-    // }
```

</details>


___


**The <code>handleSelect</code> function contains commented-out code that creates confusion <br>about whether <code>onSubmit</code> should be called. Clean up the function by removing the <br>commented code and clarifying the intended behavior.**

[packages/ui/src/components/url-input.tsx [80-94]](https://github.com/alexwine36/serpeo/pull/41/files#diff-781270a359ccff664067a44e39f0322c6c7ace3312d9cf449d4bd799d5def62fR80-R94)

```diff
 const handleSelect = (rawUrl: string) => {
   const url = validateUrl(rawUrl);
   if (!url) {
     return;
   }
-  // if (onSubmit && url) {
-  //   onSubmit(url);
-  // }
   setInputValue(url);
-
-  // if (onSubmit) {
-  //   onSubmit(url);
-  // }
   setOpen(false);
 };
```

`[Suggestion processed]`

<details><summary>Suggestion importance[1-10]: 4</summary>

__

Why: Removing commented-out code improves readability and reduces confusion, but it is a minor cleanup with limited impact on functionality or correctness.

</details></details></td><td align=center>Low

</td></tr>
<tr><td align="center" colspan="2">

- [ ] Update <!-- /improve_multi --more_suggestions=true -->

</td><td></td></tr></tbody></table>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-20T17:46:39Z
body: <h2><a href="https://bencher.dev/perf/serpeo/reports/5c2845fa-abe1-4bb4-87de-02c5ab9d6c26?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=serpeo"><img src="https://bencher.dev/favicon.svg" width="24" height="24" alt="🐰" /> Bencher Report</a></h2><table><tr><td>Branch</td><td><a href="https://bencher.dev/perf/serpeo/branches/alexwine36-per-146-update-url-input?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=serpeo">alexwine36/per-146-update-url-input</a></td></tr><tr><td>Testbed</td><td><a href="https://bencher.dev/perf/serpeo/testbeds/ubuntu-latest?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=serpeo">ubuntu-latest</a></td></tr></table><details><summary>Click to view all benchmark results</summary><br /><table><thead><tr><th>Benchmark</th><th><a href="https://bencher.dev/perf/serpeo/measures/latency?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=serpeo">Latency</a></th><th>Benchmark Result<br />microseconds (µs)<br />(Result Δ%)</th><th>Upper Boundary<br />microseconds (µs)<br />(Limit %)</th></tr></thead><tbody><tr><td><a href="https://bencher.dev/perf/serpeo/benchmarks/analyze-async-analyze?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=serpeo">analyze/async_analyze</a></td><td>📈 <a href="https://bencher.dev/perf/serpeo?branches=347bab6a-69f4-4be9-9b5e-e675b1cdf411&heads=7b512e45-f7ed-446f-b8da-950e34707bb3&testbeds=8d51bf2b-6f63-47d3-bd8d-3828d0aba000&benchmarks=810fbf96-de28-4978-8824-14476e3d3965&measures=2eafc44d-5575-4cf3-a612-5127ab798ccc&start_time=1745175162000&end_time=1747767327000&report=5c2845fa-abe1-4bb4-87de-02c5ab9d6c26&upper_boundary=true">view plot</a><br />🚷 <a href="https://bencher.dev/perf/serpeo/thresholds/30c4f43c-785f-4905-a4d3-80cd9537f53d?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=serpeo">view threshold</a></td><td>121.81 µs<br /><details><summary>(-0.80%)</summary>Baseline: 122.79 µs</details></td><td>177.42 µs<br />(68.66%)</td></tr><tr><td><a href="https://bencher.dev/perf/serpeo/benchmarks/analyze-sync-analyze?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=serpeo">analyze/sync_analyze</a></td><td>📈 <a href="https://bencher.dev/perf/serpeo?branches=347bab6a-69f4-4be9-9b5e-e675b1cdf411&heads=7b512e45-f7ed-446f-b8da-950e34707bb3&testbeds=8d51bf2b-6f63-47d3-bd8d-3828d0aba000&benchmarks=f50accbf-1cc1-49b7-bf5e-f9e3485debd7&measures=2eafc44d-5575-4cf3-a612-5127ab798ccc&start_time=1745175162000&end_time=1747767327000&report=5c2845fa-abe1-4bb4-87de-02c5ab9d6c26&upper_boundary=true">view plot</a><br />🚷 <a href="https://bencher.dev/perf/serpeo/thresholds/30c4f43c-785f-4905-a4d3-80cd9537f53d?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=serpeo">view threshold</a></td><td>119.76 µs<br /><details><summary>(-0.44%)</summary>Baseline: 120.29 µs</details></td><td>174.00 µs<br />(68.83%)</td></tr></tbody></table></details><a href="https://bencher.dev/perf/serpeo/reports/5c2845fa-abe1-4bb4-87de-02c5ab9d6c26?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr%2Bcomments&utm_term=serpeo">🐰 View full continuous benchmarking report in Bencher</a><div id="bencher.dev/projects/serpeo/id/alexwine36-per-146-update-url-input/ubuntu-latest/rust_criterion"></div>
author_association: CONTRIBUTOR