From f18f37dbd7fe36a0135482e2c83b3564712718cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 22:18:02 +0000 Subject: [PATCH 01/21] feat(chat-tools): add unified tool-response design system + global error/empty states - Add shared ToolCard, ToolError, ToolEmpty, ToolStatusPill, ToolCardSkeleton primitives - Handle output-error state in MessageParts (previously hung on loading skeleton forever) - Add present-tense running labels to getToolsInfo - Redesign GenericSuccess default success card on the new system Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012147h1wAGarCg9E3SsKkrp --- components/VercelChat/MessageParts.tsx | 10 +- components/VercelChat/ToolComponents.tsx | 30 +++- .../VercelChat/tools/GenericSuccess.tsx | 58 ++++--- .../VercelChat/tools/shared/ToolCard.tsx | 154 ++++++++++++++++++ .../tools/shared/ToolCardSkeleton.tsx | 65 ++++++++ .../VercelChat/tools/shared/ToolEmpty.tsx | 39 +++++ .../VercelChat/tools/shared/ToolError.tsx | 68 ++++++++ .../tools/shared/ToolStatusPill.tsx | 49 ++++++ .../VercelChat/tools/shared/toolCardTokens.ts | 73 +++++++++ lib/utils/getToolsInfo.ts | 28 +++- 10 files changed, 540 insertions(+), 34 deletions(-) create mode 100644 components/VercelChat/tools/shared/ToolCard.tsx create mode 100644 components/VercelChat/tools/shared/ToolCardSkeleton.tsx create mode 100644 components/VercelChat/tools/shared/ToolEmpty.tsx create mode 100644 components/VercelChat/tools/shared/ToolError.tsx create mode 100644 components/VercelChat/tools/shared/ToolStatusPill.tsx create mode 100644 components/VercelChat/tools/shared/toolCardTokens.ts diff --git a/components/VercelChat/MessageParts.tsx b/components/VercelChat/MessageParts.tsx index 1e522c846..d742f5901 100644 --- a/components/VercelChat/MessageParts.tsx +++ b/components/VercelChat/MessageParts.tsx @@ -10,7 +10,11 @@ import { Dispatch, SetStateAction } from "react"; import { cn } from "@/lib/utils"; import ViewingMessage from "./ViewingMessage"; import EditingMessage from "./EditingMessage"; -import { getToolCallComponent, getToolResultComponent } from "./ToolComponents"; +import { + getToolCallComponent, + getToolResultComponent, + getToolErrorComponent, +} from "./ToolComponents"; import MessageFileViewer from "./message-file-viewer"; import { EnhancedReasoning } from "@/components/reasoning/EnhancedReasoning"; import { Actions, Action } from "@/components/actions"; @@ -107,7 +111,9 @@ export function MessageParts({ message, mode, setMode }: MessagePartsProps) { if (isToolOrDynamicToolUIPart(part)) { const { state } = part as ToolUIPart; - if (state !== "output-available") { + if (state === "output-error") { + return getToolErrorComponent(part as ToolUIPart, () => reload()); + } else if (state !== "output-available") { return getToolCallComponent(part as ToolUIPart); } else { return getToolResultComponent(part as ToolUIPart); diff --git a/components/VercelChat/ToolComponents.tsx b/components/VercelChat/ToolComponents.tsx index 763a69408..df815c4dc 100644 --- a/components/VercelChat/ToolComponents.tsx +++ b/components/VercelChat/ToolComponents.tsx @@ -23,7 +23,6 @@ import UpdateArtistSocialsSuccess from "./tools/UpdateArtistSocialsSuccess"; import { UpdateArtistSocialsResult } from "./tools/UpdateArtistSocialsSuccess"; import { TxtFileResult } from "@/components/ui/TxtFileResult"; import { TxtFileGenerationResult } from "@/components/ui/TxtFileResult"; -import { Loader } from "lucide-react"; import GenericSuccess from "./tools/GenericSuccess"; import getToolInfo from "@/lib/utils/getToolsInfo"; import { isSearchProgressUpdate } from "@/lib/search/searchProgressUtils"; @@ -82,11 +81,32 @@ import GetChatsResult, { } from "./tools/chats/GetChatsResult"; import RunPageSkeleton from "@/components/TasksPage/Run/RunPageSkeleton"; import RunSandboxCommandResultWithPolling from "./tools/sandbox/RunSandboxCommandResultWithPolling"; +import ToolError from "./tools/shared/ToolError"; +import ToolStatusPill from "./tools/shared/ToolStatusPill"; type CallToolResult = { content: TextContent[]; }; +/** + * Unified error surface for any tool that resolves to `output-error`. + * Without this, failed tools fell through to the loading skeleton and + * appeared to hang forever. + */ +export function getToolErrorComponent( + part: ToolUIPart | DynamicToolUIPart, + onRetry?: () => void, +) { + const { toolCallId } = part; + const toolName = getToolOrDynamicToolName(part); + const errorText = (part as { errorText?: string }).errorText; + return ( +
+ +
+ ); +} + export function getToolCallComponent(part: ToolUIPart) { const { toolCallId } = part as ToolUIPart; const toolName = getToolOrDynamicToolName(part); @@ -210,12 +230,8 @@ export function getToolCallComponent(part: ToolUIPart) { // Default for other tools return ( -
- - Using {toolName} +
+
); } diff --git a/components/VercelChat/tools/GenericSuccess.tsx b/components/VercelChat/tools/GenericSuccess.tsx index 25f7eacf3..65e411165 100644 --- a/components/VercelChat/tools/GenericSuccess.tsx +++ b/components/VercelChat/tools/GenericSuccess.tsx @@ -1,5 +1,11 @@ -import { Icons } from "@/components/Icon/resolver"; +import { Check } from "lucide-react"; +import { ToolCard } from "./shared/ToolCard"; +/** + * Default success surface for tools without a bespoke result component. + * Kept intentionally compact — it should read as a quiet confirmation, + * not compete with rich result cards. + */ const GenericSuccess = ({ image, name, @@ -11,30 +17,36 @@ const GenericSuccess = ({ message: string; children?: React.ReactNode; }) => { + const prettyName = name + .replace(/^COMPOSIO_/, "") + .replace(/[_-]+/g, " ") + .trim() + .replace(/\b\w/g, (c) => c.toUpperCase()); return ( -
-
- {image ? ( - // eslint-disable-next-line @next/next/no-img-element - {name} - ) : ( - - )} -
- -
-

{name}

-

{message}

- {children} -
-
+ + {/* eslint-disable-next-line @next/next/no-img-element */} + {prettyName} +
+ ) : undefined + } + title={prettyName} + subtitle={message} + className="max-w-sm" + > + {children ?
{children}
: null} + ); }; diff --git a/components/VercelChat/tools/shared/ToolCard.tsx b/components/VercelChat/tools/shared/ToolCard.tsx new file mode 100644 index 000000000..d8300dea5 --- /dev/null +++ b/components/VercelChat/tools/shared/ToolCard.tsx @@ -0,0 +1,154 @@ +"use client"; + +import * as React from "react"; +import { motion } from "framer-motion"; +import { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { ToolTone, TOOL_TONES, toolCardMotion } from "./toolCardTokens"; + +/** + * ToolCard — the single, shared shell for every chat tool response. + * + * Goals: + * - One consistent design language across all tools (radius, border, shadow, + * spacing, header anatomy) so the chat surface feels intentional. + * - Built-in tonal accents (success / error / info / loading) via a tinted + * icon chip, so users parse the state of a response at a glance. + * - A subtle, tasteful entrance animation shared by every card. + * + * Compose with ToolCardBody / ToolCardRow, or pass `children` directly. + */ + +interface ToolCardProps { + /** Lucide icon rendered in the tinted header chip. */ + icon?: LucideIcon; + /** Custom node rendered in place of the icon chip (e.g. an artist avatar). */ + media?: React.ReactNode; + title: React.ReactNode; + subtitle?: React.ReactNode; + /** Tonal accent driving the chip + optional ring. Defaults to neutral. */ + tone?: ToolTone; + /** Trailing header slot — counts, badges, links, actions. */ + trailing?: React.ReactNode; + /** Render a soft accent ring around the whole card. */ + emphasized?: boolean; + /** Show the header chip in a pulsing "loading" treatment. */ + loading?: boolean; + className?: string; + /** Disable the entrance animation (e.g. nested cards). */ + noAnimation?: boolean; + children?: React.ReactNode; +} + +export function ToolCard({ + icon: Icon, + media, + title, + subtitle, + tone = "neutral", + trailing, + emphasized = false, + loading = false, + className, + noAnimation = false, + children, +}: ToolCardProps) { + const toneStyle = TOOL_TONES[tone]; + + const header = ( +
+ {media ? ( +
{media}
+ ) : Icon ? ( +
+ +
+ ) : null} + +
+
+

+ {title} +

+
+ {subtitle ? ( +

+ {subtitle} +

+ ) : null} +
+ + {trailing ? ( +
+ {trailing} +
+ ) : null} +
+ ); + + const body = ( +
+ {header} + {children ? ( +
{children}
+ ) : null} +
+ ); + + if (noAnimation) return body; + + return ( + + {body} + + ); +} + +/** Padded content region for the card body. */ +export function ToolCardBody({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}) { + return
{children}
; +} + +/** A single tappable/linkable row, styled consistently across tools. */ +export function ToolCardRow({ + className, + children, + ...props +}: React.HTMLAttributes) { + return ( +
+ {children} +
+ ); +} + +export default ToolCard; diff --git a/components/VercelChat/tools/shared/ToolCardSkeleton.tsx b/components/VercelChat/tools/shared/ToolCardSkeleton.tsx new file mode 100644 index 000000000..6cd673ffc --- /dev/null +++ b/components/VercelChat/tools/shared/ToolCardSkeleton.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { motion } from "framer-motion"; +import { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +/** + * Skeleton shell that mirrors ToolCard's anatomy so the loading → loaded + * transition has no layout jump. Pass `rows` to preview list-style results. + */ +export function ToolCardSkeleton({ + icon: Icon, + label, + rows = 3, + className, +}: { + icon?: LucideIcon; + /** Optional label shown next to the pulsing chip while loading. */ + label?: string; + rows?: number; + className?: string; +}) { + return ( + +
+
+ {Icon ? : null} +
+
+ {label ? ( + + {label} + + ) : ( +
+ )} +
+
+
+ {rows > 0 ? ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : null} + + ); +} + +export default ToolCardSkeleton; diff --git a/components/VercelChat/tools/shared/ToolEmpty.tsx b/components/VercelChat/tools/shared/ToolEmpty.tsx new file mode 100644 index 000000000..b9b1d07b8 --- /dev/null +++ b/components/VercelChat/tools/shared/ToolEmpty.tsx @@ -0,0 +1,39 @@ +import { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +/** + * Consistent, friendly empty state for tools that return zero results. + * Use *inside* a ToolCard body so the header still communicates what ran. + */ +export function ToolEmpty({ + icon: Icon, + title, + description, + className, +}: { + icon: LucideIcon; + title: string; + description?: string; + className?: string; +}) { + return ( +
+
+ +
+

{title}

+ {description ? ( +

+ {description} +

+ ) : null} +
+ ); +} + +export default ToolEmpty; diff --git a/components/VercelChat/tools/shared/ToolError.tsx b/components/VercelChat/tools/shared/ToolError.tsx new file mode 100644 index 000000000..bba63c748 --- /dev/null +++ b/components/VercelChat/tools/shared/ToolError.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { AlertTriangle, RefreshCcw } from "lucide-react"; +import { ToolCard, ToolCardBody } from "./ToolCard"; +import { cn } from "@/lib/utils"; + +/** + * Unified error state for any chat tool response. + * + * Previously a tool that resolved to `output-error` fell through to the + * loading skeleton and span forever. This gives every failure a clear, + * consistent, non-alarming surface with an optional retry affordance. + */ + +interface ToolErrorProps { + /** Human label for the tool, e.g. "Web search". */ + title?: string; + /** Detailed, user-facing error message. */ + message?: string; + onRetry?: () => void; + className?: string; +} + +function humanizeToolName(name?: string) { + if (!name) return "Action"; + return name + .replace(/^COMPOSIO_/, "") + .replace(/[_-]+/g, " ") + .toLowerCase() + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +export function ToolError({ + title, + message, + onRetry, + className, +}: ToolErrorProps) { + return ( + + +

+ {message?.trim() || + "Something went wrong while running this tool. You can try again."} +

+ {onRetry ? ( + + ) : null} +
+
+ ); +} + +export default ToolError; diff --git a/components/VercelChat/tools/shared/ToolStatusPill.tsx b/components/VercelChat/tools/shared/ToolStatusPill.tsx new file mode 100644 index 000000000..3736fcd42 --- /dev/null +++ b/components/VercelChat/tools/shared/ToolStatusPill.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { motion } from "framer-motion"; +import { LucideIcon, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +/** + * Compact, animated "tool is running" pill — the default loading affordance + * for tools without a bespoke skeleton. Replaces the old static + * "Using {toolName}" chip with a polished shimmer + spinner. + */ +export function ToolStatusPill({ + label, + icon: Icon = Loader2, + className, +}: { + label: string; + icon?: LucideIcon; + className?: string; +}) { + return ( + + + {label} + {/* shimmer sweep */} + + + ); +} + +export default ToolStatusPill; diff --git a/components/VercelChat/tools/shared/toolCardTokens.ts b/components/VercelChat/tools/shared/toolCardTokens.ts new file mode 100644 index 000000000..181c92738 --- /dev/null +++ b/components/VercelChat/tools/shared/toolCardTokens.ts @@ -0,0 +1,73 @@ +/** + * Shared visual tokens for chat tool-response cards. + * + * Centralizing the accent palette keeps every tool card on the same design + * language ("award-winning" comes from consistency, not novelty). Each tone + * maps to a tinted icon chip + matching ring/border so loading, success, + * empty and error states read instantly across the whole tool surface. + */ + +export type ToolTone = + | "neutral" + | "success" + | "error" + | "info" + | "accent" + | "warning"; + +interface ToneStyle { + /** Background tint for the icon chip. */ + chipBg: string; + /** Foreground color for the icon inside the chip. */ + chipText: string; + /** Optional subtle accent ring used on emphasized cards. */ + ring: string; + /** Solid-ish dot used in compact rows / pills. */ + dot: string; +} + +export const TOOL_TONES: Record = { + neutral: { + chipBg: "bg-muted", + chipText: "text-foreground/70", + ring: "ring-border", + dot: "bg-muted-foreground", + }, + success: { + chipBg: "bg-emerald-500/10", + chipText: "text-emerald-600 dark:text-emerald-400", + ring: "ring-emerald-500/20", + dot: "bg-emerald-500", + }, + error: { + chipBg: "bg-destructive/10", + chipText: "text-destructive", + ring: "ring-destructive/20", + dot: "bg-destructive", + }, + info: { + chipBg: "bg-blue-500/10", + chipText: "text-blue-600 dark:text-blue-400", + ring: "ring-blue-500/20", + dot: "bg-blue-500", + }, + accent: { + chipBg: "bg-violet-500/10", + chipText: "text-violet-600 dark:text-violet-400", + ring: "ring-violet-500/20", + dot: "bg-violet-500", + }, + warning: { + chipBg: "bg-amber-500/10", + chipText: "text-amber-600 dark:text-amber-400", + ring: "ring-amber-500/20", + dot: "bg-amber-500", + }, +}; + +/** Shared entrance motion for tool cards (fade + gentle rise). */ +export const toolCardMotion = { + initial: { opacity: 0, y: 6 }, + animate: { opacity: 1, y: 0 }, + transition: { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const }, +}; diff --git a/lib/utils/getToolsInfo.ts b/lib/utils/getToolsInfo.ts index 4b94cc9f7..3bc17ebe0 100644 --- a/lib/utils/getToolsInfo.ts +++ b/lib/utils/getToolsInfo.ts @@ -1,8 +1,24 @@ -function getToolInfo(toolName: string): { message: string } { +interface ToolInfo { + /** Past-tense summary shown on the success card. */ + message: string; + /** Present-tense label shown while the tool is running. */ + runningLabel: string; +} + +function humanize(toolName: string): string { + return toolName + .replace(/^COMPOSIO_/, "") + .replace(/[_-]+/g, " ") + .trim() + .toLowerCase(); +} + +function getToolInfo(toolName: string): ToolInfo { // Spotify related tools if (toolName.includes("spotify")) { return { message: "Music data retrieved", + runningLabel: "Pulling Spotify data", }; } // Artist data tools @@ -12,42 +28,50 @@ function getToolInfo(toolName: string): { message: string } { ) { return { message: "Artist data processed", + runningLabel: "Working with artist data", }; } // Contact team else if (toolName === "contact_team") { return { message: "Team contacted", + runningLabel: "Contacting the team", }; } // Search Web else if (toolName === "search_web") { return { message: "Information retrieved", + runningLabel: "Searching the web", }; } // Connector tools else if (toolName === "COMPOSIO_MANAGE_CONNECTIONS") { return { message: "Connection managed", + runningLabel: "Managing connection", }; } else if (toolName === "COMPOSIO_SEARCH_TOOLS") { return { message: "Tools discovered", + runningLabel: "Discovering tools", }; } else if (toolName === "COMPOSIO_GET_TOOL_SCHEMAS") { return { message: "Tool details retrieved", + runningLabel: "Reading tool details", }; } else if (toolName === "COMPOSIO_MULTI_EXECUTE_TOOL") { return { message: "Action executed", + runningLabel: "Executing action", }; } - // Default for any other tool + // Default for any other tool — derive a readable label from the tool name. else { return { message: "Data processed", + runningLabel: `Running ${humanize(toolName)}`, }; } } From 1b55abea7e7a9c6bb92b68834623d6bcd3be3a06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 22:25:59 +0000 Subject: [PATCH 02/21] feat(chat-tools): migrate tool-response cards to unified design system Redesign Spotify, Tasks, Catalog, Chats, Files, Artist, Search, Image, Video, Composio, Pulse, YouTube tool responses onto the shared ToolCard system with consistent loading/success/empty/error states, mirrored skeletons, and dark-mode-safe tokens. Add tool-ui-audit docs (user journeys + spreadsheet). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012147h1wAGarCg9E3SsKkrp --- .../VercelChat/tools/ArtistHeroSection.tsx | 97 +++++------ components/VercelChat/tools/ArtistSocial.tsx | 67 +++++++- .../tools/ArtistSocialDisplayText.tsx | 46 +++-- .../VercelChat/tools/CreateArtistToolCall.tsx | 23 ++- .../tools/CreateArtistToolResult.tsx | 16 +- .../tools/GetArtistSocialsResult.tsx | 71 +++++--- .../tools/GetArtistSocialsSkeleton.tsx | 45 +++-- .../tools/GetSpotifyAlbumWithTracksResult.tsx | 140 ++++++++------- .../GetSpotifyAlbumWithTracksSkeleton.tsx | 114 +++++------- .../tools/GetSpotifyArtistAlbumsResult.tsx | 80 +++++---- .../tools/GetSpotifyArtistAlbumsSkeleton.tsx | 49 +++--- .../tools/GetSpotifySearchToolResult.tsx | 101 +++++++---- .../VercelChat/tools/KnowledgeBaseSection.tsx | 31 ++-- .../tools/SearchWeb/SearchApiResult.tsx | 68 +++++--- .../tools/SearchWeb/SearchQueryPill.tsx | 20 ++- .../tools/SearchWeb/SearchResultItem.tsx | 47 ++++- .../tools/SearchWeb/SearchWebProgress.tsx | 111 +++++++----- .../tools/SearchWeb/SearchWebSkeleton.tsx | 43 ++++- .../tools/SpotifyAlbumWithTracksHero.tsx | 27 +-- .../tools/SpotifyAlbumWithTracksMeta.tsx | 93 +++++----- .../tools/SpotifyArtistTopTracksResult.tsx | 61 ++++--- .../tools/SpotifyArtistTopTracksSkeleton.tsx | 56 +++--- .../VercelChat/tools/SpotifyContentCard.tsx | 61 +++++-- .../tools/SpotifyDeepResearchResult.tsx | 67 +++++--- .../tools/SpotifyDeepResearchSkeleton.tsx | 27 ++- .../tools/UpdateArtistInfoSuccess.tsx | 84 +++++---- .../tools/UpdateArtistSocialsSuccess.tsx | 122 ++++++++----- .../tools/catalog/CatalogCsvUploadButton.tsx | 20 +-- .../tools/catalog/CatalogSongRow.tsx | 80 +++++++-- .../tools/catalog/CatalogSongsResult.tsx | 8 +- .../tools/catalog/CatalogSongsSkeleton.tsx | 12 +- .../tools/catalog/HideMissingItemsToggle.tsx | 19 +- .../tools/catalog/InsertCatalogSongsList.tsx | 59 +++---- .../catalog/InsertCatalogSongsStatus.tsx | 57 ++++-- .../catalog/InsertCatalogSongsSummary.tsx | 58 ++++--- .../VercelChat/tools/chats/GetChatsResult.tsx | 69 ++++---- .../tools/chats/GetChatsSkeleton.tsx | 27 +-- .../tools/composio/ComposioConnectPrompt.tsx | 84 +++++---- .../tools/composio/ComposioConnectedState.tsx | 30 ++-- .../tools/files/UpdateFileResult.tsx | 75 +++++--- .../VercelChat/tools/image/ImageResult.tsx | 31 ++-- .../VercelChat/tools/image/ImageSkeleton.tsx | 39 ++++- .../tools/pulse/PulseToolResult.tsx | 78 ++++----- .../tools/pulse/PulseToolSkeleton.tsx | 32 +++- .../RunSandboxCommandResultWithPolling.tsx | 54 +++++- .../tools/sora2/Sora2VideoResult.tsx | 84 +++++---- .../tools/sora2/Sora2VideoSkeleton.tsx | 43 ++++- .../tools/tasks/CreateTaskSuccess.tsx | 44 ++--- .../tools/tasks/DeleteTaskSkeleton.tsx | 56 +----- .../tools/tasks/DeleteTaskSuccess.tsx | 68 +++----- .../tools/tasks/GetTasksSuccess.tsx | 68 ++++---- .../tools/tasks/TaskArtistImage.tsx | 28 ++- .../VercelChat/tools/tasks/TaskCard.tsx | 109 +++++++----- .../VercelChat/tools/tasks/TaskError.tsx | 26 ++- .../tools/tasks/UpdateTaskSuccess.tsx | 44 ++--- .../tools/youtube/YouTubeRevenueDaily.tsx | 45 +++-- .../tools/youtube/YouTubeRevenueError.tsx | 29 +--- .../tools/youtube/YouTubeRevenueResult.tsx | 110 ++++++------ .../tools/youtube/YouTubeRevenueSkeleton.tsx | 123 ++++++------- .../tools/youtube/YouTubeRevenueStats.tsx | 73 ++++---- components/shared/TasksSkeleton.tsx | 66 +------ components/ui/TxtFileResult.tsx | 100 ++++++----- docs/tool-ui-audit/README.md | 23 +++ docs/tool-ui-audit/tool-ui-audit.csv | 53 ++++++ docs/tool-ui-audit/user-journeys.md | 162 ++++++++++++++++++ 65 files changed, 2329 insertions(+), 1624 deletions(-) create mode 100644 docs/tool-ui-audit/README.md create mode 100644 docs/tool-ui-audit/tool-ui-audit.csv create mode 100644 docs/tool-ui-audit/user-journeys.md diff --git a/components/VercelChat/tools/ArtistHeroSection.tsx b/components/VercelChat/tools/ArtistHeroSection.tsx index 9f804a592..31cad8e7f 100644 --- a/components/VercelChat/tools/ArtistHeroSection.tsx +++ b/components/VercelChat/tools/ArtistHeroSection.tsx @@ -1,8 +1,12 @@ import React from "react"; -import { CheckCircle, Calendar } from "lucide-react"; +import { CheckCircle2, Calendar, Tag } from "lucide-react"; import { formatDate } from "date-fns"; import { ArtistProfile } from "@/lib/supabase/artist/updateArtistProfile"; +/** + * Compact, token-safe artist identity header used inside success tool cards. + * Shows the artist avatar, name, a success confirmation, label and last-updated. + */ const ArtistHeroSection = ({ artistProfile, message, @@ -11,63 +15,46 @@ const ArtistHeroSection = ({ message?: string; }) => { return ( -
- {artistProfile.image && ( -
- )} - -
- -
-
- {artistProfile.image && ( -
- {/* eslint-disable */} - {artistProfile.name -
- )} - -
-

- {artistProfile.name} -

+
+ {artistProfile?.image ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {artistProfile?.name +
+ ) : null} -
- - - {message || "Profile Updated Successfully!"} - -
+
+

+ {artistProfile?.name || "Artist"} +

- {artistProfile.label && ( -
- Label:{" "} - {artistProfile.label} -
- )} +
+ + + {message || "Profile updated successfully"} + +
- {artistProfile.updated_at && ( -
- - - Updated{" "} - {formatDate( - new Date(artistProfile.updated_at), - "MMM d, yyyy, h:mm a" - )} - -
- )} -
+
+ {artistProfile?.label ? ( + + + {artistProfile.label} + + ) : null} + {artistProfile?.updated_at ? ( + + + {formatDate( + new Date(artistProfile.updated_at), + "MMM d, yyyy, h:mm a", + )} + + ) : null}
diff --git a/components/VercelChat/tools/ArtistSocial.tsx b/components/VercelChat/tools/ArtistSocial.tsx index ea1fd75ab..262444162 100644 --- a/components/VercelChat/tools/ArtistSocial.tsx +++ b/components/VercelChat/tools/ArtistSocial.tsx @@ -1,14 +1,52 @@ import { Social as SocialType } from "@/types/Social"; import Link from "next/link"; +import { + Instagram, + Youtube, + Music2, + Twitter, + Facebook, + Globe, + ExternalLink, + type LucideIcon, +} from "lucide-react"; import ArtistSocialDisplayText from "./ArtistSocialDisplayText"; import getSocialPlatformByLink from "@/lib/getSocialPlatformByLink"; import getPlatformDisplayName from "@/lib/socials/getPlatformDisplayName"; +import { cn } from "@/lib/utils"; + +const PLATFORM_ICONS: Record = { + INSTAGRAM: Instagram, + YOUTUBE: Youtube, + SPOTIFY: Music2, + TIKTOK: Music2, + TWITTER: Twitter, + FACEBOOK: Facebook, + THREADS: Twitter, + APPPLE: Music2, +}; + +/** Compact follower-count formatter (1.2K / 3.4M). */ +function formatCount(value: number): string { + if (value >= 1_000_000) + return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}M`; + if (value >= 1_000) + return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}K`; + return `${value}`; +} export const ArtistSocial = ({ social }: { social: SocialType }) => { const platformType = getSocialPlatformByLink(social.profile_url); - const platform = platformType !== "NONE" - ? getPlatformDisplayName(platformType) - : social.profile_url.split("/")[0].split(".")[0]; + const platform = + platformType !== "NONE" + ? getPlatformDisplayName(platformType) + : social.profile_url.split("/")[0].split(".")[0]; + + const Icon = PLATFORM_ICONS[platformType] ?? Globe; + const followers = + typeof social.follower_count === "number" && social.follower_count > 0 + ? formatCount(social.follower_count) + : null; return ( { href={`https://${social.profile_url}`} target="_blank" rel="noopener noreferrer" - className="flex flex-col items-start p-4 border rounded-xl transition-all hover:shadow-md hover:scale-[1.02] bg-card hover:bg-accent" + className={cn( + "group/social flex flex-col gap-2 rounded-xl border border-border bg-card p-3", + "transition-all hover:-translate-y-0.5 hover:border-border hover:bg-muted/60 hover:shadow-md", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + )} > - {platform} +
+ + + + + {platform} + + +
+ + + {followers ? ( + + {followers} followers + + ) : null} ); }; diff --git a/components/VercelChat/tools/ArtistSocialDisplayText.tsx b/components/VercelChat/tools/ArtistSocialDisplayText.tsx index 067be8b78..3b7cf4447 100644 --- a/components/VercelChat/tools/ArtistSocialDisplayText.tsx +++ b/components/VercelChat/tools/ArtistSocialDisplayText.tsx @@ -9,37 +9,31 @@ const ArtistSocialDisplayText = ({ social }: { social: SocialType }) => { // For Spotify, we don't have a display-friendly username (only the ID), so skip username display const hasUsername = Boolean( - social.username && - social.username.length > 0 && - !isYoutube && - !isSpotify + social.username && social.username.length > 0 && !isYoutube && !isSpotify, ); - const username = hasUsername && social.username - ? (social.username.startsWith("@") ? social.username : `@${social.username}`) + const username = + hasUsername && social.username + ? social.username.startsWith("@") + ? social.username + : `@${social.username}` + : ""; + + const youtubeChannelName = isYoutube + ? getYoutubeChannelNameFromURL(social.profile_url) : ""; - const youtubeChannelName = isYoutube ? getYoutubeChannelNameFromURL(social.profile_url) : ""; + const display = hasUsername + ? username + : isYoutube && youtubeChannelName + ? youtubeChannelName + : social.profile_url; - if (hasUsername) { - return ( - - {username} - - ); - } else if (isYoutube && youtubeChannelName) { - return ( - - {youtubeChannelName} - - ); - } else { - return ( - - {social.profile_url} - - ); - } + return ( + + {display} + + ); }; export default ArtistSocialDisplayText; diff --git a/components/VercelChat/tools/CreateArtistToolCall.tsx b/components/VercelChat/tools/CreateArtistToolCall.tsx index fd20c9eaa..17c443eb3 100644 --- a/components/VercelChat/tools/CreateArtistToolCall.tsx +++ b/components/VercelChat/tools/CreateArtistToolCall.tsx @@ -1,22 +1,19 @@ import React from "react"; +import { Sparkles } from "lucide-react"; +import ToolCardSkeleton from "./shared/ToolCardSkeleton"; /** - * Component that displays when the create_new_artist tool is being called - * Shows a loading skeleton for the artist being created + * Component that displays when the create_new_artist tool is being called. + * Mirrors the resolved CreateArtist success card so there's no layout jump. */ export function CreateArtistToolCall() { return ( -
- {/* Profile picture skeleton */} -
- -
- {/* Name skeleton */} -
- {/* Status text */} -
Creating new artist...
-
-
+ ); } diff --git a/components/VercelChat/tools/CreateArtistToolResult.tsx b/components/VercelChat/tools/CreateArtistToolResult.tsx index cd891790d..82ca8b920 100644 --- a/components/VercelChat/tools/CreateArtistToolResult.tsx +++ b/components/VercelChat/tools/CreateArtistToolResult.tsx @@ -2,6 +2,7 @@ import React, { useEffect } from "react"; import { CreateArtistResult } from "@/types/createArtistResult"; import useCreateArtistTool from "@/hooks/useCreateArtistTool"; import GenericSuccess from "./GenericSuccess"; +import ToolError from "./shared/ToolError"; import { useArtistProvider } from "@/providers/ArtistProvider"; /** @@ -28,17 +29,10 @@ export function CreateArtistToolResult({ // If there's an error or no artist data, show error state if (!result.artist) { return ( -
-
- ! -
-
-

Artist Creation Failed

-

- {result.error || "Unknown error"} -

-
-
+ ); } diff --git a/components/VercelChat/tools/GetArtistSocialsResult.tsx b/components/VercelChat/tools/GetArtistSocialsResult.tsx index 1333071c8..8db5f58dc 100644 --- a/components/VercelChat/tools/GetArtistSocialsResult.tsx +++ b/components/VercelChat/tools/GetArtistSocialsResult.tsx @@ -1,7 +1,10 @@ import { SocialsResponse } from "@/types/Social"; -import { Music } from "lucide-react"; +import { Share2, Users } from "lucide-react"; import { ReactNode } from "react"; import { ArtistSocial } from "./ArtistSocial"; +import { ToolCard, ToolCardBody } from "./shared/ToolCard"; +import ToolEmpty from "./shared/ToolEmpty"; +import ToolError from "./shared/ToolError"; export default function GetArtistSocialsResult({ result, @@ -16,42 +19,56 @@ export default function GetArtistSocialsResult({ }) { if (result.status !== "success") { return ( -
- {errorText ?? "Artist socials failed"} -
+ ); } - const hasSocials = result.socials.length > 0; - const socials = hasSocials ? result.socials : []; + const socials = result.socials ?? []; + const hasSocials = socials.length > 0; return ( -
-
- {icon ?? } -

- {title ?? "Artist Socials Found"} -

-
- - {hasSocials && ( -
-
-

- Artist Socials -

- - {socials.length} {socials.length === 1 ? "platform" : "platforms"} - + + {icon}
- -
+ ) : undefined + } + tone="accent" + title={title ?? "Artist socials"} + subtitle={ + hasSocials + ? `${socials.length} ${socials.length === 1 ? "platform" : "platforms"} connected` + : "No platforms connected" + } + trailing={ + hasSocials ? ( + + {socials.length} + + ) : undefined + } + > + {hasSocials ? ( + +
{socials.map((social) => ( ))}
-
+ + ) : ( + )} -
+ ); } diff --git a/components/VercelChat/tools/GetArtistSocialsSkeleton.tsx b/components/VercelChat/tools/GetArtistSocialsSkeleton.tsx index b8de2a6b5..cad8281d9 100644 --- a/components/VercelChat/tools/GetArtistSocialsSkeleton.tsx +++ b/components/VercelChat/tools/GetArtistSocialsSkeleton.tsx @@ -1,31 +1,46 @@ -import { Loader } from "lucide-react"; +"use client"; + +import { motion } from "framer-motion"; +import { Users } from "lucide-react"; const GetArtistSocialsSkeleton = ({ title }: { title?: string }) => { return ( -
-
- -

- {title ?? "Getting Artist Socials..."} -

+ +
+
+ +
+
+ + {title ?? "Getting artist socials…"} + +
+
-
-

Artist Socials

-
+
+
{[...Array(4)].map((_, i) => (
-
-
-
+
+
+
+
+
+
))}
-
+ ); }; diff --git a/components/VercelChat/tools/GetSpotifyAlbumWithTracksResult.tsx b/components/VercelChat/tools/GetSpotifyAlbumWithTracksResult.tsx index fe1b930e1..bbee6e6ec 100644 --- a/components/VercelChat/tools/GetSpotifyAlbumWithTracksResult.tsx +++ b/components/VercelChat/tools/GetSpotifyAlbumWithTracksResult.tsx @@ -1,10 +1,15 @@ +"use client"; + import React from "react"; -import { Badge } from "@/components/ui/badge"; -import { Play, ExternalLink } from "lucide-react"; +import { motion } from "framer-motion"; +import { Play, ExternalLink, Disc3, ListMusic } from "lucide-react"; import { SpotifyAlbum } from "@/types/spotify"; import { formatDuration } from "@/lib/spotify/formatDuration"; import Link from "next/link"; import SpotifyAlbumWithTracksHero from "./SpotifyAlbumWithTracksHero"; +import { toolCardMotion } from "./shared/toolCardTokens"; +import { ToolCard, ToolCardBody } from "./shared/ToolCard"; +import ToolEmpty from "./shared/ToolEmpty"; interface GetSpotifyAlbumWithTracksResultProps { result: SpotifyAlbum; @@ -13,79 +18,94 @@ interface GetSpotifyAlbumWithTracksResultProps { const GetSpotifyAlbumWithTracksResult: React.FC< GetSpotifyAlbumWithTracksResultProps > = ({ result }) => { - const totalDuration = result.tracks.items.reduce( + const tracks = result.tracks?.items ?? []; + + if (tracks.length === 0) { + return ( + + + + ); + } + + const totalDuration = tracks.reduce( (acc, track) => acc + track.duration_ms, - 0 + 0, ); return ( -
+ {/* Hero Section */} - + {/* Track Listing */} -
-
-
- {result.tracks.items.map((track) => ( - -
- {/* Track Number */} -
- - {track.track_number} - - -
+
+
+ {tracks.map((track) => ( + +
+ {/* Track Number / Play */} +
+ + {track.track_number} + + +
- {/* Track Info */} -
-
- - {track.name} + {/* Track Info */} +
+
+ + {track.name} + + {track.explicit && ( + + E - {track.explicit && ( - - E - - )} -
-
- {track.artists.map((artist) => artist.name).join(", ")} -
+ )}
- - {/* Track Duration */} -
- {formatDuration(track.duration_ms)} +
+ {track.artists.map((artist) => artist.name).join(", ")}
+
- {/* Track Actions - Hidden on mobile */} -
- {track.external_urls?.spotify && ( -

- -

- )} -
+ {/* Duration */} +
+ {formatDuration(track.duration_ms)}
- - ))} -
+ + {/* External link affordance */} + {track.external_urls?.spotify && ( +
+ +
+ )} +
+ + ))}
-
+ ); }; diff --git a/components/VercelChat/tools/GetSpotifyAlbumWithTracksSkeleton.tsx b/components/VercelChat/tools/GetSpotifyAlbumWithTracksSkeleton.tsx index 315074f42..883ef6f23 100644 --- a/components/VercelChat/tools/GetSpotifyAlbumWithTracksSkeleton.tsx +++ b/components/VercelChat/tools/GetSpotifyAlbumWithTracksSkeleton.tsx @@ -1,85 +1,57 @@ -const GetSpotifyAlbumWithTracksSkeleton = () => { - return ( -
- {/* Hero Section Skeleton */} -
- {/* Background placeholder */} -
-
-
- - {/* Content Overlay */} -
-
- {/* Album Cover Skeleton - Hidden on mobile */} -
-
-
+"use client"; - {/* Album Info Skeleton */} -
- {/* Badge skeleton */} -
-
-
- - {/* Title skeleton */} -
- - {/* Artist skeleton */} -
- - {/* Meta info skeleton */} -
-
-
-
-
+import { motion } from "framer-motion"; +import { toolCardMotion } from "./shared/toolCardTokens"; - {/* Button skeleton */} -
- - {/* Tags skeleton */} -
-
-
-
+const GetSpotifyAlbumWithTracksSkeleton = () => { + return ( + + {/* Hero Section Skeleton — mirrors the resolved immersive hero */} +
+
+ {/* Album Cover */} +
+ + {/* Album Info */} +
+
+
+
+
+
+
+
+
{/* Track Listing Skeleton */} -
-
-
- {/* Generate 8-12 track skeletons */} - {Array.from({ length: 5 }).map((_, index) => ( -
- {/* Track Number */} -
- - {/* Track Info */} -
-
-
-
- - {/* Duration */} -
- - {/* Action button skeleton - Hidden on mobile */} -
+
+
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
- ))} -
+
+
+ ))}
-
+ ); }; -export default GetSpotifyAlbumWithTracksSkeleton; \ No newline at end of file +export default GetSpotifyAlbumWithTracksSkeleton; diff --git a/components/VercelChat/tools/GetSpotifyArtistAlbumsResult.tsx b/components/VercelChat/tools/GetSpotifyArtistAlbumsResult.tsx index a2ab9525c..9a4ac40e3 100644 --- a/components/VercelChat/tools/GetSpotifyArtistAlbumsResult.tsx +++ b/components/VercelChat/tools/GetSpotifyArtistAlbumsResult.tsx @@ -1,51 +1,65 @@ import React from "react"; +import { Disc3 } from "lucide-react"; import { SpotifyArtistAlbumsResultUIType } from "@/types/spotify"; import SpotifyContentCard from "./SpotifyContentCard"; -import Image from "next/image"; +import { ToolCard, ToolCardBody } from "./shared/ToolCard"; +import ToolEmpty from "./shared/ToolEmpty"; const GetSpotifyArtistAlbumsResult: React.FC<{ result: SpotifyArtistAlbumsResultUIType; }> = ({ result }) => { if (!result.items || result.items.length === 0) { return ( -
- No albums found for this artist. -
+ + + ); } + const showingMore = result.total > result.items.length; + return ( -
-
- Spotify -
Artist Albums
-
-
- {result.items.map((album) => { - const releaseYear = album.release_date ? new Date(album.release_date).getFullYear() : null; - album = {...album, artists: releaseYear ? [{ ...album.artists[0], name: releaseYear.toString() }] : album.artists}; + + +
+ {result.items.map((album) => { + const releaseYear = album.release_date + ? new Date(album.release_date).getFullYear() + : null; + // Surface the release year as the card subtitle (in place of artist). + const displayAlbum = { + ...album, + artists: releaseYear + ? [{ ...album.artists[0], name: releaseYear.toString() }] + : album.artists, + }; - return ( - - ); - })} -
- {result.total > result.items.length && ( -
- Showing {result.items.length} of {result.total} albums + return ( + + ); + })}
- )} -
+ + ); }; -export default GetSpotifyArtistAlbumsResult; \ No newline at end of file +export default GetSpotifyArtistAlbumsResult; diff --git a/components/VercelChat/tools/GetSpotifyArtistAlbumsSkeleton.tsx b/components/VercelChat/tools/GetSpotifyArtistAlbumsSkeleton.tsx index ebf4e5cb6..202c42566 100644 --- a/components/VercelChat/tools/GetSpotifyArtistAlbumsSkeleton.tsx +++ b/components/VercelChat/tools/GetSpotifyArtistAlbumsSkeleton.tsx @@ -1,32 +1,33 @@ import React from "react"; -import Image from "next/image"; +import { Disc3 } from "lucide-react"; +import { ToolCard, ToolCardBody } from "./shared/ToolCard"; const GetSpotifyArtistAlbumsSkeleton: React.FC = () => { - const skeletonItems = Array.from({ length: 10 }, (_, i) => i); + const skeletonItems = Array.from({ length: 8 }, (_, i) => i); return ( -
-
- Spotify -
Artist Albums
-
-
- {skeletonItems.map((item) => ( -
-
-
-
-
- ))} -
-
+ + +
+ {skeletonItems.map((item) => ( +
+
+
+
+
+
+
+ ))} +
+ + ); }; -export default GetSpotifyArtistAlbumsSkeleton; \ No newline at end of file +export default GetSpotifyArtistAlbumsSkeleton; diff --git a/components/VercelChat/tools/GetSpotifySearchToolResult.tsx b/components/VercelChat/tools/GetSpotifySearchToolResult.tsx index 58b664213..a3bdf69ed 100644 --- a/components/VercelChat/tools/GetSpotifySearchToolResult.tsx +++ b/components/VercelChat/tools/GetSpotifySearchToolResult.tsx @@ -1,7 +1,10 @@ import React from "react"; +import { Search } from "lucide-react"; import { SpotifySearchResponse } from "@/types/spotify"; import SpotifyContentCard from "./SpotifyContentCard"; import { type SpotifyContent } from "@/lib/spotify/spotifyContentUtils"; +import { ToolCard, ToolCardBody } from "./shared/ToolCard"; +import ToolEmpty from "./shared/ToolEmpty"; const typeLabels: Record = { artists: "Artists", @@ -16,45 +19,73 @@ const typeLabels: Record = { const GetSpotifySearchToolResult: React.FC<{ result: SpotifySearchResponse; }> = ({ result }) => { + const sections = Object.entries(result).filter( + ([key, value]) => + key !== "success" && + value && + typeof value === "object" && + "items" in (value as object) && + Array.isArray((value as { items?: unknown[] }).items) && + ((value as { items?: unknown[] }).items?.length ?? 0) > 0, + ) as [string, { items: unknown[]; total?: number }][]; + + const totalResults = sections.reduce( + (acc, [, value]) => acc + value.items.length, + 0, + ); + + if (sections.length === 0) { + return ( + + + + ); + } return ( -
- {Object.entries(result) - .filter( - ([key, value]) => - key !== "success" && - value && - typeof value === "object" && - "items" in (value as object) && - Array.isArray((value as { items?: unknown[] }).items) && - ((value as { items?: unknown[] }).items?.length ?? 0) > 0 - ) - .map(([key, value]) => { - const section = value as { items: unknown[] }; - return ( -
-
+ + + {sections.map(([key, section]) => ( +
+
+

{typeLabels[key] || key} -

-
- {section.items.map((item) => { - const obj = item as { id?: string; name?: string }; - return ( -
- -
- ); - })} -
+ + + {section.items.length} + +
+
+ {section.items.map((item, idx) => { + const obj = item as { id?: string }; + return ( +
+ +
+ ); + })}
- ); - })} -
+
+ ))} + + ); }; diff --git a/components/VercelChat/tools/KnowledgeBaseSection.tsx b/components/VercelChat/tools/KnowledgeBaseSection.tsx index 97d1f14e3..b9c90514f 100644 --- a/components/VercelChat/tools/KnowledgeBaseSection.tsx +++ b/components/VercelChat/tools/KnowledgeBaseSection.tsx @@ -6,34 +6,33 @@ import { Knowledge } from "@/types/knowledge"; const KnowledgeBaseSection = ({ knowledges }: { knowledges: Knowledge[] }) => { return (
-

- - Knowledge Base ({knowledges.length}) +

+ + Knowledge base ({knowledges.length})

-
+
{knowledges.map((knowledge, index) => ( -
-
- -
+
+ +
-
-
- {knowledge.name} -
-
{knowledge.type}
+
+
+ {knowledge.name}
- -
- +
+ {knowledge.type}
+ + ))}
diff --git a/components/VercelChat/tools/SearchWeb/SearchApiResult.tsx b/components/VercelChat/tools/SearchWeb/SearchApiResult.tsx index 872e7a279..1f5859736 100644 --- a/components/VercelChat/tools/SearchWeb/SearchApiResult.tsx +++ b/components/VercelChat/tools/SearchWeb/SearchApiResult.tsx @@ -1,5 +1,9 @@ import React from "react"; +import { Globe, Search } from "lucide-react"; import SearchResultItem from "./SearchResultItem"; +import { ToolCard } from "../shared/ToolCard"; +import ToolEmpty from "../shared/ToolEmpty"; +import ToolError from "../shared/ToolError"; export interface ParsedSearchResult { title: string; @@ -17,38 +21,48 @@ export interface SearchApiResultType { const SearchApiResult = ({ result }: { result: SearchApiResultType }) => { if (!result) { return ( -
-

- Error retrieving search results -

-
+ ); } - const searchResults: ParsedSearchResult[] = result.results; - - if (searchResults.length === 0) { - return ( -
-

- No search results found. -

-
- ); - } + const searchResults: ParsedSearchResult[] = result.results ?? []; + const hasResults = searchResults.length > 0; return ( -
-

- Reviewing sources · {searchResults.length} -

- -
- {searchResults.map((item, index) => ( - - ))} -
-
+ + {searchResults.length} + + ) : undefined + } + > + {hasResults ? ( +
+ {searchResults.map((item, index) => ( + + ))} +
+ ) : ( + + )} +
); }; diff --git a/components/VercelChat/tools/SearchWeb/SearchQueryPill.tsx b/components/VercelChat/tools/SearchWeb/SearchQueryPill.tsx index 579a73abe..696a6bff5 100644 --- a/components/VercelChat/tools/SearchWeb/SearchQueryPill.tsx +++ b/components/VercelChat/tools/SearchWeb/SearchQueryPill.tsx @@ -1,18 +1,28 @@ import React from "react"; import { Search } from "lucide-react"; +import { cn } from "@/lib/utils"; interface SearchQueryPillProps { query: string; + /** Show an animated pulse on the icon while the query is in flight. */ + active?: boolean; } -const SearchQueryPill: React.FC = ({ query }) => { +const SearchQueryPill: React.FC = ({ + query, + active = false, +}) => { return ( -
- - {query} +
+ + {query}
); }; export default SearchQueryPill; - diff --git a/components/VercelChat/tools/SearchWeb/SearchResultItem.tsx b/components/VercelChat/tools/SearchWeb/SearchResultItem.tsx index d5fce421f..709c0c204 100644 --- a/components/VercelChat/tools/SearchWeb/SearchResultItem.tsx +++ b/components/VercelChat/tools/SearchWeb/SearchResultItem.tsx @@ -10,33 +10,64 @@ interface SearchResultItemProps { result: ParsedSearchResult; } +function formatDate(value?: string): string | null { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + const SearchResultItem: React.FC = ({ result }) => { const domain = getDomain(result.url); const faviconUrl = getFaviconUrl(domain); + const date = formatDate(result.date ?? result.last_updated); return ( -
+ + {/* eslint-disable-next-line @next/next/no-img-element */} { e.currentTarget.src = getFallbackFaviconUrl(); }} /> - + + +
+
+ {domain} + {date ? ( + <> + + · + + {date} + + ) : null} +
+ +

{result.title} - +

+ + {result.snippet ? ( +

+ {result.snippet} +

+ ) : null}
- - {domain} -
); }; diff --git a/components/VercelChat/tools/SearchWeb/SearchWebProgress.tsx b/components/VercelChat/tools/SearchWeb/SearchWebProgress.tsx index 325d46e32..305e845dd 100644 --- a/components/VercelChat/tools/SearchWeb/SearchWebProgress.tsx +++ b/components/VercelChat/tools/SearchWeb/SearchWebProgress.tsx @@ -1,77 +1,96 @@ import React from "react"; -import { Loader } from "lucide-react"; +import { Search, Globe, Sparkles } from "lucide-react"; import { Response } from "@/components/ai-elements/response"; import { SearchProgress } from "@/lib/search/searchProgressUtils"; import SearchResultItem from "./SearchResultItem"; import SearchQueryPill from "./SearchQueryPill"; +import { ToolCard, ToolCardBody } from "../shared/ToolCard"; interface SearchWebProgressProps { progress: SearchProgress; } -export const SearchWebProgress: React.FC = ({ progress }) => { - // Searching state: Display the query being searched - if (progress.status === 'searching') { +export const SearchWebProgress: React.FC = ({ + progress, +}) => { + // Searching state: Display the query being searched. + if (progress.status === "searching") { return ( -
-

Searching

-
- -
-
+ + {progress.query ? ( + + + + ) : null} + ); } - // Reviewing state: Display query pill AND list of sources being reviewed - if (progress.status === 'reviewing') { + // Reviewing state: Display query pill AND list of sources being reviewed. + if (progress.status === "reviewing") { const searchResults = progress.searchResults || []; return ( -
-

Searching

-
- -
- -

- Reviewing sources · {searchResults.length} -

- -
- {searchResults.map((item, index) => ( - - ))} -
-
+ + {searchResults.length} + + } + > + + {progress.query ? ( + + ) : null} +
+ {searchResults.map((item, index) => ( + + ))} +
+
+
); } - // Streaming state: Display accumulated content as it streams - if (progress.status === 'streaming') { + // Streaming state: Display accumulated content as it streams. + if (progress.status === "streaming") { return ( -
-
- - {progress.message} -
- {progress.accumulatedContent && ( - {progress.accumulatedContent} - )} -
+ + {progress.accumulatedContent ? ( + + + {progress.accumulatedContent} + + + ) : null} + ); } - // Complete state: Don't show anything (final result handled by SearchWebResult) - if (progress.status === 'complete') { + // Complete state: Don't show anything (final result handled by SearchWebResult). + if (progress.status === "complete") { return null; } - // Fallback for unknown status + // Fallback for unknown status. return null; }; export default SearchWebProgress; - diff --git a/components/VercelChat/tools/SearchWeb/SearchWebSkeleton.tsx b/components/VercelChat/tools/SearchWeb/SearchWebSkeleton.tsx index a19f2fd57..b72857396 100644 --- a/components/VercelChat/tools/SearchWeb/SearchWebSkeleton.tsx +++ b/components/VercelChat/tools/SearchWeb/SearchWebSkeleton.tsx @@ -1,18 +1,43 @@ +"use client"; + import React from "react"; -import { Search } from "lucide-react"; +import { motion } from "framer-motion"; +import { Globe } from "lucide-react"; const SearchWebSkeleton: React.FC = () => { return ( -
-

Searching

-
-
- -
+ +
+
+ +
+
+ + Searching the web… + +
-
+ +
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ); }; -export default SearchWebSkeleton; \ No newline at end of file +export default SearchWebSkeleton; diff --git a/components/VercelChat/tools/SpotifyAlbumWithTracksHero.tsx b/components/VercelChat/tools/SpotifyAlbumWithTracksHero.tsx index b33731b9b..2dfcadd58 100644 --- a/components/VercelChat/tools/SpotifyAlbumWithTracksHero.tsx +++ b/components/VercelChat/tools/SpotifyAlbumWithTracksHero.tsx @@ -15,32 +15,37 @@ const SpotifyAlbumWithTracksHero: React.FC = ({ const backgroundImage = result.images?.[0]?.url; return ( -
- {/* Background Image with Overlay */} +
+ {/* Blurred album-art backdrop with a darkening gradient for legibility */} {backgroundImage && (
-
+
+
)} + {!backgroundImage && ( +
+ )} {/* Content Overlay */}
-
- {/* Album Cover - Hidden on mobile */} -
+
+ {/* Album Cover */} +
{backgroundImage ? ( // eslint-disable-next-line @next/next/no-img-element {result.name} ) : ( -
- +
+
)}
diff --git a/components/VercelChat/tools/SpotifyAlbumWithTracksMeta.tsx b/components/VercelChat/tools/SpotifyAlbumWithTracksMeta.tsx index 0ca7af31a..ea69b346d 100644 --- a/components/VercelChat/tools/SpotifyAlbumWithTracksMeta.tsx +++ b/components/VercelChat/tools/SpotifyAlbumWithTracksMeta.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { Badge } from "@/components/ui/badge"; import { Calendar, Clock, Music, ExternalLink } from "lucide-react"; import { SpotifyAlbum } from "@/types/spotify"; import { formatDuration } from "@/lib/spotify/formatDuration"; @@ -15,79 +14,81 @@ const SpotifyAlbumWithTracksMeta: React.FC = ({ totalDuration, }) => { const spotifyUrl = result.external_urls?.spotify; + const releaseYear = result.release_date + ? new Date(result.release_date).getFullYear() + : null; return ( -
-
- - {result.album_type} - -
+
+ {result.album_type && ( +
+ + {result.album_type} + +
+ )} -

+

{result.name}

-
- +
+ {result.artists.map((artist) => artist.name).join(", ")}
{/* Album Meta */} -
-
- - {new Date(result.release_date).getFullYear()} -
+
+ {releaseYear && ( +
+ + {releaseYear} +
+ )}
- - {result.total_tracks} songs + + {result.total_tracks} song{result.total_tracks === 1 ? "" : "s"}
- + {formatDuration(totalDuration)}
-
- {spotifyUrl && ( + {spotifyUrl && ( +
- + Listen on Spotify Listen - )} -
+
+ )} {/* Label and Genres */} -
- {result.label && ( - - {result.label} - - )} - {result.genres?.slice(0, 2).map((genre, index) => ( - - {genre} - - ))} -
+ {(result.label || (result.genres && result.genres.length > 0)) && ( +
+ {result.label && ( + + {result.label} + + )} + {result.genres?.slice(0, 2).map((genre, index) => ( + + {genre} + + ))} +
+ )}
); }; diff --git a/components/VercelChat/tools/SpotifyArtistTopTracksResult.tsx b/components/VercelChat/tools/SpotifyArtistTopTracksResult.tsx index ed0d84d32..ce360456f 100644 --- a/components/VercelChat/tools/SpotifyArtistTopTracksResult.tsx +++ b/components/VercelChat/tools/SpotifyArtistTopTracksResult.tsx @@ -1,43 +1,48 @@ +import { TrendingUp, Mic2 } from "lucide-react"; import { SpotifyArtistTopTracksResultType } from "@/types/spotify"; -import Image from "next/image"; -import { Badge } from "@/components/ui/badge"; import SpotifyTrackCard from "./SpotifyTrackCard"; +import { ToolCard, ToolCardBody } from "./shared/ToolCard"; +import ToolEmpty from "./shared/ToolEmpty"; const SpotifyArtistTopTracksResult = ({ result, }: { result: SpotifyArtistTopTracksResultType; }) => { - if (result.tracks.length === 0) { - return
Failed to get Spotify artist top tracks
; - } + const tracks = result.tracks ?? []; - const tracks = result.tracks; + if (tracks.length === 0) { + return ( + + + + ); + } return ( -
-
-
- Spotify -

Top Tracks

+ + +
+ {tracks.map((track) => ( + + ))}
- - Spotify - -
- -
- {tracks.map((track) => ( - - ))} -
-
+ + ); }; diff --git a/components/VercelChat/tools/SpotifyArtistTopTracksSkeleton.tsx b/components/VercelChat/tools/SpotifyArtistTopTracksSkeleton.tsx index 8be90dc68..135ea6a7c 100644 --- a/components/VercelChat/tools/SpotifyArtistTopTracksSkeleton.tsx +++ b/components/VercelChat/tools/SpotifyArtistTopTracksSkeleton.tsx @@ -1,40 +1,32 @@ -import { Card, CardContent } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import Image from "next/image"; +import { TrendingUp } from "lucide-react"; +import { ToolCard, ToolCardBody } from "./shared/ToolCard"; const SpotifyArtistTopTracksSkeleton = () => { - // Create an array of 10 items for skeleton placeholders - const skeletonItems = Array.from({ length: 10 }, (_, i) => i); + const skeletonItems = Array.from({ length: 8 }, (_, i) => i); return ( -
-
-
- Spotify -

Getting Top Tracks...

+ + +
+ {skeletonItems.map((item) => ( +
+
+
+
+
+
+
+ ))}
- Spotify -
- -
- {skeletonItems.map((item) => ( - -
- -
-
-
-
- ))} -
-
+ + ); }; -export default SpotifyArtistTopTracksSkeleton; \ No newline at end of file +export default SpotifyArtistTopTracksSkeleton; diff --git a/components/VercelChat/tools/SpotifyContentCard.tsx b/components/VercelChat/tools/SpotifyContentCard.tsx index acee4bd98..7e489185d 100644 --- a/components/VercelChat/tools/SpotifyContentCard.tsx +++ b/components/VercelChat/tools/SpotifyContentCard.tsx @@ -1,9 +1,14 @@ +"use client"; + import React from "react"; -import { Card, CardContent } from "@/components/ui/card"; -import { ExternalLink } from "lucide-react"; +import { ExternalLink, Music } from "lucide-react"; import Link from "next/link"; import { getSpotifyImage } from "@/lib/spotify/getSpotifyImage"; -import { getSpotifySubtitle, type SpotifyContent } from "@/lib/spotify/spotifyContentUtils"; +import { + getSpotifySubtitle, + type SpotifyContent, +} from "@/lib/spotify/spotifyContentUtils"; +import { cn } from "@/lib/utils"; interface SpotifyContentCardProps { content: SpotifyContent; @@ -13,40 +18,57 @@ const SpotifyContentCard = ({ content }: SpotifyContentCardProps) => { const imageUrl = getSpotifyImage(content); const subtitle = getSpotifySubtitle(content); const spotifyUrl = content.external_urls?.spotify; - const hasValidUrl = spotifyUrl && spotifyUrl !== "#"; + const hasValidUrl = Boolean(spotifyUrl && spotifyUrl !== "#"); + // Artists are circular on Spotify; everything else is a rounded square. + const isArtist = content.type === "artist"; const cardContent = ( - -
+
+
{imageUrl ? ( // eslint-disable-next-line @next/next/no-img-element {content.name ) : ( -
- No Image +
+
)} {hasValidUrl && ( -
-
- +
+
+
)}
- -

{content.name}

+
+

+ {content.name} +

{subtitle && ( -

+

{subtitle}

)} - - +
+
); if (hasValidUrl) { @@ -55,6 +77,7 @@ const SpotifyContentCard = ({ content }: SpotifyContentCardProps) => { href={spotifyUrl} target="_blank" rel="noopener noreferrer" + className="block h-full rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/40" > {cardContent} @@ -64,4 +87,4 @@ const SpotifyContentCard = ({ content }: SpotifyContentCardProps) => { return cardContent; }; -export default SpotifyContentCard; \ No newline at end of file +export default SpotifyContentCard; diff --git a/components/VercelChat/tools/SpotifyDeepResearchResult.tsx b/components/VercelChat/tools/SpotifyDeepResearchResult.tsx index a1576f3a9..1a27a5a52 100644 --- a/components/VercelChat/tools/SpotifyDeepResearchResult.tsx +++ b/components/VercelChat/tools/SpotifyDeepResearchResult.tsx @@ -1,8 +1,9 @@ +import { Mic2, Share2 } from "lucide-react"; import { SpotifyDeepResearchResultUIType } from "@/types/spotify"; -import GetArtistSocialsResult from "./GetArtistSocialsResult"; -import { SocialsResponse } from "@/types/Social"; -import Image from "next/image"; -import spotifyLogo from "@/public/brand-logos/spotify.png"; +import { ArtistSocial } from "./ArtistSocial"; +import { ToolCard, ToolCardBody } from "./shared/ToolCard"; +import ToolError from "./shared/ToolError"; +import ToolEmpty from "./shared/ToolEmpty"; export default function SpotifyDeepResearchResult({ result, @@ -10,30 +11,42 @@ export default function SpotifyDeepResearchResult({ result: SpotifyDeepResearchResultUIType; }) { const socials = result.artistSocials?.socials ?? []; - const processedResult = { - success: result.success, - socials, - status: result.success ? "success" : "error", - pagination: { - page: 1, - limit: 10, - total_count: socials.length, - total_pages: 1, - }, - } as SocialsResponse; + + if (!result.success) { + return ( + + ); + } + return ( - + 0 + ? `${socials.length} platform${socials.length === 1 ? "" : "s"} found` + : "Research complete" } - errorText="Spotify Deep Research Failed" - result={processedResult} - /> + > + + {socials.length > 0 ? ( +
+ {socials.map((social) => ( + + ))} +
+ ) : ( + + )} +
+
); } diff --git a/components/VercelChat/tools/SpotifyDeepResearchSkeleton.tsx b/components/VercelChat/tools/SpotifyDeepResearchSkeleton.tsx index f7635a95a..eb11f59b7 100644 --- a/components/VercelChat/tools/SpotifyDeepResearchSkeleton.tsx +++ b/components/VercelChat/tools/SpotifyDeepResearchSkeleton.tsx @@ -1,5 +1,28 @@ -import GetArtistSocialsSkeleton from "./GetArtistSocialsSkeleton"; +import { Mic2 } from "lucide-react"; +import { ToolCard, ToolCardBody } from "./shared/ToolCard"; export default function SpotifyDeepResearchSkeleton() { - return ; + return ( + + +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+ ))} +
+ + + ); } diff --git a/components/VercelChat/tools/UpdateArtistInfoSuccess.tsx b/components/VercelChat/tools/UpdateArtistInfoSuccess.tsx index ccbfc0044..6ef9e93dd 100644 --- a/components/VercelChat/tools/UpdateArtistInfoSuccess.tsx +++ b/components/VercelChat/tools/UpdateArtistInfoSuccess.tsx @@ -1,10 +1,10 @@ import React, { useEffect } from "react"; -import ArtistHeroSection from "./ArtistHeroSection"; import KnowledgeBaseSection from "./KnowledgeBaseSection"; import { Knowledge } from "@/types/knowledge"; -import { CheckCircle, FileText } from "lucide-react"; +import { CheckCircle2, FileText, Building2 } from "lucide-react"; import { useArtistProvider } from "@/providers/ArtistProvider"; import { ArtistProfile } from "@/lib/supabase/artist/updateArtistProfile"; +import { ToolCard, ToolCardBody } from "./shared/ToolCard"; /** * Result type for updateAccountInfo tool @@ -30,59 +30,77 @@ const UpdateArtistInfoSuccess: React.FC = ({ getArtists(); }, []); + // Minimal confirmation when no profile detail is returned. if (!artistProfile) { return ( -
-
- - {message} -
-
+ ); } - // Type guard for knowledges const knowledges = Array.isArray(artistProfile.knowledges) ? (artistProfile.knowledges as unknown as Knowledge[]) : []; - return ( -
- + const avatar = artistProfile.image ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {artistProfile.name +
+ ) : undefined; + + const hasBody = + Boolean(artistProfile.instruction) || + knowledges.length > 0 || + Boolean(artistProfile.organization); -
-
- {/* Custom Instructions */} + return ( + + {hasBody ? ( + {artistProfile.instruction && ( -
-

- Custom Instructions +
+

+ Custom instructions

-
-

- {artistProfile.instruction} -

-
+

+ {artistProfile.instruction} +

)} - {/* Knowledge base */} {knowledges.length > 0 && ( )} - {/* Organization */} {artistProfile.organization && ( -
-
- Organization:{" "} - {artistProfile.organization} -
+
+ + Organization + {artistProfile.organization}
)} -
-

-
+ + ) : null} + ); }; diff --git a/components/VercelChat/tools/UpdateArtistSocialsSuccess.tsx b/components/VercelChat/tools/UpdateArtistSocialsSuccess.tsx index 7e92012e7..0a1ec8ae0 100644 --- a/components/VercelChat/tools/UpdateArtistSocialsSuccess.tsx +++ b/components/VercelChat/tools/UpdateArtistSocialsSuccess.tsx @@ -3,9 +3,13 @@ import ArtistHeroSection from "./ArtistHeroSection"; import { ArtistProfile } from "@/lib/supabase/artist/updateArtistProfile"; import { AccountSocialWithSocial } from "@/lib/supabase/account_socials/getAccountSocials"; import Link from "next/link"; -import { ExternalLink, Globe } from "lucide-react"; +import { ExternalLink, Globe, Share2 } from "lucide-react"; import getSocialPlatformByLink from "@/lib/getSocialPlatformByLink"; +import getPlatformDisplayName from "@/lib/socials/getPlatformDisplayName"; import { useEffect } from "react"; +import { ToolCard } from "./shared/ToolCard"; +import { ToolCardRow } from "./shared/ToolCard"; +import ToolEmpty from "./shared/ToolEmpty"; export interface UpdateArtistSocialsResult { success: boolean; @@ -17,62 +21,90 @@ export interface UpdateArtistSocialsSuccessProps { result: UpdateArtistSocialsResult; } +const normalizeUrl = (url: string) => + url.startsWith("http://") || url.startsWith("https://") + ? url + : `https://${url}`; + const UpdateArtistSocialsSuccess: React.FC = ({ result, }) => { - const { getArtists } = useArtistProvider(); - const { selectedArtist } = useArtistProvider(); + const { getArtists, selectedArtist } = useArtistProvider(); useEffect(() => { getArtists(); }, []); + const socials = result.socials ?? []; + const hasSocials = socials.length > 0; + return ( -
- -
-
- {result.socials?.map((social) => ( - -
-
- -
+ + {socials.length} + + ) : undefined + } + className="max-w-xl" + > + {selectedArtist ? ( + + ) : null} -
-
- {social.social.profile_url} -
-
- {getSocialPlatformByLink( - social.social.profile_url - ).toLowerCase() || "Social Link"} -
-
+ {hasSocials ? ( +
+ {socials.map((social) => { + const platformType = getSocialPlatformByLink( + social.social.profile_url, + ); + const platform = + platformType !== "NONE" + ? getPlatformDisplayName(platformType) + : "Social link"; -
- -
-
- - ))} + return ( + + + + + +
+
+ {social.social.profile_url} +
+
+ {platform} +
+
+ +
+ + ); + })}
-
-
+ ) : ( + + )} + ); }; diff --git a/components/VercelChat/tools/catalog/CatalogCsvUploadButton.tsx b/components/VercelChat/tools/catalog/CatalogCsvUploadButton.tsx index 9d7130de4..018fbe4b4 100644 --- a/components/VercelChat/tools/catalog/CatalogCsvUploadButton.tsx +++ b/components/VercelChat/tools/catalog/CatalogCsvUploadButton.tsx @@ -16,17 +16,17 @@ export default function CatalogCsvUploadButton({ hasCatalogId = true, }: CatalogCsvUploadButtonProps) { return ( -
-