-
-
+
+
+ setNewSubject(e.target.value)}
+ placeholder="Ticket subject"
+ className="w-full h-8 px-2.5 rounded-lg bg-[#0f1011] border border-[rgba(255,255,255,0.08)] text-sm text-[#f7f8f8] placeholder:text-[#8a8f98] outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]"
+ autoFocus
/>
-
-
-
-
-
-
diff --git a/web/src/app/tickets/[id]/page.tsx b/web/src/app/tickets/[id]/page.tsx
index cb45b7a..56b8e6e 100644
--- a/web/src/app/tickets/[id]/page.tsx
+++ b/web/src/app/tickets/[id]/page.tsx
@@ -1,39 +1,10 @@
"use client";
-import { useEffect, useState, useCallback } from "react";
-import { useParams } from "next/navigation";
+import { useState, useEffect, use } from "react";
+import { useRouter } from "next/navigation";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
-import {
- ArrowLeft,
- Plus,
- ArrowRightLeft,
- MessageSquare,
- Pencil,
- RefreshCw,
-} from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import {
- Card,
- CardHeader,
- CardTitle,
- CardContent,
-} from "@/components/ui/card";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
-} from "@/components/ui/dialog";
+import { ArrowLeftIcon, SendIcon, PaperclipIcon } from "lucide-react";
import {
getTicket,
getTicketTransactions,
@@ -41,335 +12,657 @@ import {
previewTicket,
updateTicket,
} from "@/lib/api";
-import type { Ticket, Transaction, Queue, PreviewResult, UpdateResult } from "@/lib/types";
+import type {
+ Ticket,
+ Transaction,
+ Queue,
+ PreviewResult,
+ UpdateResult,
+} from "@/lib/types";
+import { cn } from "@/lib/utils";
const STATUS_COLORS: Record
= {
- new: "bg-blue-500/10 text-blue-400 border-blue-500/30",
- open: "bg-sky-500/10 text-sky-400 border-sky-500/30",
- in_progress: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
- resolved: "bg-green-500/10 text-green-400 border-green-500/30",
- closed: "bg-neutral-500/10 text-neutral-400 border-neutral-500/30",
+ new: "#8a8f98",
+ open: "#7170ff",
+ in_progress: "#f59e0b",
+ resolved: "#22c55e",
+ closed: "#6b7280",
};
-const TX_ICONS: Record = {
- Create: Plus,
- StatusChange: ArrowRightLeft,
- Comment: MessageSquare,
- CustomField: Pencil,
+const STATUS_LABELS: Record = {
+ new: "New",
+ open: "Open",
+ in_progress: "In progress",
+ resolved: "Resolved",
+ closed: "Closed",
};
-const TX_COLORS: Record = {
- Create: "bg-green-500/10 text-green-400 border-green-500/30",
- StatusChange: "bg-blue-500/10 text-blue-400 border-blue-500/30",
- Comment: "bg-neutral-500/10 text-neutral-400 border-neutral-500/30",
- CustomField: "bg-purple-500/10 text-purple-400 border-purple-500/30",
-};
+const ALL_STATUSES = ["new", "open", "in_progress", "resolved", "closed"];
-function formatDate(iso: string) {
- return new Date(iso).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- });
+function getInitial(name: string): string {
+ if (!name) return "?";
+ return name.charAt(0).toUpperCase();
}
-export default function TicketDetailPage() {
- const { id } = useParams<{ id: string }>();
+function getInitialColor(name: string): string {
+ const colors = ["#5e6ad2", "#7170ff", "#828fff", "#f59e0b", "#22c55e"];
+ let hash = 0;
+ for (let i = 0; i < name.length; i++) {
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ return colors[Math.abs(hash) % colors.length];
+}
+
+function TransactionBubble({
+ tx,
+}: {
+ tx: Transaction;
+}) {
+ const isSystem =
+ tx.transaction_type === "status_change" ||
+ tx.transaction_type === "assignment" ||
+ tx.transaction_type === "create";
+ const isAgent = tx.transaction_type === "agent_reply" || tx.transaction_type === "internal_note";
+
+ if (isSystem) {
+ const timeAgo = formatDistanceToNow(new Date(tx.created_at), {
+ addSuffix: true,
+ });
+ let message = "";
+ if (tx.transaction_type === "create") {
+ message = `Ticket created`;
+ } else if (tx.transaction_type === "status_change") {
+ const oldLabel = tx.old_value ? STATUS_LABELS[tx.old_value] || tx.old_value : "?";
+ const newLabel = tx.new_value ? STATUS_LABELS[tx.new_value] || tx.new_value : "?";
+ message = `${getInitial(tx.creator_id)} changed status from ${oldLabel} to ${newLabel}`;
+ } else if (tx.transaction_type === "assignment") {
+ message = tx.new_value
+ ? `${getInitial(tx.creator_id)} assigned to ${tx.new_value}`
+ : `${getInitial(tx.creator_id)} unassigned`;
+ } else {
+ message = tx.transaction_type;
+ }
+
+ return (
+
+
+ {message} · {timeAgo}
+
+
+ );
+ }
+
+ const isInternal = tx.transaction_type === "internal_note";
+ const timeAgo = formatDistanceToNow(new Date(tx.created_at), {
+ addSuffix: true,
+ });
+
+ return (
+
+
+
+ {getInitial(tx.creator_id)}
+
+
+
+ {isInternal && (
+
+ Internal note
+
+ )}
+
+ {typeof tx.data === "object" && tx.data !== null && "body" in (tx.data as Record)
+ ? String((tx.data as Record).body)
+ : tx.transaction_type}
+
+
{timeAgo}
+
+
+ );
+}
+
+export default function TicketDetailPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = use(params);
+ const router = useRouter();
+
const [ticket, setTicket] = useState(null);
const [transactions, setTransactions] = useState([]);
- const [queues, setQueues] = useState([]);
+ const [queue, setQueue] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const [selectedStatus, setSelectedStatus] = useState("");
- const [previewing, setPreviewing] = useState(false);
- const [applying, setApplying] = useState(false);
- const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
- const [previewResult, setPreviewResult] = useState(null);
- const [previewError, setPreviewError] = useState(null);
- const [applyResult, setApplyResult] = useState(null);
- const [applyError, setApplyError] = useState(null);
- const fetchData = useCallback(async () => {
+ // Reply
+ const [replyText, setReplyText] = useState("");
+ const [replyMode, setReplyMode] = useState<"public" | "internal">("public");
+
+ // Status change
+ const [statusSelectOpen, setStatusSelectOpen] = useState(false);
+ const [pendingStatus, setPendingStatus] = useState(null);
+ const [preview, setPreview] = useState(null);
+ const [previewLoading, setPreviewLoading] = useState(false);
+ const [previewError, setPreviewError] = useState(null);
+ const [applyLoading, setApplyLoading] = useState(false);
+ const [scripResults, setScripResults] = useState(null);
+
+ const fetchData = async () => {
setLoading(true);
setError(null);
- const [tRes, txRes, qRes] = await Promise.all([
+
+ const [ticketRes, txRes, queuesRes] = await Promise.all([
getTicket(id),
getTicketTransactions(id),
getQueues(),
]);
- if (tRes.error) setError(tRes.error);
- else {
- setTicket(tRes.data);
- setSelectedStatus(tRes.data?.status ?? "");
+
+ if (ticketRes.error) {
+ setError(ticketRes.error);
+ } else {
+ setTicket(ticketRes.data);
}
- if (txRes.error) setError(txRes.error);
- else setTransactions(txRes.data ?? []);
- if (qRes.error && !error) setError(qRes.error);
- else setQueues(qRes.data ?? []);
+
+ if (txRes.error) {
+ setError((prev) => prev || txRes.error);
+ } else {
+ setTransactions(txRes.data ?? []);
+ }
+
+ if (queuesRes.data && ticketRes.data) {
+ const q = queuesRes.data.find((q) => q.id === ticketRes.data!.queue_id);
+ if (q) setQueue(q);
+ }
+
setLoading(false);
- }, [id]);
+ };
useEffect(() => {
fetchData();
- }, [fetchData]);
+ }, [id]);
- const handlePreview = async () => {
- if (!selectedStatus) return;
- setPreviewing(true);
+ const handleStatusSelect = async (newStatus: string) => {
+ setPendingStatus(newStatus);
+ setStatusSelectOpen(false);
+ setPreview(null);
setPreviewError(null);
- const { data, error } = await previewTicket(id, { status: selectedStatus });
- setPreviewing(false);
+ setScripResults(null);
+
+ if (newStatus === ticket?.status) return;
+
+ setPreviewLoading(true);
+ const { data, error } = await previewTicket(id, { status: newStatus });
+ setPreviewLoading(false);
+
if (error) {
setPreviewError(error);
+ setPendingStatus(null);
} else {
- setPreviewResult(data);
+ setPreview(data);
}
- setPreviewDialogOpen(true);
};
- const handleApply = async () => {
- if (!selectedStatus) return;
- setApplying(true);
- setApplyError(null);
- setApplyResult(null);
- const { data, error } = await updateTicket(id, { status: selectedStatus });
- setApplying(false);
+ const handleApplyStatus = async () => {
+ if (!pendingStatus) return;
+ setApplyLoading(true);
+ setPreviewError(null);
+
+ const { data, error } = await updateTicket(id, { status: pendingStatus });
+
+ setApplyLoading(false);
+
if (error) {
- setApplyError(error);
- } else {
- setApplyResult(data);
- await fetchData();
+ setPreviewError(error);
+ } else if (data) {
+ setTicket(data.ticket);
+ setScripResults(data.scrip_results);
+ setPreview(null);
+ setPendingStatus(null);
+ // Refresh transactions
+ const txRes = await getTicketTransactions(id);
+ if (txRes.data) setTransactions(txRes.data);
}
};
- const queueName = queues.find((q) => q.id === ticket?.queue_id)?.name ?? ticket?.queue_id ?? "Unknown";
+ const handleCancelStatus = () => {
+ setPendingStatus(null);
+ setPreview(null);
+ setPreviewError(null);
+ setScripResults(null);
+ };
if (loading) {
- return Loading ticket...
;
+ return (
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+ );
}
if (error && !ticket) {
return (
-
-
- {error}
-
-
- Back to tickets
-
+
);
}
if (!ticket) {
return (
-
- Ticket not found.{" "}
-
- Back to tickets
-
+
);
}
+ const currentStatusColor =
+ STATUS_COLORS[ticket.status] || STATUS_COLORS.new;
+ const currentStatusLabel =
+ STATUS_LABELS[ticket.status] || ticket.status;
+
return (
-
-
-
- All tickets
-
-
-
-
-
-
-
{ticket.subject}
-
- {queueName}
- Created {formatDate(ticket.created_at)}
- Updated {formatDate(ticket.updated_at)}
-
-
-
- {ticket.status.replace("_", " ")}
-
+
+ {/* Left panel — conversation */}
+
+ {/* Header */}
+
+
+
+
+
+
+ {ticket.subject}
+
+
+ {ticket.id.slice(0, 8)} · {queue?.name || ticket.queue_id}
+
-
- Owner: {ticket.owner_id ?? "Unassigned"}
-
-
-
+
-
-
- Change Status
-
-
- setSelectedStatus(!v || v === "_none" ? "" : v)}>
-
-
-
-
- {["new", "open", "in_progress", "resolved", "closed"].map((s) => (
-
- {s.replace("_", " ")}
-
- ))}
-
-
-
- {previewing ? (
- <>
-
- Previewing...
- >
- ) : (
- "Preview"
- )}
-
-
- {applying ? (
- <>
-
- Applying...
- >
- ) : (
- "Apply"
- )}
-
- {applyError && {applyError}}
- {applyResult && (
-
-
Status updated successfully.
- {applyResult.scrip_results.map((r) => (
-
- {r.scripId}: {r.message}
-
- ))}
+ {/* Conversation */}
+
+ {transactions.length === 0 && (
+
)}
-
-
+ {transactions.map((tx) => (
+
+ ))}
+
-
-
- Transaction Timeline
-
-
- {transactions.length === 0 ? (
- No transactions yet.
- ) : (
-
- {transactions.map((tx) => {
- const Icon = TX_ICONS[tx.transaction_type] ?? MessageSquare;
- return (
-
-
-
-
-
-
-
- {tx.transaction_type}
-
- {tx.field && (
-
- {tx.field}
- {tx.old_value && tx.new_value ? (
- <>
- {" "}
- {tx.old_value} →{" "}
- {tx.new_value}
- >
- ) : tx.new_value ? (
- <>
- {" "}
- set to {tx.new_value}
- >
- ) : null}
+ {/* Reply box */}
+
+ {/* Toggle tabs */}
+
+ setReplyMode("public")}
+ className={cn(
+ "px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
+ replyMode === "public"
+ ? "bg-[#5e6ad2] text-[#f7f8f8]"
+ : "text-[#8a8f98] hover:text-[#d0d6e0]"
+ )}
+ >
+ Reply
+
+ setReplyMode("internal")}
+ className={cn(
+ "px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
+ replyMode === "internal"
+ ? "bg-[#5e6ad2] text-[#f7f8f8]"
+ : "text-[#8a8f98] hover:text-[#d0d6e0]"
+ )}
+ >
+ Internal note
+
+
+
+
+
+
+
+ {/* Right panel — properties */}
+
+
+ {/* Status */}
+
+
+
+
setStatusSelectOpen(!statusSelectOpen)}
+ className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-[rgba(255,255,255,0.08)] bg-[#08090a] text-sm hover:border-[rgba(255,255,255,0.15)] transition-colors"
+ >
+
+
+ {currentStatusLabel}
+
+
+
+
+ {statusSelectOpen && (
+
+ {ALL_STATUSES.map((status) => {
+ const color = STATUS_COLORS[status];
+ const label = STATUS_LABELS[status];
+ const isCurrent = status === ticket.status;
+ return (
+ handleStatusSelect(status)}
+ disabled={isCurrent}
+ className={cn(
+ "w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors",
+ isCurrent
+ ? "bg-[rgba(255,255,255,0.03)] text-[#8a8f98] cursor-default"
+ : "text-[#d0d6e0] hover:bg-[rgba(255,255,255,0.05)]"
+ )}
+ >
+
+ {label}
+ {isCurrent && (
+
+ current
)}
-
-
- {formatDistanceToNow(new Date(tx.created_at), { addSuffix: true })}
-
+
+ );
+ })}
+
+ )}
+
+
+
+ {/* Status change preview */}
+ {preview && (
+
+
+ Preview: changing to{" "}
+
+ {STATUS_LABELS[pendingStatus || ""]}
+
+
+ {preview.prepared_scrips.length > 0 ? (
+
+ {preview.prepared_scrips.map((scrip) => (
+
+
+ {scrip.scripName}
-
- );
- })}
+ ))}
+
+ ) : (
+
+ No scrips will fire
+
+ )}
+
+
+ {applyLoading
+ ? "Applying..."
+ : `Apply — ${preview.prepared_scrips.length} scrip${preview.prepared_scrips.length !== 1 ? "s" : ""} will fire`}
+
+
+ Cancel
+
+
)}
-
-
- {ticket.custom_fields && ticket.custom_fields.length > 0 && (
-
-
- Custom Fields
-
-
-
- {ticket.custom_fields.map((cf) => (
-
-
- {cf.custom_field?.name ?? cf.custom_field_id}:
-
- {cf.value}
-
- ))}
+ {previewError && (
+
-
-
- )}
+ )}
-
);
}
diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx
new file mode 100644
index 0000000..6250f4b
--- /dev/null
+++ b/web/src/components/app-shell.tsx
@@ -0,0 +1,265 @@
+"use client";
+
+import { useState, useEffect, Suspense } from "react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import Link from "next/link";
+import {
+ LayoutGridIcon,
+ UserIcon,
+ InboxIcon,
+ ClockIcon,
+ SettingsIcon,
+} from "lucide-react";
+import { getTickets, getQueues } from "@/lib/api";
+import type { Queue } from "@/lib/types";
+import { CommandPalette } from "@/components/command-palette";
+import { cn } from "@/lib/utils";
+
+interface ViewCounts {
+ all: number;
+ my: number;
+ unassigned: number;
+ recent: number;
+}
+
+function SidebarNav() {
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const [counts, setCounts] = useState({
+ all: 0,
+ my: 0,
+ unassigned: 0,
+ recent: 0,
+ });
+ const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
+
+ useEffect(() => {
+ getTickets().then(({ data }) => {
+ if (data) {
+ const now = Date.now();
+ const week = 7 * 24 * 60 * 60 * 1000;
+ setCounts({
+ all: data.length,
+ my: data.filter((t) => t.owner_id).length,
+ unassigned: data.filter((t) => !t.owner_id).length,
+ recent: data.filter(
+ (t) => new Date(t.updated_at).getTime() > now - week
+ ).length,
+ });
+ }
+ });
+
+ getQueues().then(({ data }) => {
+ if (data) {
+ Promise.all(
+ data.map((q) =>
+ getTickets({ queue_id: q.id }).then(({ data: tickets }) => ({
+ ...q,
+ count: tickets?.length ?? 0,
+ }))
+ )
+ ).then(setQueues);
+ }
+ });
+ }, []);
+
+ const views = [
+ {
+ label: "All tickets",
+ href: "/",
+ param: null,
+ count: counts.all,
+ icon: LayoutGridIcon,
+ },
+ {
+ label: "My tickets",
+ href: "/?view=my",
+ param: "my",
+ count: counts.my,
+ icon: UserIcon,
+ },
+ {
+ label: "Unassigned",
+ href: "/?view=unassigned",
+ param: "unassigned",
+ count: counts.unassigned,
+ icon: InboxIcon,
+ },
+ {
+ label: "Recently updated",
+ href: "/?view=recent",
+ param: "recent",
+ count: counts.recent,
+ icon: ClockIcon,
+ },
+ ];
+
+ const currentView = searchParams.get("view");
+
+ return (
+ <>
+
+ {views.map((view) => {
+ const Icon = view.icon;
+ const active =
+ pathname === "/" &&
+ (view.param ? currentView === view.param : !currentView);
+ return (
+
+
+
+ {view.label}
+
+ {view.count > 0 && (
+
+ {view.count}
+
+ )}
+
+ );
+ })}
+
+
+ {queues.length > 0 && (
+
+
+ Queues
+
+ {queues.map((queue) => {
+ const active =
+ pathname === "/" && searchParams.get("queue") === queue.id;
+ return (
+
+
+
+ {queue.name}
+
+ {queue.count > 0 && (
+
+ {queue.count}
+
+ )}
+
+ );
+ })}
+
+ )}
+ >
+ );
+}
+
+function SidebarBottom() {
+ const pathname = usePathname();
+
+ return (
+
+ );
+}
+
+export function AppShell({ children }: { children: React.ReactNode }) {
+ const [commandOpen, setCommandOpen] = useState(false);
+
+ useEffect(() => {
+ const down = (e: KeyboardEvent) => {
+ if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || e.key === "/") {
+ if (
+ e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLTextAreaElement ||
+ (e.target instanceof HTMLElement && e.target.isContentEditable)
+ ) {
+ return;
+ }
+ e.preventDefault();
+ setCommandOpen((o) => !o);
+ }
+ };
+ document.addEventListener("keydown", down);
+ return () => document.removeEventListener("keydown", down);
+ }, []);
+
+ return (
+
+ {/* Sidebar */}
+
+ }
+ >
+
+
+
+
+ {/* Bottom */}
+
+
+
+ {/* Main */}
+ {children}
+
+ {/* Command Palette */}
+
+
+ );
+}
diff --git a/web/src/components/command-palette.tsx b/web/src/components/command-palette.tsx
new file mode 100644
index 0000000..b4be93b
--- /dev/null
+++ b/web/src/components/command-palette.tsx
@@ -0,0 +1,172 @@
+"use client";
+
+import { useState, useEffect, useRef, useCallback } from "react";
+import { useRouter } from "next/navigation";
+import {
+ SearchIcon,
+ PlusIcon,
+ LayoutGridIcon,
+ SettingsIcon,
+} from "lucide-react";
+
+interface CommandItem {
+ id: string;
+ label: string;
+ icon: React.ComponentType<{ className?: string }>;
+ action: () => void;
+ category?: string;
+}
+
+interface CommandPaletteProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
+ const router = useRouter();
+ const inputRef = useRef
(null);
+ const listRef = useRef(null);
+ const [query, setQuery] = useState("");
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ const commands: CommandItem[] = [
+ {
+ id: "new-ticket",
+ label: "New ticket",
+ icon: PlusIcon,
+ action: () => {
+ onOpenChange(false);
+ router.push("/?new=true");
+ },
+ category: "Actions",
+ },
+ {
+ id: "all-tickets",
+ label: "All tickets",
+ icon: LayoutGridIcon,
+ action: () => {
+ onOpenChange(false);
+ router.push("/");
+ },
+ category: "Navigate",
+ },
+ {
+ id: "admin",
+ label: "Go to admin",
+ icon: SettingsIcon,
+ action: () => {
+ onOpenChange(false);
+ router.push("/admin");
+ },
+ category: "Navigate",
+ },
+ ];
+
+ const filtered = commands.filter((cmd) =>
+ cmd.label.toLowerCase().includes(query.toLowerCase())
+ );
+
+ const grouped = filtered.reduce>((acc, cmd) => {
+ const cat = cmd.category || "Other";
+ if (!acc[cat]) acc[cat] = [];
+ acc[cat].push(cmd);
+ return acc;
+ }, {});
+
+ useEffect(() => {
+ if (open) {
+ setQuery("");
+ setSelectedIndex(0);
+ setTimeout(() => inputRef.current?.focus(), 50);
+ }
+ }, [open]);
+
+ useEffect(() => {
+ setSelectedIndex(0);
+ }, [query]);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setSelectedIndex((i) => Math.max(i - 1, 0));
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ if (filtered[selectedIndex]) {
+ filtered[selectedIndex].action();
+ }
+ } else if (e.key === "Escape") {
+ onOpenChange(false);
+ }
+ },
+ [filtered, selectedIndex, onOpenChange]
+ );
+
+ if (!open) return null;
+
+ return (
+
+
onOpenChange(false)}
+ />
+
+
+
+
+ setQuery(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="Type a command or search..."
+ className="w-full h-10 bg-transparent text-sm text-[#f7f8f8] placeholder:text-[#8a8f98] outline-none"
+ />
+
+
+ {filtered.length === 0 && (
+
+ No results found
+
+ )}
+ {Object.entries(grouped).map(([category, items]) => (
+
+
+ {category}
+
+ {items.map((item) => {
+ const idx = filtered.indexOf(item);
+ const isSelected = idx === selectedIndex;
+ const Icon = item.icon;
+ return (
+
setSelectedIndex(idx)}
+ >
+
+ {item.label}
+
+ );
+ })}
+
+ ))}
+
+
+ ↑↓ Navigate
+ ↵ Select
+ Esc Dismiss
+
+
+
+
+ );
+}