diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 000000000..a131d3023 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,13 @@ +import type { StorybookConfig } from "@storybook/nextjs-vite"; + +const config: StorybookConfig = { + stories: ["../components/**/*.stories.@(ts|tsx)"], + addons: [], + framework: { + name: "@storybook/nextjs-vite", + options: {}, + }, + staticDirs: ["../public"], +}; + +export default config; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 000000000..109d940dd --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,66 @@ +import React, { useEffect, useState } from "react"; +import type { Preview } from "@storybook/nextjs-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { StoryBoundary } from "../components/VercelChat/tools/__stories__/StoryBoundary"; +import "../app/globals.css"; + +/** + * Per-story wrapper: a fresh QueryClient (so cache never leaks between stories) + * plus the app's real `.dark` class toggle driven by the toolbar. + */ +function StoryWrapper({ + theme, + children, +}: { + theme: string; + children: React.ReactNode; +}) { + // useState initializer runs once per story mount → isolated cache per story. + const [queryClient] = useState( + () => new QueryClient({ defaultOptions: { queries: { retry: false } } }), + ); + useEffect(() => { + const root = document.documentElement; + root.classList.toggle("dark", theme === "dark"); + root.style.colorScheme = theme; + }, [theme]); + return ( + {children} + ); +} + +const preview: Preview = { + parameters: { + layout: "padded", + controls: { expanded: true }, + backgrounds: { disable: true }, + }, + globalTypes: { + theme: { + description: "Color theme", + defaultValue: "light", + toolbar: { + title: "Theme", + icon: "circlehollow", + items: [ + { value: "light", title: "Light", icon: "sun" }, + { value: "dark", title: "Dark", icon: "moon" }, + ], + dynamicTitle: true, + }, + }, + }, + decorators: [ + (Story, context) => ( + +
+ + + +
+
+ ), + ], +}; + +export default preview; diff --git a/components/VercelChat/MessageParts.tsx b/components/VercelChat/MessageParts.tsx index 1e522c846..84a3388d2 100644 --- a/components/VercelChat/MessageParts.tsx +++ b/components/VercelChat/MessageParts.tsx @@ -10,7 +10,9 @@ 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 } from "./getToolCallComponent"; +import { getToolResultComponent } from "./getToolResultComponent"; +import { getToolErrorComponent } from "./getToolErrorComponent"; import MessageFileViewer from "./message-file-viewer"; import { EnhancedReasoning } from "@/components/reasoning/EnhancedReasoning"; import { Actions, Action } from "@/components/actions"; @@ -25,7 +27,11 @@ interface MessagePartsProps { } export function MessageParts({ message, mode, setMode }: MessagePartsProps) { - const { status, reload } = useVercelChatContext(); + const { status, reload, messages } = useVercelChatContext(); + // `reload()` regenerates the latest assistant turn, so a tool-error retry + // only makes sense on the most recent message — older historical tool + // failures would otherwise regenerate the wrong turn. + const isLatestMessage = messages[messages.length - 1]?.id === message.id; return (
{message.parts?.map( @@ -107,7 +113,14 @@ 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, + isLatestMessage && status !== "streaming" + ? () => reload() + : undefined, + ); + } else if (state !== "output-available") { return getToolCallComponent(part as ToolUIPart); } else { return getToolResultComponent(part as ToolUIPart); diff --git a/components/VercelChat/getToolCallComponent.tsx b/components/VercelChat/getToolCallComponent.tsx new file mode 100644 index 000000000..9b2ebb676 --- /dev/null +++ b/components/VercelChat/getToolCallComponent.tsx @@ -0,0 +1,25 @@ +import { ToolUIPart, getToolOrDynamicToolName, DynamicToolUIPart } from "ai"; +import GenericToolCard from "./tools/GenericToolCard"; +import { TOOL_CALL_SKELETONS } from "./toolCallSkeletons"; + +/** + * Renders the loading state for an in-flight tool call: a bespoke skeleton when + * the tool has one (see TOOL_CALL_SKELETONS), otherwise the generic tool card, + * which explains the step in plain English and echoes its input so repeated + * calls read as distinct, intentional steps (not a frozen loop). + */ +export function getToolCallComponent(part: ToolUIPart | DynamicToolUIPart) { + const { toolCallId } = part; + const toolName = getToolOrDynamicToolName(part); + const skeleton = TOOL_CALL_SKELETONS[toolName]; + if (skeleton) return
{skeleton}
; + return ( +
+ +
+ ); +} diff --git a/components/VercelChat/getToolErrorComponent.tsx b/components/VercelChat/getToolErrorComponent.tsx new file mode 100644 index 000000000..999f41726 --- /dev/null +++ b/components/VercelChat/getToolErrorComponent.tsx @@ -0,0 +1,22 @@ +import { ToolUIPart, getToolOrDynamicToolName, DynamicToolUIPart } from "ai"; +import { ToolError } from "./tools/shared/ToolError"; + +/** + * 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); + // `errorText` is a runtime-populated field not yet in ToolUIPart's public type. + const errorText = (part as { errorText?: string }).errorText; + return ( +
+ +
+ ); +} diff --git a/components/VercelChat/ToolComponents.tsx b/components/VercelChat/getToolResultComponent.tsx similarity index 63% rename from components/VercelChat/ToolComponents.tsx rename to components/VercelChat/getToolResultComponent.tsx index 763a69408..b6c652de1 100644 --- a/components/VercelChat/ToolComponents.tsx +++ b/components/VercelChat/getToolResultComponent.tsx @@ -1,4 +1,3 @@ -import { ImageSkeleton } from "@/components/VercelChat/tools/image/ImageSkeleton"; import { ImageResult } from "@/components/VercelChat/tools/image/ImageResult"; import { ImageGenerationResult, @@ -6,7 +5,6 @@ import { RetrieveVideoContentResult, } from "@/components/VercelChat/types"; import dynamic from "next/dynamic"; -import CreateArtistToolCall from "./tools/CreateArtistToolCall"; import CreateArtistToolResult from "./tools/CreateArtistToolResult"; import { CreateArtistResult } from "@/types/createArtistResult"; import GetSpotifySearchToolResult from "./tools/GetSpotifySearchToolResult"; @@ -23,35 +21,24 @@ 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 GenericToolCard from "./tools/GenericToolCard"; import { isSearchProgressUpdate } from "@/lib/search/searchProgressUtils"; import YouTubeRevenueResult, { type YouTubeRevenueResult as YouTubeRevenueResultType, } from "./tools/youtube/YouTubeRevenueResult"; -import YouTubeRevenueSkeleton from "./tools/youtube/YouTubeRevenueSkeleton"; -import SearchWebSkeleton from "./tools/SearchWeb/SearchWebSkeleton"; -import SpotifyDeepResearchSkeleton from "./tools/SpotifyDeepResearchSkeleton"; import { SearchApiResultType } from "./tools/SearchWeb/SearchApiResult"; import SearchApiResult from "./tools/SearchWeb/SearchApiResult"; import SearchWebProgress from "./tools/SearchWeb/SearchWebProgress"; import SpotifyDeepResearchResult from "./tools/SpotifyDeepResearchResult"; import GetArtistSocialsResult from "./tools/GetArtistSocialsResult"; -import GetArtistSocialsSkeleton from "./tools/GetArtistSocialsSkeleton"; import GetSpotifyArtistAlbumsResult from "./tools/GetSpotifyArtistAlbumsResult"; import { SpotifyArtistAlbumsResultUIType } from "@/types/spotify"; -import GetSpotifyArtistAlbumsSkeleton from "./tools/GetSpotifyArtistAlbumsSkeleton"; import SpotifyArtistTopTracksResult from "./tools/SpotifyArtistTopTracksResult"; -import SpotifyArtistTopTracksSkeleton from "./tools/SpotifyArtistTopTracksSkeleton"; import GetTasksSuccess from "./tools/tasks/GetTasksSuccess"; import CreateTaskSuccess from "./tools/tasks/CreateTaskSuccess"; -import TasksSkeleton from "@/components/shared/TasksSkeleton"; import GetSpotifyAlbumWithTracksResult from "./tools/GetSpotifyAlbumWithTracksResult"; -import GetSpotifyAlbumWithTracksSkeleton from "./tools/GetSpotifyAlbumWithTracksSkeleton"; import { SpotifyAlbum } from "@/types/spotify"; import DeleteTaskSuccess from "./tools/tasks/DeleteTaskSuccess"; -import DeleteTaskSkeleton from "./tools/tasks/DeleteTaskSkeleton"; import UpdateTaskSuccess from "./tools/tasks/UpdateTaskSuccess"; import { Sora2VideoSkeleton } from "./tools/sora2/Sora2VideoSkeleton"; @@ -62,7 +49,6 @@ const Sora2VideoResult = dynamic( ), { ssr: false, loading: () => }, ); -import CatalogSongsSkeleton from "./tools/catalog/CatalogSongsSkeleton"; import CatalogSongsResult, { CatalogSongsResult as CatalogSongsResultType, } from "./tools/catalog/CatalogSongsResult"; @@ -72,160 +58,31 @@ import { } from "./tools/files/UpdateFileResult"; import ComposioAuthResult from "./tools/composio/ComposioAuthResult"; import { TextContent } from "@modelcontextprotocol/sdk/types.js"; -import PulseToolSkeleton from "./tools/pulse/PulseToolSkeleton"; import PulseToolResult, { PulseToolResultType, } from "./tools/pulse/PulseToolResult"; -import GetChatsSkeleton from "./tools/chats/GetChatsSkeleton"; import GetChatsResult, { GetChatsResultType, } from "./tools/chats/GetChatsResult"; -import RunPageSkeleton from "@/components/TasksPage/Run/RunPageSkeleton"; import RunSandboxCommandResultWithPolling from "./tools/sandbox/RunSandboxCommandResultWithPolling"; type CallToolResult = { content: TextContent[]; }; -export function getToolCallComponent(part: ToolUIPart) { - const { toolCallId } = part as ToolUIPart; - const toolName = getToolOrDynamicToolName(part); - const isSearchWebTool = toolName === "search_web"; - - if (toolName === "generate_image" || toolName === "edit_image") { - return ( -
- -
- ); - } else if (toolName === "create_new_artist") { - return ( -
- -
- ); - } else if (toolName === "get_youtube_revenue") { - return ( -
- -
- ); - } else if (isSearchWebTool) { - return ( -
- -
- ); - } else if (toolName === "spotify_deep_research") { - return ( -
- -
- ); - } else if (toolName === "get_spotify_artist_albums") { - return ( -
- -
- ); - } else if (toolName === "get_artist_socials") { - return ( -
- -
- ); - } else if (toolName === "get_spotify_artist_top_tracks") { - return ( -
- -
- ); - } else if (toolName === "get_tasks") { - return ( -
- -
- ); - } else if (toolName === "get_spotify_album") { - return ( -
- -
- ); - } else if (toolName === "create_task") { - return ( -
- -
- ); - } else if (toolName === "delete_task") { - return ( -
- -
- ); - } else if (toolName === "update_task") { - return ( -
- -
- ); - } else if (toolName === "retrieve_sora_2_video_content") { - return ( -
- -
- ); - } else if ( - toolName === "insert_catalog_songs" || - toolName === "select_catalog_songs" - ) { - return ( -
- -
- ); - } else if (toolName === "get_pulses" || toolName === "update_pulse") { - return ( -
- -
- ); - } else if (toolName === "get_chats") { - return ( -
- -
- ); - } else if ( - toolName === "get_task_run_status" || - toolName === "prompt_sandbox" - ) { - return ( -
- -
- ); - } - - // Default for other tools - return ( -
- - Using {toolName} -
- ); -} - export function getToolResultComponent(part: ToolUIPart | DynamicToolUIPart) { const { toolCallId, output, type } = part; const isMcp = type === "dynamic-tool"; - const result = isMcp - ? JSON.parse((output as CallToolResult).content[0].text) - : output; + const parseMcpResult = () => { + const text = (output as CallToolResult).content?.[0]?.text; + if (typeof text !== "string") return output; + try { + return JSON.parse(text); + } catch { + return { message: text }; + } + }; + const result = isMcp ? parseMcpResult() : output; const toolName = getToolOrDynamicToolName(part); const isSearchWebTool = toolName === "search_web"; @@ -401,22 +258,20 @@ export function getToolResultComponent(part: ToolUIPart | DynamicToolUIPart) { ); } - // Default generic result for other tools + // Default for tools without bespoke UI (incl. unknown/MCP tools): a plain- + // English card that echoes the input and offers the raw output, so a + // non-technical user understands the step and repeated calls look distinct. return ( - ); } - -export const ToolComponents = { - getToolCallComponent, - getToolResultComponent, -}; - -export default ToolComponents; diff --git a/components/VercelChat/toolCallSkeletons.tsx b/components/VercelChat/toolCallSkeletons.tsx new file mode 100644 index 000000000..d06aa9140 --- /dev/null +++ b/components/VercelChat/toolCallSkeletons.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from "react"; +import { ImageSkeleton } from "@/components/VercelChat/tools/image/ImageSkeleton"; +import CreateArtistToolCall from "./tools/CreateArtistToolCall"; +import YouTubeRevenueSkeleton from "./tools/youtube/YouTubeRevenueSkeleton"; +import SearchWebSkeleton from "./tools/SearchWeb/SearchWebSkeleton"; +import SpotifyDeepResearchSkeleton from "./tools/SpotifyDeepResearchSkeleton"; +import GetArtistSocialsSkeleton from "./tools/GetArtistSocialsSkeleton"; +import GetSpotifyArtistAlbumsSkeleton from "./tools/GetSpotifyArtistAlbumsSkeleton"; +import SpotifyArtistTopTracksSkeleton from "./tools/SpotifyArtistTopTracksSkeleton"; +import TasksSkeleton from "@/components/shared/TasksSkeleton"; +import GetSpotifyAlbumWithTracksSkeleton from "./tools/GetSpotifyAlbumWithTracksSkeleton"; +import DeleteTaskSkeleton from "./tools/tasks/DeleteTaskSkeleton"; +import { Sora2VideoSkeleton } from "./tools/sora2/Sora2VideoSkeleton"; +import CatalogSongsSkeleton from "./tools/catalog/CatalogSongsSkeleton"; +import PulseToolSkeleton from "./tools/pulse/PulseToolSkeleton"; +import GetChatsSkeleton from "./tools/chats/GetChatsSkeleton"; +import RunPageSkeleton from "@/components/TasksPage/Run/RunPageSkeleton"; + +/** + * Registry mapping a tool name to its bespoke loading skeleton. Keeping this as + * data (rather than a long if/else dispatcher) makes adding a tool a one-line + * change and keeps `getToolCallComponent` tiny (OCP). + */ +export const TOOL_CALL_SKELETONS: Record = { + generate_image: ( +
+ +
+ ), + edit_image: ( +
+ +
+ ), + create_new_artist: , + get_youtube_revenue: , + search_web: , + spotify_deep_research: , + get_spotify_artist_albums: , + get_artist_socials: , + get_spotify_artist_top_tracks: , + get_tasks: , + create_task: , + update_task: , + get_spotify_album: , + delete_task: , + retrieve_sora_2_video_content: , + insert_catalog_songs: , + select_catalog_songs: , + get_pulses: , + update_pulse: , + get_chats: , + get_task_run_status: , + prompt_sandbox: , +}; diff --git a/components/VercelChat/tools/ArtistAvatar.tsx b/components/VercelChat/tools/ArtistAvatar.tsx new file mode 100644 index 000000000..ed9001f7c --- /dev/null +++ b/components/VercelChat/tools/ArtistAvatar.tsx @@ -0,0 +1,50 @@ +"use client"; + +import React from "react"; +import { User } from "lucide-react"; + +/** + * Avatar with a fade-in load + graceful icon fallback. Its load/error state is + * owned locally, so a parent that remounts it via `key={imageUrl}` gets a fresh + * start per avatar — no useEffect reset, no stale-state flicker between URLs. + */ +const ArtistAvatar = ({ + imageUrl, + name, +}: { + imageUrl: string; + name: string; +}) => { + const [imgLoaded, setImgLoaded] = React.useState(false); + const [imgErrored, setImgErrored] = React.useState(false); + + if (imgErrored) { + return ( +
+ +
+ ); + } + + return ( +
+ {!imgLoaded ? ( +
+ +
+ ) : null} + {/* eslint-disable-next-line @next/next/no-img-element */} + {name} setImgLoaded(true)} + onError={() => setImgErrored(true)} + /> +
+ ); +}; + +export default ArtistAvatar; diff --git a/components/VercelChat/tools/ArtistHeroSection.tsx b/components/VercelChat/tools/ArtistHeroSection.tsx index 9f804a592..1feaee738 100644 --- a/components/VercelChat/tools/ArtistHeroSection.tsx +++ b/components/VercelChat/tools/ArtistHeroSection.tsx @@ -1,8 +1,16 @@ -import React from "react"; -import { CheckCircle, Calendar } from "lucide-react"; +"use client"; + +import { CheckCircle2, Calendar, Tag, User } from "lucide-react"; import { formatDate } from "date-fns"; import { ArtistProfile } from "@/lib/supabase/artist/updateArtistProfile"; +import ArtistAvatar from "./ArtistAvatar"; +/** + * Compact, token-safe artist identity header used inside success tool cards. + * Shows the artist avatar, name, label and last-updated. The success line only + * renders when a `message` is passed, so a parent ToolCard that already owns the + * confirmation can suppress it (no doubled checkmark/message). + */ const ArtistHeroSection = ({ artistProfile, message, @@ -10,64 +18,50 @@ const ArtistHeroSection = ({ artistProfile: ArtistProfile; message?: string; }) => { + const imageUrl = artistProfile?.image; + return ( -
- {artistProfile.image && ( -
+ {imageUrl ? ( + + ) : ( +
+ +
)} -
- -
-
- {artistProfile.image && ( -
- {/* eslint-disable */} - {artistProfile.name -
- )} +
+

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

-
-

- {artistProfile.name} -

- -
- - - {message || "Profile Updated Successfully!"} - -
- - {artistProfile.label && ( -
- Label:{" "} - {artistProfile.label} -
- )} - - {artistProfile.updated_at && ( -
- - - Updated{" "} - {formatDate( - new Date(artistProfile.updated_at), - "MMM d, yyyy, h:mm a" - )} - -
- )} + {message ? ( +
+ + {message}
+ ) : null} + +
+ {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..dc853efd4 100644 --- a/components/VercelChat/tools/ArtistSocial.tsx +++ b/components/VercelChat/tools/ArtistSocial.tsx @@ -1,25 +1,75 @@ import { Social as SocialType } from "@/types/Social"; import Link from "next/link"; +import { ExternalLink } from "lucide-react"; import ArtistSocialDisplayText from "./ArtistSocialDisplayText"; import getSocialPlatformByLink from "@/lib/getSocialPlatformByLink"; import getPlatformDisplayName from "@/lib/socials/getPlatformDisplayName"; +import { getPlatformVisual } from "./getPlatformVisual"; +import { cn } from "@/lib/utils"; + +/** Compact follower-count formatter (1.2K / 3.4M). */ +function formatCount(value: number): string { + // Round up to "M" near the boundary so e.g. 999_950 renders "1M" not "1000.0K". + if (value >= 999_950) + 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}`; +} + +/** Ensure the profile URL has a protocol before using it as an href. */ +function toSocialHref(profileUrl: string): string { + if (profileUrl.startsWith("http://") || profileUrl.startsWith("https://")) + return profileUrl; + return `https://${profileUrl}`; +} 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, chipClass } = getPlatformVisual(social.profile_url); + const followers = + typeof social.follower_count === "number" && social.follower_count > 0 + ? formatCount(social.follower_count) + : null; return ( - {platform} +
+ + + + + {platform} + + +
+ + + {/* Reserve the follower line for an even baseline grid even when absent. */} + + {followers ? `${followers} followers` : "—"} + ); }; 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..48c33f7d9 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..e4490076a 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/GenericSuccess.tsx b/components/VercelChat/tools/GenericSuccess.tsx index 25f7eacf3..da6d3dc76 100644 --- a/components/VercelChat/tools/GenericSuccess.tsx +++ b/components/VercelChat/tools/GenericSuccess.tsx @@ -1,5 +1,12 @@ -import { Icons } from "@/components/Icon/resolver"; +import { Check } from "lucide-react"; +import { ToolCard } from "./shared/ToolCard"; +import { humanizeToolName } from "@/lib/tools/humanizeToolName"; +/** + * 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 +18,38 @@ const GenericSuccess = ({ message: string; children?: React.ReactNode; }) => { + const prettyName = humanizeToolName(name); + // Only render images from app-relative or https sources (defense-in-depth, + // consistent with the href validation used elsewhere in tool cards). + const safeImage = + image && (image.startsWith("/") || image.startsWith("https://")) + ? image + : undefined; 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/GenericToolCard.tsx b/components/VercelChat/tools/GenericToolCard.tsx new file mode 100644 index 000000000..a7b2efaea --- /dev/null +++ b/components/VercelChat/tools/GenericToolCard.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useState } from "react"; +import { Cog, Check, ChevronRight, CornerDownRight } from "lucide-react"; +import { ToolCard, ToolCardBody } from "./shared/ToolCard"; +import { cn } from "@/lib/utils"; +import { humanizeToolName } from "@/lib/tools/humanizeToolName"; +import getToolInfo from "@/lib/tools/getToolInfo"; +import { summarizeToolInput } from "@/lib/tools/summarizeToolInput"; + +interface GenericToolCardProps { + name: string; + /** Raw tool input args — the most meaningful field is echoed so repeated + * calls of the same tool read as distinct, intentional steps. */ + input?: unknown; + /** Raw tool output/result, shown in a collapsible "details" section. */ + output?: unknown; + /** Optional explicit summary line (overrides the derived description). */ + message?: string; + state?: "loading" | "success"; +} + +function toDisplayString(value: unknown): string | null { + if (value == null) return null; + if (typeof value === "string") return value.trim() || null; + try { + const text = JSON.stringify(value, null, 2); + return text && text !== "{}" && text !== "null" ? text : null; + } catch { + return null; + } +} + +/** + * Default card for tools without bespoke UI (including unknown/MCP tools). + * Designed for non-technical users: a friendly name, a plain-English + * explanation of what the step does, the specific input it ran this call, and + * a collapsible look at the raw output. + */ +export function GenericToolCard({ + name, + input, + output, + message, + state = "success", +}: GenericToolCardProps) { + const [open, setOpen] = useState(false); + const loading = state === "loading"; + const info = getToolInfo(name); + const inputSummary = summarizeToolInput(input); + const outputText = loading ? null : toDisplayString(output); + const messageText = typeof message === "string" ? message.trim() : ""; + + return ( + + + Working + + ) : ( + + + Done + + ) + } + > + {inputSummary || outputText ? ( + + {inputSummary ? ( +
+ + + {inputSummary} + +
+ ) : null} + + {outputText ? ( +
+ + {open ? ( +
+                  {outputText}
+                
+ ) : null} +
+ ) : null} +
+ ) : null} +
+ ); +} + +export default GenericToolCard; diff --git a/components/VercelChat/tools/GetArtistSocialsResult.tsx b/components/VercelChat/tools/GetArtistSocialsResult.tsx index 1333071c8..5b0cbc5b8 100644 --- a/components/VercelChat/tools/GetArtistSocialsResult.tsx +++ b/components/VercelChat/tools/GetArtistSocialsResult.tsx @@ -1,7 +1,13 @@ +"use client"; + import { SocialsResponse } from "@/types/Social"; -import { Music } from "lucide-react"; +import { motion } from "framer-motion"; +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 +22,72 @@ 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..3f2952bc5 100644 --- a/components/VercelChat/tools/GetArtistSocialsSkeleton.tsx +++ b/components/VercelChat/tools/GetArtistSocialsSkeleton.tsx @@ -1,31 +1,77 @@ -import { Loader } from "lucide-react"; +"use client"; +import { motion } from "framer-motion"; +import { Users } from "lucide-react"; + +/** A muted block carrying a left-to-right shimmer sweep. */ +const Shimmer = ({ + className = "", + delay = 0, +}: { + className?: string; + delay?: number; +}) => ( +
+ +
+); + +/** + * Mirrors GetArtistSocialsResult: a ToolCard header + a 2/3/4-col grid of + * placeholder tiles (not a row list), so resolving to the result has no + * layout jump. + */ 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..635b31055 100644 --- a/components/VercelChat/tools/GetSpotifyAlbumWithTracksResult.tsx +++ b/components/VercelChat/tools/GetSpotifyAlbumWithTracksResult.tsx @@ -1,91 +1,206 @@ +"use client"; + import React from "react"; -import { Badge } from "@/components/ui/badge"; -import { Play, ExternalLink } from "lucide-react"; -import { SpotifyAlbum } from "@/types/spotify"; +import { motion } from "framer-motion"; +import { Play, ExternalLink, Disc3, ListMusic } from "lucide-react"; +import { SpotifyAlbum, SpotifyTrack } from "@/types/spotify"; import { formatDuration } from "@/lib/spotify/formatDuration"; +import { cn } from "@/lib/utils"; import Link from "next/link"; import SpotifyAlbumWithTracksHero from "./SpotifyAlbumWithTracksHero"; +import { toolCardMotion } from "./shared/toolCardTokens"; +import { ToolCard } from "./shared/ToolCard"; +import { ToolEmpty } from "./shared/ToolEmpty"; interface GetSpotifyAlbumWithTracksResultProps { result: SpotifyAlbum; } +// Spotify brand green for the play / now-playing accent. +const SPOTIFY_GREEN = "#1DB954"; + +// Cascade the rows in after the hero settles, so the record "loads." +const rowsStagger = { + hidden: {}, + show: { transition: { delayChildren: 0.18, staggerChildren: 0.035 } }, +}; + +const rowItem = { + hidden: { opacity: 0, y: 6 }, + show: { opacity: 1, y: 0 }, +}; + +/** + * A purely decorative equalizer flourish shown on the hovered row. CSS/transform + * only — there is no audio; it simply signals the "now playing" affordance. + */ +const Equalizer = () => ( + + {[0, 1, 2].map((i) => ( + + ))} + +); + +const TrackRow = ({ track }: { track: SpotifyTrack }) => { + const spotifyUrl = track.external_urls?.spotify; + const popularity = + "popularity" in track && typeof (track as { popularity?: number }).popularity === "number" + ? (track as { popularity?: number }).popularity + : undefined; + + const rowContent = ( +
+ {/* Track Number swaps to a play affordance on hover (player feel). */} +
+ + {track.track_number} + + +
+ + {/* Track Info */} +
+
+ + {track.name} + + {track.explicit && ( + + E + + )} +
+
+ {track.artists.map((artist) => artist.name).join(", ")} +
+ {typeof popularity === "number" && ( +
+
+
+ )} +
+ + {/* Now-playing equalizer flourish on hover. */} + + + {/* Duration */} +
+ {formatDuration(track.duration_ms)} +
+ + {/* Secondary: open in Spotify. */} + {spotifyUrl && ( +
+ +
+ )} +
+ ); + + return ( + + {spotifyUrl ? ( + + {rowContent} + + ) : ( +
{rowContent}
+ )} +
+ ); +}; + 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} - - -
- - {/* Track Info */} -
-
- - {track.name} - - {track.explicit && ( - - E - - )} -
-
- {track.artists.map((artist) => artist.name).join(", ")} -
-
- - {/* Track Duration */} -
- {formatDuration(track.duration_ms)} -
- - {/* Track Actions - Hidden on mobile */} -
- {track.external_urls?.spotify && ( -

- -

- )} -
-
- - ))} -
-
+
+ + {tracks.map((track) => ( + + ))} +
-
+
); }; 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..b9475c26d 100644 --- a/components/VercelChat/tools/GetSpotifyArtistAlbumsResult.tsx +++ b/components/VercelChat/tools/GetSpotifyArtistAlbumsResult.tsx @@ -1,51 +1,75 @@ +"use client"; + import React from "react"; +import { motion } from "framer-motion"; +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 gridStagger = { + hidden: {}, + show: { transition: { staggerChildren: 0.05 } }, +}; 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; - 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..04af4ab5b 100644 --- a/components/VercelChat/tools/GetSpotifySearchToolResult.tsx +++ b/components/VercelChat/tools/GetSpotifySearchToolResult.tsx @@ -1,7 +1,13 @@ +"use client"; + import React from "react"; +import { motion } from "framer-motion"; +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", @@ -13,48 +19,103 @@ const typeLabels: Record = { audiobooks: "Audiobooks", }; +// Sections cascade top-down; cards within each rail cascade left-to-right. +const sectionStagger = { + hidden: {}, + show: { transition: { staggerChildren: 0.06 } }, +}; + +const railStagger = { + hidden: {}, + show: { transition: { staggerChildren: 0.04 } }, +}; + 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 ( -
-
- {typeLabels[key] || key} + + + + {sections.map(([key, section]) => ( + +
+

+ {typeLabels[key] || key} +

+ + {section.items.length} +
-
- {section.items.map((item) => { - const obj = item as { id?: string; name?: string }; + + {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..a65c63b7e 100644 --- a/components/VercelChat/tools/SearchWeb/SearchApiResult.tsx +++ b/components/VercelChat/tools/SearchWeb/SearchApiResult.tsx @@ -1,5 +1,12 @@ +"use client"; + import React from "react"; +import { motion } from "framer-motion"; +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 +24,64 @@ 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, i) => ( + + + + ))} + + ) : ( + + )} + ); }; diff --git a/components/VercelChat/tools/SearchWeb/SearchQueryPill.tsx b/components/VercelChat/tools/SearchWeb/SearchQueryPill.tsx index 579a73abe..e72bd77ea 100644 --- a/components/VercelChat/tools/SearchWeb/SearchQueryPill.tsx +++ b/components/VercelChat/tools/SearchWeb/SearchQueryPill.tsx @@ -1,18 +1,42 @@ +"use client"; + import React from "react"; +import { motion, useReducedMotion } from "framer-motion"; import { Search } from "lucide-react"; +import { cn } from "@/lib/utils"; interface SearchQueryPillProps { query: string; + /** Animate the pill (shimmer sweep + icon nudge) while the query is in flight. */ + active?: boolean; } -const SearchQueryPill: React.FC = ({ query }) => { +const SearchQueryPill: React.FC = ({ + query, + active = false, +}) => { + const reduce = useReducedMotion(); return ( -
- - {query} +
+ {active && !reduce ? ( + + ) : null} + + + {query} +
); }; export default SearchQueryPill; - diff --git a/components/VercelChat/tools/SearchWeb/SearchResultItem.tsx b/components/VercelChat/tools/SearchWeb/SearchResultItem.tsx index d5fce421f..2bd57b7b1 100644 --- a/components/VercelChat/tools/SearchWeb/SearchResultItem.tsx +++ b/components/VercelChat/tools/SearchWeb/SearchResultItem.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { ArrowUpRight } from "lucide-react"; import { getDomain, getFaviconUrl, @@ -8,35 +9,85 @@ import type { ParsedSearchResult } from "./SearchApiResult"; interface SearchResultItemProps { result: ParsedSearchResult; + /** Optional 1-based citation index rendered as a numbered badge. */ + index?: number; } -const SearchResultItem: React.FC = ({ result }) => { +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", + timeZone: "UTC", + }); +} + +const SearchResultItem: React.FC = ({ + result, + index, +}) => { const domain = getDomain(result.url); const faviconUrl = getFaviconUrl(domain); + const date = formatDate(result.date) ?? formatDate(result.last_updated); + const initial = (domain || result.title || "?").charAt(0).toUpperCase(); return ( -
+ + {/* Letter fallback sits beneath the favicon; if the favicon loads it covers this. */} + + {initial} + + {/* eslint-disable-next-line @next/next/no-img-element */} { e.currentTarget.src = getFallbackFaviconUrl(); }} /> - - {result.title} - -
- - {domain} + +
+
+ {typeof index === "number" ? ( + + {index} + + ) : null} + {domain} + {date ? ( + <> + + · + + {date} + + ) : null} +
+ +

+ + {result.title} + + +

+ + {result.snippet ? ( +

+ {result.snippet} +

+ ) : null} +
); }; diff --git a/components/VercelChat/tools/SearchWeb/SearchWebProgress.tsx b/components/VercelChat/tools/SearchWeb/SearchWebProgress.tsx index 325d46e32..bfb8a3052 100644 --- a/components/VercelChat/tools/SearchWeb/SearchWebProgress.tsx +++ b/components/VercelChat/tools/SearchWeb/SearchWebProgress.tsx @@ -1,77 +1,120 @@ +"use client"; + import React from "react"; -import { Loader } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +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"; +import { useCountUp } from "../shared/useCountUp"; interface SearchWebProgressProps { progress: SearchProgress; } -export const SearchWebProgress: React.FC = ({ progress }) => { - // Searching state: Display the query being searched - if (progress.status === 'searching') { +/** Small animated integer that tweens toward `value` whenever it changes. */ +const CountUp: React.FC<{ value: number }> = ({ value }) => { + const rounded = useCountUp(value); + return {rounded}; +}; + +export const SearchWebProgress: React.FC = ({ + progress, +}) => { + const reduce = useReducedMotion(); + + // 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 as they stream in. + if (progress.status === "reviewing") { const searchResults = progress.searchResults || []; return ( -
-

Searching

-
- -
- -

- Reviewing sources · {searchResults.length} -

- -
- {searchResults.map((item, index) => ( - - ))} -
-
+ + + + } + > + + {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..d757c948e 100644 --- a/components/VercelChat/tools/SearchWeb/SearchWebSkeleton.tsx +++ b/components/VercelChat/tools/SearchWeb/SearchWebSkeleton.tsx @@ -1,18 +1,84 @@ +"use client"; + import React from "react"; -import { Search } from "lucide-react"; +import { motion, useReducedMotion } from "framer-motion"; +import { Globe } from "lucide-react"; + +/** A muted block that wears a left-to-right shimmer sweep instead of a bare pulse. */ +const Shimmer: React.FC<{ className?: string; delay?: number }> = ({ + className = "", + delay = 0, +}) => { + const reduce = useReducedMotion(); + return ( +
+ {reduce ? null : ( + + )} +
+ ); +}; const SearchWebSkeleton: React.FC = () => { + const reduce = useReducedMotion(); return ( -
-

Searching

-
-
- -
+ +
+
+ +
+
+ + Searching the web… + +
-
+ + {/* Thin indeterminate scanning bar to signal active work. */} +
+ {reduce ? null : ( + + )} +
+ +
+ {[...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..b247a5b78 100644 --- a/components/VercelChat/tools/SpotifyAlbumWithTracksHero.tsx +++ b/components/VercelChat/tools/SpotifyAlbumWithTracksHero.tsx @@ -1,4 +1,7 @@ +"use client"; + import React from "react"; +import { motion } from "framer-motion"; import { Music } from "lucide-react"; import { SpotifyAlbum } from "@/types/spotify"; import SpotifyAlbumWithTracksMeta from "./SpotifyAlbumWithTracksMeta"; @@ -15,35 +18,46 @@ const SpotifyAlbumWithTracksHero: React.FC = ({ const backgroundImage = result.images?.[0]?.url; return ( -
- {/* Background Image with Overlay */} +
+ {/* Blurred album-art backdrop. Lighter scrim lets the artwork's hue + survive so each release feels like itself. */} {backgroundImage && (
-
+
+
)} + {!backgroundImage && ( +
+ )} {/* Content Overlay */}
-
- {/* Album Cover - Hidden on mobile */} -
+
+ {/* Album Cover — moves once, gracefully, as the record loads. */} + {backgroundImage ? ( // eslint-disable-next-line @next/next/no-img-element {result.name} ) : ( -
- +
+
)} -
+
{/* Album Info */} = ({ result, totalDuration, }) => { const spotifyUrl = result.external_urls?.spotify; + const safeSpotifyUrl = isSafeSpotifyUrl(spotifyUrl) ? spotifyUrl : undefined; + const releaseYear = result.release_date + ? new Date(result.release_date).getFullYear() + : null; + const popularity = + typeof result.popularity === "number" ? result.popularity : undefined; + + // Spotify's metadata is icon-free and calm — joined with middots. + const metaParts: string[] = []; + if (releaseYear) metaParts.push(String(releaseYear)); + metaParts.push( + `${result.total_tracks} song${result.total_tracks === 1 ? "" : "s"}`, + ); + metaParts.push(formatDuration(totalDuration)); 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()} -
-
- - {result.total_tracks} songs -
-
- - {formatDuration(totalDuration)} + {/* Album Meta — calm, icon-free, middot-separated. */} +
{metaParts.join(" · ")}
+ + {/* Popularity stat (data already in the payload). */} + {typeof popularity === "number" && ( +
+
+ Popularity + {popularity}/100 +
+
+
+
-
+ )} -
- {spotifyUrl && ( + {safeSpotifyUrl && ( +
- + Listen on Spotify Listen - )} -
+
+ )} - {/* Label and Genres */} -
- {result.label && ( - - {result.label} - - )} - {result.genres?.slice(0, 2).map((genre, index) => ( - - {genre} - - ))} -
+ {/* Label and Genres — quiet ghost chips, lighter than before. */} + {(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..6e1c4d42d 100644 --- a/components/VercelChat/tools/SpotifyArtistTopTracksResult.tsx +++ b/components/VercelChat/tools/SpotifyArtistTopTracksResult.tsx @@ -1,43 +1,62 @@ +"use client"; + +import { motion, useReducedMotion } from "framer-motion"; +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 gridStagger = { + hidden: {}, + show: { transition: { staggerChildren: 0.045 } }, +}; const SpotifyArtistTopTracksResult = ({ result, }: { result: SpotifyArtistTopTracksResultType; }) => { - if (result.tracks.length === 0) { - return
Failed to get Spotify artist top tracks
; - } + const reduce = useReducedMotion(); + const tracks = result.tracks ?? []; - const tracks = result.tracks; + if (tracks.length === 0) { + return ( + + + + ); + } return ( -
-
-
- Spotify -

Top Tracks

-
- - Spotify - -
- -
- {tracks.map((track) => ( - - ))} -
-
+ + + + {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..bb16894e4 100644 --- a/components/VercelChat/tools/SpotifyContentCard.tsx +++ b/components/VercelChat/tools/SpotifyContentCard.tsx @@ -1,60 +1,160 @@ +"use client"; + import React from "react"; -import { Card, CardContent } from "@/components/ui/card"; -import { ExternalLink } from "lucide-react"; +import { motion } from "framer-motion"; +import { Music, Play } from "lucide-react"; import Link from "next/link"; import { getSpotifyImage } from "@/lib/spotify/getSpotifyImage"; -import { getSpotifySubtitle, type SpotifyContent } from "@/lib/spotify/spotifyContentUtils"; +import { isSafeSpotifyUrl } from "@/lib/spotify/isSafeSpotifyUrl"; +import { + getSpotifySubtitle, + type SpotifyContent, +} from "@/lib/spotify/spotifyContentUtils"; +import { cn } from "@/lib/utils"; interface SpotifyContentCardProps { content: SpotifyContent; + /** + * Optional explicit subtitle that overrides the derived one. Lets callers + * surface a real label (e.g. a release year on a discography grid) without + * mutating the underlying content data. + */ + subtitle?: string; } -const SpotifyContentCard = ({ content }: SpotifyContentCardProps) => { +// Spotify's brand green (#1DB954) used only for the play affordance accent. +const SPOTIFY_GREEN = "#1DB954"; + +/** Reads popularity (0-100) off content types that carry it, guarding absence. */ +const getPopularity = (content: SpotifyContent): number | undefined => { + if ("popularity" in content && typeof content.popularity === "number") { + return content.popularity; + } + return undefined; +}; + +/** Reads a follower total off artists, guarding absence. */ +const getFollowers = (content: SpotifyContent): number | undefined => { + if ( + content.type === "artist" && + content.followers && + typeof content.followers.total === "number" + ) { + return content.followers.total; + } + return undefined; +}; + +const cardItemMotion = { + hidden: { opacity: 0, y: 8 }, + show: { opacity: 1, y: 0 }, +}; + +const SpotifyContentCard = ({ content, subtitle }: SpotifyContentCardProps) => { const imageUrl = getSpotifyImage(content); - const subtitle = getSpotifySubtitle(content); + const resolvedSubtitle = subtitle ?? getSpotifySubtitle(content); const spotifyUrl = content.external_urls?.spotify; - const hasValidUrl = spotifyUrl && spotifyUrl !== "#"; + const safeSpotifyUrl = isSafeSpotifyUrl(spotifyUrl) ? spotifyUrl : undefined; + const hasValidUrl = Boolean(safeSpotifyUrl); + // Artists are circular on Spotify; everything else is a rounded square. + const isArtist = content.type === "artist"; + // Tracks lead with a play verb; albums/artists open the page. + const isTrack = content.type === "track"; + + const popularity = getPopularity(content); + const followers = getFollowers(content); const cardContent = ( - -
+ +
{imageUrl ? ( // eslint-disable-next-line @next/next/no-img-element {content.name ) : ( -
- No Image +
+
)} {hasValidUrl && ( -
-
- -
+
+ + {/* Tracks read "play"; everything else still gets the music glyph. */} + {isTrack ? ( + + ) : ( + + )} +
)}
- -

{content.name}

- {subtitle && ( -

- {subtitle} +

+

+ {content.name} +

+ {resolvedSubtitle && ( +

+ {resolvedSubtitle} +

+ )} + {typeof followers === "number" && ( +

+ {followers.toLocaleString()} followers

)} - - + {typeof popularity === "number" && ( +
+
+
+
+
+ )} +
+ ); - if (hasValidUrl) { + if (safeSpotifyUrl) { return ( {cardContent} @@ -64,4 +164,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..fc115a35b 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..2c71cb692 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/SpotifyTrackCard.tsx b/components/VercelChat/tools/SpotifyTrackCard.tsx index 57d1c135f..641f35132 100644 --- a/components/VercelChat/tools/SpotifyTrackCard.tsx +++ b/components/VercelChat/tools/SpotifyTrackCard.tsx @@ -1,10 +1,8 @@ import { SpotifyTrackSearchResult } from "@/types/spotify"; import SpotifyContentCard from "./SpotifyContentCard"; -const SpotifyTrackCard = ({ track }: {track: SpotifyTrackSearchResult}) => { - return ( - - ); +const SpotifyTrackCard = ({ track }: { track: SpotifyTrackSearchResult }) => { + return ; }; -export default SpotifyTrackCard; \ No newline at end of file +export default SpotifyTrackCard; diff --git a/components/VercelChat/tools/UpdateArtistInfoSuccess.tsx b/components/VercelChat/tools/UpdateArtistInfoSuccess.tsx index ccbfc0044..70cc73d87 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,86 @@ 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[]) + ? artistProfile.knowledges.filter( + (item): item is Knowledge => + Boolean( + item && + typeof item === "object" && + "url" in item && + "name" in item && + "type" in item, + ), + ) : []; - 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..a210b25c8 100644 --- a/components/VercelChat/tools/UpdateArtistSocialsSuccess.tsx +++ b/components/VercelChat/tools/UpdateArtistSocialsSuccess.tsx @@ -1,11 +1,20 @@ +"use client"; + import { useArtistProvider } from "@/providers/ArtistProvider"; 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 { motion } from "framer-motion"; +import { ExternalLink, 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"; +import { getPlatformVisual } from "./getPlatformVisual"; +import { cn } from "@/lib/utils"; export interface UpdateArtistSocialsResult { success: boolean; @@ -17,62 +26,112 @@ 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" + > + {/* The ToolCard header owns the success confirmation; the hero shows + identity only (no `message`) so the check/message isn't doubled. */} + {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"; + const { Icon, chipClass } = getPlatformVisual( + social.social.profile_url, + ); -
- -
-
- - ))} -
-
-
+ return ( + + + + + + +
+
+ {platform} +
+
+ {social.social.profile_url} +
+
+ +
+ +
+ ); + })} + + ) : ( + + )} + ); }; diff --git a/components/VercelChat/tools/__stories__/AllToolStates.stories.tsx b/components/VercelChat/tools/__stories__/AllToolStates.stories.tsx new file mode 100644 index 000000000..a02d22e18 --- /dev/null +++ b/components/VercelChat/tools/__stories__/AllToolStates.stories.tsx @@ -0,0 +1,175 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import React from "react"; + +import GetChatsResult from "../chats/GetChatsResult"; +import GetChatsSkeleton from "../chats/GetChatsSkeleton"; +import SearchApiResult from "../SearchWeb/SearchApiResult"; +import SearchWebSkeleton from "../SearchWeb/SearchWebSkeleton"; +import SearchWebProgress from "../SearchWeb/SearchWebProgress"; +import GetSpotifyArtistAlbumsResult from "../GetSpotifyArtistAlbumsResult"; +import GetSpotifyArtistAlbumsSkeleton from "../GetSpotifyArtistAlbumsSkeleton"; +import SpotifyArtistTopTracksResult from "../SpotifyArtistTopTracksResult"; +import SpotifyArtistTopTracksSkeleton from "../SpotifyArtistTopTracksSkeleton"; +import GetSpotifySearchToolResult from "../GetSpotifySearchToolResult"; +import SpotifyDeepResearchSkeleton from "../SpotifyDeepResearchSkeleton"; +import YouTubeRevenueResult from "../youtube/YouTubeRevenueResult"; +import YouTubeRevenueSkeleton from "../youtube/YouTubeRevenueSkeleton"; +import GetArtistSocialsResult from "../GetArtistSocialsResult"; +import GetArtistSocialsSkeleton from "../GetArtistSocialsSkeleton"; +import ComposioConnectPrompt from "../composio/ComposioConnectPrompt"; +import ComposioConnectedState from "../composio/ComposioConnectedState"; +import { ImageSkeleton } from "../image/ImageSkeleton"; +import { ImageResult } from "../image/ImageResult"; +import CatalogSongsSkeleton from "../catalog/CatalogSongsSkeleton"; +import GenericSuccess from "../GenericSuccess"; +import GenericToolCard from "../GenericToolCard"; + +const IMG = "/dashboard.png"; +type R = C extends (props: infer P) => unknown + ? P extends { result: infer X } + ? X + : never + : never; + +const meta: Meta = { + title: "Tool States", + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/* ---------------------------------------------------------------- Chats */ +const chats = { + chats: [ + { id: "1", title: "Q3 release strategy for Nova", sessionId: "s1", accountId: "a1", updatedAt: new Date().toISOString() }, + { id: "2", title: "TikTok campaign brainstorm", sessionId: "s1", accountId: "a1", updatedAt: new Date().toISOString() }, + { id: "3", title: "Untitled Chat", sessionId: "s1", accountId: "a1", updatedAt: new Date().toISOString() }, + ], +} satisfies R; +export const ChatsResult: Story = { render: () => }; +export const ChatsEmpty: Story = { render: () => }; +export const ChatsSkeleton: Story = { render: () => }; + +/* ------------------------------------------------------------- Web search */ +const search = { + formatted: "", + results: [ + { title: "Nova announces sophomore album 'Aurora' — Pitchfork", url: "https://pitchfork.com/news/nova-aurora", snippet: "The rising pop artist revealed a 12-track record arriving this fall.", date: "2026-05-12" }, + { title: "How Nova built a 2M fanbase on short-form video", url: "https://www.rollingstone.com/music/nova", snippet: "A look at the creative strategy behind one of the year's fastest-growing acts.", date: "2026-04-28" }, + ], +} satisfies R; +export const SearchResult: Story = { render: () => }; +export const SearchEmpty: Story = { render: () => }; +export const SearchSkeleton: Story = { render: () => }; +export const SearchProgress: Story = { + render: () => ( + ["progress"]} + /> + ), +}; + +/* --------------------------------------------------------------- Spotify */ +const albums = { + total: 9, + items: [ + { id: "al1", name: "Aurora", release_date: "2026-09-04", images: [{ url: IMG }], artists: [{ id: "a1", name: "Nova" }], type: "album" }, + { id: "al2", name: "Midnight Drives", release_date: "2024-03-15", images: [{ url: IMG }], artists: [{ id: "a1", name: "Nova" }], type: "album" }, + { id: "al3", name: "First Light - EP", release_date: "2022-07-01", images: [{ url: IMG }], artists: [{ id: "a1", name: "Nova" }], type: "album" }, + ], +} as unknown as R; +export const SpotifyAlbumsResult: Story = { render: () => }; +export const SpotifyAlbumsSkeleton: Story = { render: () => }; + +const tracks = { + tracks: [ + { id: "t1", name: "Aurora", duration_ms: 201000, popularity: 88, explicit: false, album: { images: [{ url: IMG }] }, artists: [{ name: "Nova" }], external_urls: { spotify: "https://open.spotify.com" } }, + { id: "t2", name: "Midnight Drive", duration_ms: 184000, popularity: 74, explicit: true, album: { images: [{ url: IMG }] }, artists: [{ name: "Nova" }], external_urls: { spotify: "https://open.spotify.com" } }, + ], +} as unknown as R; +export const SpotifyTopTracksResult: Story = { render: () => }; +export const SpotifyTopTracksSkeleton: Story = { render: () => }; +export const SpotifyDeepResearchSkeletonStory: Story = { render: () => }; + +const spotifySearch = { + artists: { items: [{ id: "a1", name: "Nova", images: [{ url: IMG }], type: "artist", external_urls: { spotify: "#" } }] }, + tracks: { items: [{ id: "t1", name: "Aurora", album: { images: [{ url: IMG }] }, artists: [{ name: "Nova" }], type: "track", external_urls: { spotify: "#" } }] }, +} as unknown as R; +export const SpotifySearchResult: Story = { render: () => }; + +/* --------------------------------------------------------------- YouTube */ +const youtube = { + success: true, + status: "ok", + revenueData: { + totalRevenue: 4821.57, + dailyRevenue: Array.from({ length: 7 }).map((_, i) => ({ date: `2026-06-${String(14 + i).padStart(2, "0")}`, revenue: 420 + Math.round(Math.sin(i) * 180 + i * 60) })), + dateRange: { startDate: "2026-06-14", endDate: "2026-06-20" }, + channelId: "UC123", + isMonetized: true, + }, +} satisfies R; +export const YouTubeResult: Story = { render: () => }; +export const YouTubeError: Story = { render: () => }; +export const YouTubeSkeleton: Story = { render: () => }; + +/* ----------------------------------------------------------- Artist socials */ +const socials = { + status: "success", + socials: [ + { id: "s1", profile_url: "instagram.com/novamusic", follower_count: 1200000 }, + { id: "s2", profile_url: "youtube.com/@novamusic", follower_count: 480000 }, + { id: "s3", profile_url: "tiktok.com/@novamusic", follower_count: 2100000 }, + { id: "s4", profile_url: "spotify.com/artist/nova", follower_count: 95000 }, + ], +} as unknown as R; +export const ArtistSocialsResult: Story = { render: () => }; +export const ArtistSocialsEmpty: Story = { render: () => } /> }; +export const ArtistSocialsSkeleton: Story = { render: () => }; + +/* ------------------------------------------------------------- Connectors */ +export const ComposioConnect: Story = { render: () => }; +export const ComposioConnected: Story = { render: () => }; + +/* ----------------------------------------------------------------- Media */ +export const ImageLoading: Story = { render: () => }; +export const ImageResultStory: Story = { render: () => } /> }; + +/* ---------------------------------------------------------------- Catalog */ +export const CatalogSkeleton: Story = { render: () => }; + +/* ----------------------------------------------------------------- Generic */ +export const GenericSuccessDefault: Story = { render: () => }; + +/* ------------------------------------------------- Generic tool clarity card */ +export const ToolCardCommandLoading: Story = { + render: () => , +}; +export const ToolCardCommandSuccess: Story = { + render: () => ( + + ), +}; +/** Repeated calls of the same tool must read as distinct, intentional steps. */ +export const ToolCardRepeatedCalls: Story = { + render: () => ( +
+ + + +
+ ), +}; +export const ToolCardMcpTool: Story = { + render: () => ( + + ), +}; diff --git a/components/VercelChat/tools/__stories__/Gallery.stories.tsx b/components/VercelChat/tools/__stories__/Gallery.stories.tsx new file mode 100644 index 000000000..5c4291735 --- /dev/null +++ b/components/VercelChat/tools/__stories__/Gallery.stories.tsx @@ -0,0 +1,115 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import React from "react"; +import { Sparkles } from "lucide-react"; +import { + ToolCard, + ToolCardBody, + ToolError, + ToolEmpty, + ToolStatusPill, + ToolCardSkeleton, +} from "../shared"; +import GenericSuccess from "../GenericSuccess"; +import GetChatsResult from "../chats/GetChatsResult"; +import SearchApiResult from "../SearchWeb/SearchApiResult"; +import YouTubeRevenueResult from "../youtube/YouTubeRevenueResult"; +import GetSpotifyArtistAlbumsResult from "../GetSpotifyArtistAlbumsResult"; +import GetArtistSocialsResult from "../GetArtistSocialsResult"; +import ComposioConnectPrompt from "../composio/ComposioConnectPrompt"; +import ComposioConnectedState from "../composio/ComposioConnectedState"; +import { StoryBoundary } from "./StoryBoundary"; + +const IMG = "/dashboard.png"; + +const chats: React.ComponentProps["result"] = { + chats: [ + { id: "1", title: "Q3 release strategy for Nova", sessionId: "s1", accountId: "a1", updatedAt: new Date().toISOString() }, + { id: "2", title: "TikTok campaign brainstorm", sessionId: "s1", accountId: "a1", updatedAt: new Date().toISOString() }, + { id: "3", title: "Untitled Chat", sessionId: "s1", accountId: "a1", updatedAt: new Date().toISOString() }, + ], +}; +const search: React.ComponentProps["result"] = { + formatted: "", + results: [ + { title: "Nova announces sophomore album 'Aurora' — Pitchfork", url: "https://pitchfork.com/news/nova-aurora", snippet: "The rising pop artist revealed a 12-track record arriving this fall, produced with longtime collaborator Jae Park.", date: "2026-05-12" }, + { title: "How Nova built a 2M-strong fanbase on short-form video", url: "https://www.rollingstone.com/music/nova-fanbase", snippet: "A look at the creative strategy behind one of the year's fastest-growing independent acts.", date: "2026-04-28" }, + ], +}; +const youtube: React.ComponentProps["result"] = { + success: true, + status: "ok", + revenueData: { + totalRevenue: 4821.57, + dailyRevenue: Array.from({ length: 7 }).map((_, i) => ({ date: `2026-06-${String(14 + i).padStart(2, "0")}`, revenue: 420 + Math.round(Math.sin(i) * 180 + i * 60) })), + dateRange: { startDate: "2026-06-14", endDate: "2026-06-20" }, + channelId: "UC123", + isMonetized: true, + }, +}; +// Partial fixture asserted to the result type — stories only exercise the +// fields the component reads (cover art, name, release year, artist name). +const albums = { + total: 9, + items: [ + { id: "al1", name: "Aurora", release_date: "2026-09-04", images: [{ url: IMG }], artists: [{ id: "a1", name: "Nova" }], type: "album" }, + { id: "al2", name: "Midnight Drives", release_date: "2024-03-15", images: [{ url: IMG }], artists: [{ id: "a1", name: "Nova" }], type: "album" }, + { id: "al3", name: "First Light - EP", release_date: "2022-07-01", images: [{ url: IMG }], artists: [{ id: "a1", name: "Nova" }], type: "album" }, + ], +} as unknown as React.ComponentProps["result"]; +const socials = { + status: "success", + socials: [ + { id: "s1", profile_url: "instagram.com/novamusic", follower_count: 1200000 }, + { id: "s2", profile_url: "youtube.com/@novamusic", follower_count: 480000 }, + { id: "s3", profile_url: "tiktok.com/@novamusic", follower_count: 2100000 }, + { id: "s4", profile_url: "spotify.com/artist/nova", follower_count: 95000 }, + ], +} as unknown as React.ComponentProps["result"]; + +interface SectionProps { + title: string; + children: React.ReactNode; +} + +function Section({ title, children }: SectionProps) { + return ( +
+

{title}

+ +
{children}
+
+
+ ); +} + +const meta: Meta = { title: "Chat Tools/Gallery", parameters: { layout: "fullscreen" } }; +export default meta; +type Story = StoryObj; + +/** Every state composed on one canvas — used for the PR overview screenshots. */ +export const AllStates: Story = { + render: () => ( +
+
+ + + {}} /> + + + + + + +
+
+
+
+
+
+
+ + +
+
+ ), +}; diff --git a/components/VercelChat/tools/__stories__/StoryBoundary.tsx b/components/VercelChat/tools/__stories__/StoryBoundary.tsx new file mode 100644 index 000000000..8ed2d9353 --- /dev/null +++ b/components/VercelChat/tools/__stories__/StoryBoundary.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +/** Isolates a single story item so one failing component can't blank the canvas. */ +export class StoryBoundary extends React.Component< + { label: string; children: React.ReactNode }, + { error: Error | null } +> { + state = { error: null as Error | null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + if (this.state.error) { + return ( +
+ {this.props.label}: {this.state.error.message} +
+ ); + } + return this.props.children; + } +} + +export default StoryBoundary; diff --git a/components/VercelChat/tools/__stories__/ToolDesignSystem.stories.tsx b/components/VercelChat/tools/__stories__/ToolDesignSystem.stories.tsx new file mode 100644 index 000000000..cd7101770 --- /dev/null +++ b/components/VercelChat/tools/__stories__/ToolDesignSystem.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { Sparkles, Music } from "lucide-react"; +import { + ToolCard, + ToolCardBody, + ToolCardRow, + ToolError, + ToolEmpty, + ToolStatusPill, + ToolCardSkeleton, +} from "../shared"; + +/** + * The shared design-system primitives every chat tool response is built on. + */ +const meta: Meta = { + title: "Chat Tools/Design System", +}; +export default meta; + +type Story = StoryObj; + +export const StatusPill: Story = { + render: () => , +}; + +export const Skeleton: Story = { + render: () => , +}; + +export const Error: Story = { + render: () => ( + {}} + /> + ), +}; + +export const Empty: Story = { + render: () => ( + + + + + + ), +}; + +export const CardTones: Story = { + render: () => ( +
+ {(["neutral", "success", "error", "info", "accent", "warning"] as const).map( + (tone) => ( + + + + A list row + + + + ), + )} +
+ ), +}; diff --git a/components/VercelChat/tools/catalog/CatalogCsvUploadButton.tsx b/components/VercelChat/tools/catalog/CatalogCsvUploadButton.tsx index 9d7130de4..3a20889e7 100644 --- a/components/VercelChat/tools/catalog/CatalogCsvUploadButton.tsx +++ b/components/VercelChat/tools/catalog/CatalogCsvUploadButton.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { useId, useRef } from "react"; import { Upload } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -15,43 +18,44 @@ export default function CatalogCsvUploadButton({ onFileSelect, hasCatalogId = true, }: CatalogCsvUploadButtonProps) { + const inputId = useId(); + const inputRef = useRef(null); return ( -
-