Refactor FindAgents and SearchDocs tools to use ToolAccordion for improved UI/UX

- Replaced custom expandable sections with ToolAccordion component in both FindAgents and SearchDocs tools.
- Simplified state management by removing unnecessary useState and useReducedMotion hooks.
- Enhanced accessibility and readability of agent and document search results with clearer descriptions and structured layouts.
This commit is contained in:
abhi1992002
2026-02-03 13:37:31 +05:30
parent 7772c71a15
commit b06868f453
4 changed files with 265 additions and 221 deletions

View File

@@ -0,0 +1,97 @@
"use client";
import { CaretDownIcon } from "@phosphor-icons/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useId } from "react";
import { cn } from "@/lib/utils";
import { useToolAccordion } from "./useToolAccordion";
interface Props {
badgeText: string;
title: React.ReactNode;
description?: React.ReactNode;
children: React.ReactNode;
className?: string;
defaultExpanded?: boolean;
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
}
export function ToolAccordion({
badgeText,
title,
description,
children,
className,
defaultExpanded,
expanded,
onExpandedChange,
}: Props) {
const shouldReduceMotion = useReducedMotion();
const contentId = useId();
const { isExpanded, toggle } = useToolAccordion({
expanded,
defaultExpanded,
onExpandedChange,
});
return (
<div
className={cn(
"mt-2 rounded-2xl border bg-background px-3 py-2",
className,
)}
>
<button
type="button"
aria-expanded={isExpanded}
aria-controls={contentId}
onClick={toggle}
className="flex w-full items-center justify-between gap-3 py-1 text-left"
>
<div className="flex min-w-0 items-center gap-2">
<span className="rounded-full border bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{badgeText}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{title}
</p>
{description && (
<p className="truncate text-xs text-muted-foreground">
{description}
</p>
)}
</div>
</div>
<CaretDownIcon
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isExpanded && "rotate-180",
)}
weight="bold"
/>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
id={contentId}
initial={{ height: 0, opacity: 0, filter: "blur(10px)" }}
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
exit={{ height: 0, opacity: 0, filter: "blur(10px)" }}
transition={
shouldReduceMotion
? { duration: 0 }
: { type: "spring", bounce: 0.35, duration: 0.55 }
}
className="overflow-hidden"
style={{ willChange: "height, opacity, filter" }}
>
<div className="pb-2 pt-3">{children}</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { useState } from "react";
interface UseToolAccordionOptions {
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
}
interface UseToolAccordionResult {
isExpanded: boolean;
toggle: () => void;
}
export function useToolAccordion({
expanded,
defaultExpanded = false,
onExpandedChange,
}: UseToolAccordionOptions): UseToolAccordionResult {
const [uncontrolledExpanded, setUncontrolledExpanded] =
useState(defaultExpanded);
const isControlled = typeof expanded === "boolean";
const isExpanded = isControlled ? expanded : uncontrolledExpanded;
function toggle() {
const next = !isExpanded;
if (!isControlled) setUncontrolledExpanded(next);
onExpandedChange?.(next);
}
return { isExpanded, toggle };
}

View File

@@ -1,10 +1,7 @@
"use client";
import { ToolUIPart } from "ai";
import { CaretDownIcon } from "@phosphor-icons/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import Link from "next/link";
import { useState } from "react";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import {
getAgentHref,
@@ -13,7 +10,7 @@ import {
getSourceLabelFromToolType,
StateIcon,
} from "./helpers";
import { cn } from "@/lib/utils";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
export interface FindAgentsToolPart {
type: string;
@@ -30,8 +27,6 @@ interface Props {
export function FindAgentsTool({ part }: Props) {
const text = getAnimationText(part);
const output = getFindAgentsOutput(part);
const shouldReduceMotion = useReducedMotion();
const [isExpanded, setIsExpanded] = useState(false);
const query =
typeof part.input === "object" && part.input !== null
@@ -52,6 +47,9 @@ export function FindAgentsTool({ part }: Props) {
: source === "marketplace"
? "in marketplace"
: "";
const accordionDescription = `Found ${totalCount}${scopeText ? ` ${scopeText}` : ""}${
query ? ` for "${query}"` : ""
}`;
return (
<div className="py-2">
@@ -61,97 +59,55 @@ export function FindAgentsTool({ part }: Props) {
</div>
{hasAgents && (
<div className="mt-2 rounded-2xl border bg-background px-3 py-2">
<button
type="button"
aria-expanded={isExpanded}
onClick={() => setIsExpanded((v) => !v)}
className="flex w-full items-center justify-between gap-3 py-1 text-left"
>
<div className="flex min-w-0 items-center gap-2">
<span className="rounded-full border bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{sourceLabel}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
Agent results
</p>
<p className="truncate text-xs text-muted-foreground">
Found {totalCount} {scopeText}
{query ? ` for "${query}"` : ""}
</p>
</div>
</div>
<CaretDownIcon
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isExpanded && "rotate-180",
)}
weight="bold"
/>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0, filter: "blur(10px)" }}
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
exit={{ height: 0, opacity: 0, filter: "blur(10px)" }}
transition={
shouldReduceMotion
? { duration: 0 }
: { type: "spring", bounce: 0.35, duration: 0.55 }
}
className="overflow-hidden"
style={{ willChange: "height, opacity, filter" }}
>
<div className="grid gap-2 pb-2 pt-3 sm:grid-cols-2">
{output.agents.map((agent) => {
const href = getAgentHref(agent);
const agentSource =
agent.source === "library"
? "Library"
: agent.source === "marketplace"
? "Marketplace"
: null;
return (
<div
key={agent.id}
className="rounded-2xl border bg-background p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium text-foreground">
{agent.name}
</p>
{agentSource && (
<span className="shrink-0 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{agentSource}
</span>
)}
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{agent.description}
</p>
</div>
{href && (
<Link
href={href}
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
>
Open
</Link>
)}
</div>
<ToolAccordion
badgeText={sourceLabel}
title="Agent results"
description={accordionDescription}
>
<div className="grid gap-2 sm:grid-cols-2">
{output.agents.map((agent) => {
const href = getAgentHref(agent);
const agentSource =
agent.source === "library"
? "Library"
: agent.source === "marketplace"
? "Marketplace"
: null;
return (
<div
key={agent.id}
className="rounded-2xl border bg-background p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium text-foreground">
{agent.name}
</p>
{agentSource && (
<span className="shrink-0 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{agentSource}
</span>
)}
</div>
);
})}
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{agent.description}
</p>
</div>
{href && (
<Link
href={href}
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
>
Open
</Link>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
</ToolAccordion>
)}
</div>
);

View File

@@ -2,11 +2,9 @@
import type { ToolUIPart } from "ai";
import Link from "next/link";
import { useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CaretDownIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import { cn } from "@/lib/utils";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import {
getDocsToolOutput,
getDocsToolTitle,
@@ -36,9 +34,6 @@ function truncate(text: string, maxChars: number): string {
}
export function SearchDocsTool({ part }: Props) {
const shouldReduceMotion = useReducedMotion();
const [isExpanded, setIsExpanded] = useState(false);
const output = getDocsToolOutput(part);
const text = getAnimationText(part);
@@ -58,6 +53,15 @@ export function SearchDocsTool({ part }: Props) {
output.type === "no_results" ||
output.type === "error");
const accordionDescription =
hasExpandableContent && output
? output.type === "doc_search_results"
? `Found ${output.count} result${output.count === 1 ? "" : "s"} for "${output.query}"`
: output.type === "doc_page"
? output.path
: output.message
: null;
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -66,103 +70,35 @@ export function SearchDocsTool({ part }: Props) {
</div>
{hasExpandableContent && normalized && (
<div className="mt-2 rounded-2xl border bg-background px-3 py-2">
<button
type="button"
aria-expanded={isExpanded}
onClick={() => setIsExpanded((v) => !v)}
className="flex w-full items-center justify-between gap-3 py-1 text-left"
>
<div className="flex min-w-0 items-center gap-2">
<span className="rounded-full border bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{normalized.label}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{normalized.title}
</p>
<p className="truncate text-xs text-muted-foreground">
{output.type === "doc_search_results"
? `Found ${output.count} result${output.count === 1 ? "" : "s"} for "${output.query}"`
: output.type === "doc_page"
? output.path
: output.message}
</p>
</div>
</div>
<CaretDownIcon
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isExpanded && "rotate-180",
)}
weight="bold"
/>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0, filter: "blur(10px)" }}
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
exit={{ height: 0, opacity: 0, filter: "blur(10px)" }}
transition={
shouldReduceMotion
? { duration: 0 }
: { type: "spring", bounce: 0.35, duration: 0.55 }
}
className="overflow-hidden"
style={{ willChange: "height, opacity, filter" }}
>
{output.type === "doc_search_results" && (
<div className="grid gap-2 pb-2 pt-3">
{output.results.map((r) => {
const href = r.doc_url ?? toDocsUrl(r.path);
return (
<div
key={r.path}
className="rounded-2xl border bg-background p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{r.title}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{r.path}
{r.section ? `${r.section}` : ""}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{truncate(r.snippet, 240)}
</p>
</div>
<Link
href={href}
target="_blank"
rel="noreferrer"
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
>
Open
</Link>
</div>
</div>
);
})}
</div>
)}
{output.type === "doc_page" && (
<div className="pb-2 pt-3">
<ToolAccordion
badgeText={normalized.label}
title={normalized.title}
description={accordionDescription}
>
{output.type === "doc_search_results" && (
<div className="grid gap-2">
{output.results.map((r) => {
const href = r.doc_url ?? toDocsUrl(r.path);
return (
<div
key={r.path}
className="rounded-2xl border bg-background p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{output.title}
{r.title}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{output.path}
{r.path}
{r.section ? `${r.section}` : ""}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{truncate(r.snippet, 240)}
</p>
</div>
<Link
href={output.doc_url ?? toDocsUrl(output.path)}
href={href}
target="_blank"
rel="noreferrer"
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
@@ -170,39 +106,62 @@ export function SearchDocsTool({ part }: Props) {
Open
</Link>
</div>
<p className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
{truncate(output.content, 800)}
</p>
</div>
)}
);
})}
</div>
)}
{output.type === "no_results" && (
<div className="pb-2 pt-3">
<p className="text-sm text-foreground">{output.message}</p>
{output.suggestions && output.suggestions.length > 0 && (
<ul className="mt-2 list-disc space-y-1 pl-5 text-xs text-muted-foreground">
{output.suggestions.slice(0, 5).map((s) => (
<li key={s}>{s}</li>
))}
</ul>
)}
</div>
)}
{output.type === "doc_page" && (
<div>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{output.title}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{output.path}
</p>
</div>
<Link
href={output.doc_url ?? toDocsUrl(output.path)}
target="_blank"
rel="noreferrer"
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
>
Open
</Link>
</div>
<p className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
{truncate(output.content, 800)}
</p>
</div>
)}
{output.type === "error" && (
<div className="pb-2 pt-3">
<p className="text-sm text-foreground">{output.message}</p>
{output.error && (
<p className="mt-2 text-xs text-muted-foreground">
{output.error}
</p>
)}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{output.type === "no_results" && (
<div>
<p className="text-sm text-foreground">{output.message}</p>
{output.suggestions && output.suggestions.length > 0 && (
<ul className="mt-2 list-disc space-y-1 pl-5 text-xs text-muted-foreground">
{output.suggestions.slice(0, 5).map((s) => (
<li key={s}>{s}</li>
))}
</ul>
)}
</div>
)}
{output.type === "error" && (
<div>
<p className="text-sm text-foreground">{output.message}</p>
{output.error && (
<p className="mt-2 text-xs text-muted-foreground">
{output.error}
</p>
)}
</div>
)}
</ToolAccordion>
)}
</div>
);