Compare commits

..

1 Commits

Author SHA1 Message Date
Otto
6d8c83c039 refactor(backend): move local imports to module level in chat service
Addresses review feedback from PRs #11937, #11856:
- Move uuid import to top level (was duplicated in 3 functions)
- Move compress_context import to top level
- Remove redundant local imports for cast and ChatCompletionMessageParam
  (already imported at module level)

Refs:
- https://github.com/Significant-Gravitas/AutoGPT/pull/11937#discussion_r2761107861
- https://github.com/Significant-Gravitas/AutoGPT/pull/11856#discussion_r2761558008
- https://github.com/Significant-Gravitas/AutoGPT/pull/11856#discussion_r2761559661
2026-02-04 03:33:15 +00:00
48 changed files with 28 additions and 6887 deletions

View File

@@ -117,7 +117,7 @@ class StreamToolOutputAvailable(StreamBaseResponse):
type: ResponseType = ResponseType.TOOL_OUTPUT_AVAILABLE
toolCallId: str = Field(..., description="Tool call ID this responds to")
output: str | dict[str, Any] = Field(..., description="Tool execution output")
# Keep these for internal backend use
# Additional fields for internal use (not part of AI SDK spec but useful)
toolName: str | None = Field(
default=None, description="Name of the tool that was executed"
)
@@ -125,15 +125,6 @@ class StreamToolOutputAvailable(StreamBaseResponse):
default=True, description="Whether the tool execution succeeded"
)
def to_sse(self) -> str:
"""Convert to SSE format, excluding non-spec fields."""
import json
data = {
"type": self.type.value,
"toolCallId": self.toolCallId,
"output": self.output,
}
return f"data: {json.dumps(data)}\n\n"
# ========== Other ==========

View File

@@ -1,12 +1,15 @@
import asyncio
import logging
import time
import uuid as uuid_module
from asyncio import CancelledError
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Any, cast
import openai
from backend.util.prompt import compress_context
if TYPE_CHECKING:
from backend.util.prompt import CompressResult
@@ -341,7 +344,6 @@ async def stream_chat_completion(
retry_count: int = 0,
session: ChatSession | None = None,
context: dict[str, str] | None = None, # {url: str, content: str}
_continuation_message_id: str | None = None, # Internal: reuse message ID for tool call continuations
) -> AsyncGenerator[StreamBaseResponse, None]:
"""Main entry point for streaming chat completions with database handling.
@@ -468,17 +470,11 @@ async def stream_chat_completion(
should_retry = False
# Generate unique IDs for AI SDK protocol
import uuid as uuid_module
# Reuse message ID for continuations (tool call follow-ups) to avoid duplicate messages
is_continuation = _continuation_message_id is not None
message_id = _continuation_message_id or str(uuid_module.uuid4())
message_id = str(uuid_module.uuid4())
text_block_id = str(uuid_module.uuid4())
# Only yield message start for the initial call, not for continuations
# This prevents the AI SDK from creating duplicate message objects
if not is_continuation:
yield StreamStart(messageId=message_id)
# Yield message start
yield StreamStart(messageId=message_id)
try:
async for chunk in _stream_chat_chunks(
@@ -706,7 +702,6 @@ async def stream_chat_completion(
retry_count=retry_count + 1,
session=session,
context=context,
_continuation_message_id=message_id, # Reuse message ID since start was already sent
):
yield chunk
return # Exit after retry to avoid double-saving in finally block
@@ -776,7 +771,6 @@ async def stream_chat_completion(
session=session, # Pass session object to avoid Redis refetch
context=context,
tool_call_response=str(tool_response_messages),
_continuation_message_id=message_id, # Reuse message ID to avoid duplicates
):
yield chunk
@@ -833,10 +827,6 @@ async def _manage_context_window(
Returns:
CompressResult with compacted messages and metadata
"""
import openai
from backend.util.prompt import compress_context
# Convert messages to dict format
messages_dict = []
for msg in messages:
@@ -1147,8 +1137,6 @@ async def _yield_tool_call(
KeyError: If expected tool call fields are missing
TypeError: If tool call structure is invalid
"""
import uuid as uuid_module
tool_name = tool_calls[yield_idx]["function"]["name"]
tool_call_id = tool_calls[yield_idx]["id"]
@@ -1769,8 +1757,6 @@ async def _generate_llm_continuation_with_streaming(
after a tool result is saved. Chunks are published to the stream registry
so reconnecting clients can receive them.
"""
import uuid as uuid_module
try:
# Load fresh session from DB (bypass cache to get the updated tool result)
await invalidate_session_cache(session_id)
@@ -1806,10 +1792,6 @@ async def _generate_llm_continuation_with_streaming(
extra_body["session_id"] = session_id[:128]
# Make streaming LLM call (no tools - just text response)
from typing import cast
from openai.types.chat import ChatCompletionMessageParam
# Generate unique IDs for AI SDK protocol
message_id = str(uuid_module.uuid4())
text_block_id = str(uuid_module.uuid4())

View File

@@ -30,7 +30,6 @@
"defaults"
],
"dependencies": {
"@ai-sdk/react": "3.0.61",
"@faker-js/faker": "10.0.0",
"@hookform/resolvers": "5.2.2",
"@next/third-parties": "15.4.6",
@@ -61,10 +60,6 @@
"@rjsf/utils": "6.1.2",
"@rjsf/validator-ajv8": "6.1.2",
"@sentry/nextjs": "10.27.0",
"@streamdown/cjk": "1.0.1",
"@streamdown/code": "1.0.1",
"@streamdown/math": "1.0.1",
"@streamdown/mermaid": "1.0.1",
"@supabase/ssr": "0.7.0",
"@supabase/supabase-js": "2.78.0",
"@tanstack/react-query": "5.90.6",
@@ -73,7 +68,6 @@
"@vercel/analytics": "1.5.0",
"@vercel/speed-insights": "1.2.0",
"@xyflow/react": "12.9.2",
"ai": "6.0.59",
"boring-avatars": "1.11.2",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -118,11 +112,9 @@
"remark-math": "6.0.0",
"shepherd.js": "14.5.1",
"sonner": "2.0.7",
"streamdown": "2.1.0",
"tailwind-merge": "2.6.0",
"tailwind-scrollbar": "3.1.0",
"tailwindcss-animate": "1.0.7",
"use-stick-to-bottom": "1.1.2",
"uuid": "11.1.0",
"vaul": "1.1.2",
"zod": "3.25.76",

File diff suppressed because it is too large Load Diff

View File

@@ -1,70 +0,0 @@
"use client";
import { UIDataTypes, UITools, UIMessage } from "ai";
import { ChatMessagesContainer } from "../ChatMessagesContainer/ChatMessagesContainer";
import { EmptySession } from "../EmptySession/EmptySession";
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
import { CopilotChatActionsProvider } from "../CopilotChatActionsProvider/CopilotChatActionsProvider";
import { LayoutGroup, motion } from "framer-motion";
export interface ChatContainerProps {
messages: UIMessage<unknown, UIDataTypes, UITools>[];
status: string;
error: Error | undefined;
sessionId: string | null;
isCreatingSession: boolean;
onCreateSession: () => void | Promise<string>;
onSend: (message: string) => void | Promise<void>;
}
export const ChatContainer = ({
messages,
status,
error,
sessionId,
isCreatingSession,
onCreateSession,
onSend,
}: ChatContainerProps) => {
const inputLayoutId = "copilot-2-chat-input";
return (
<CopilotChatActionsProvider onSend={onSend}>
<LayoutGroup id="copilot-2-chat-layout">
<div className="h-full w-full pb-6">
<div className="flex h-full flex-col">
{sessionId ? (
<div className="mx-auto flex h-full w-full max-w-3xl flex-col">
<ChatMessagesContainer
messages={messages}
status={status}
error={error}
/>
<motion.div
layoutId={inputLayoutId}
transition={{ type: "spring", bounce: 0.2, duration: 0.65 }}
className="relative px-3 pt-2"
>
<div className="pointer-events-none absolute top-[-18px] z-10 h-6 w-full bg-gradient-to-b from-transparent to-[#f8f8f9] dark:to-background" />
<ChatInput
inputId="chat-input-session"
onSend={onSend}
disabled={status === "streaming"}
isStreaming={status === "streaming"}
onStop={() => {}}
placeholder="You can search or just ask"
/>
</motion.div>
</div>
) : (
<EmptySession
inputLayoutId={inputLayoutId}
isCreatingSession={isCreatingSession}
onCreateSession={onCreateSession}
onSend={onSend}
/>
)}
</div>
</div>
</LayoutGroup>
</CopilotChatActionsProvider>
);
};

View File

@@ -1,144 +0,0 @@
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import {
Message,
MessageContent,
MessageResponse,
} from "@/components/ai-elements/message";
import { MessageSquareIcon } from "lucide-react";
import { UIMessage, UIDataTypes, UITools, ToolUIPart } from "ai";
import { FindBlocksTool } from "../../tools/FindBlocks/FindBlocks";
import { FindAgentsTool } from "../../tools/FindAgents/FindAgents";
import { SearchDocsTool } from "../../tools/SearchDocs/SearchDocs";
import { RunBlockTool } from "../../tools/RunBlock/RunBlock";
import { RunAgentTool } from "../../tools/RunAgent/RunAgent";
import { ViewAgentOutputTool } from "../../tools/ViewAgentOutput/ViewAgentOutput";
import { CreateAgentTool } from "../../tools/CreateAgent/CreateAgent";
import { EditAgentTool } from "../../tools/EditAgent/EditAgent";
interface ChatMessagesContainerProps {
messages: UIMessage<unknown, UIDataTypes, UITools>[];
status: string;
error: Error | undefined;
}
export const ChatMessagesContainer = ({
messages,
status,
error,
}: ChatMessagesContainerProps) => {
return (
<Conversation className="flex-1">
<ConversationContent>
{messages.length === 0 ? (
<ConversationEmptyState
icon={<MessageSquareIcon className="size-12" />}
title="Start a conversation"
description="Type a message below to begin chatting"
/>
) : (
messages.map((message) => (
<Message from={message.role} key={message.id}>
<MessageContent
className={
"rounded-xl border px-3 py-2 " +
"group-[.is-user]:rounded-2xl group-[.is-user]:border-purple-200 group-[.is-user]:bg-purple-100 group-[.is-user]:text-slate-900 " +
"group-[.is-assistant]:border-none group-[.is-assistant]:bg-slate-50/20 group-[.is-assistant]:text-slate-900"
}
>
{message.parts.map((part, i) => {
switch (part.type) {
case "text":
return (
<MessageResponse key={`${message.id}-${i}`}>
{part.text}
</MessageResponse>
);
case "tool-find_block":
return (
<FindBlocksTool
key={`${message.id}-${i}`}
part={part as ToolUIPart}
/>
);
case "tool-find_agent":
case "tool-find_library_agent":
return (
<FindAgentsTool
key={`${message.id}-${i}`}
part={part as ToolUIPart}
/>
);
case "tool-search_docs":
case "tool-get_doc_page":
return (
<SearchDocsTool
key={`${message.id}-${i}`}
part={part as ToolUIPart}
/>
);
case "tool-run_block":
return (
<RunBlockTool
key={`${message.id}-${i}`}
part={part as ToolUIPart}
/>
);
case "tool-run_agent":
case "tool-schedule_agent":
return (
<RunAgentTool
key={`${message.id}-${i}`}
part={part as ToolUIPart}
/>
);
case "tool-create_agent":
return (
<CreateAgentTool
key={`${message.id}-${i}`}
part={part as ToolUIPart}
/>
);
case "tool-edit_agent":
return (
<EditAgentTool
key={`${message.id}-${i}`}
part={part as ToolUIPart}
/>
);
case "tool-view_agent_output":
return (
<ViewAgentOutputTool
key={`${message.id}-${i}`}
part={part as ToolUIPart}
/>
);
default:
return null;
}
})}
</MessageContent>
</Message>
))
)}
{status === "submitted" && (
<Message from="assistant">
<MessageContent>
<p className="text-zinc-500">Thinking...</p>
</MessageContent>
</Message>
)}
{error && (
<div className="rounded-lg bg-red-50 p-3 text-red-600">
Error: {error.message}
</div>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
);
};

View File

@@ -1,174 +0,0 @@
"use client";
import {
Sidebar,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import {
PlusIcon,
SpinnerGapIcon,
ChatCircleIcon,
} from "@phosphor-icons/react";
import { motion } from "framer-motion";
import { useState } from "react";
import { parseAsString, useQueryState } from "nuqs";
import {
postV2CreateSession,
useGetV2ListSessions,
getGetV2ListSessionsQueryKey,
} from "@/app/api/__generated__/endpoints/chat/chat";
import { Button } from "@/components/atoms/Button/Button";
import { useQueryClient } from "@tanstack/react-query";
export function ChatSidebar() {
const { state } = useSidebar();
const isCollapsed = state === "collapsed";
const [isCreating, setIsCreating] = useState(false);
const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString);
const queryClient = useQueryClient();
const { data: sessionsResponse, isLoading: isLoadingSessions } =
useGetV2ListSessions({ limit: 50 });
const sessions =
sessionsResponse?.status === 200 ? sessionsResponse.data.sessions : [];
async function handleNewChat() {
if (isCreating) return;
setIsCreating(true);
try {
const response = await postV2CreateSession({
body: JSON.stringify({}),
});
if (response.status === 200 && response.data?.id) {
setSessionId(response.data.id);
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey(),
});
}
} finally {
setIsCreating(false);
}
}
function handleSelectSession(id: string) {
setSessionId(id);
}
function formatDate(dateString: string) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Yesterday";
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString();
}
return (
<Sidebar
variant="inset"
collapsible="icon"
className="!top-[60px] !h-[calc(100vh-60px)]"
>
{isCollapsed && (
<SidebarHeader
className={cn(
"flex",
isCollapsed
? "flex-row items-center justify-between gap-y-4 md:flex-col md:items-start md:justify-start"
: "flex-row items-center justify-between",
)}
>
<motion.div
key={isCollapsed ? "header-collapsed" : "header-expanded"}
className={cn(
"flex items-center gap-2",
isCollapsed ? "flex-row md:flex-col-reverse" : "flex-row",
)}
initial={{ opacity: 0, filter: "blur(3px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
transition={{ type: "spring", bounce: 0.2 }}
>
{isCollapsed && (
<div className="h-fit rounded-3xl border border-neutral-400 bg-secondary p-1">
<SidebarTrigger />
</div>
)}
</motion.div>
</SidebarHeader>
)}
<SidebarContent className="gap-4 overflow-y-auto px-2 py-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={handleNewChat}
disabled={isCreating}
className={cn(
"flex h-fit w-full items-center justify-center gap-2 rounded-3xl border-purple-400 bg-purple-100 px-3 py-2 text-purple-600 hover:border-purple-500 hover:bg-purple-200 hover:text-purple-700",
isCollapsed && "justify-center rounded-3xl px-1",
)}
>
{isCreating ? (
<SpinnerGapIcon className="h-4 w-4 animate-spin" weight="bold" />
) : (
<PlusIcon className="h-4 w-4" weight="bold" />
)}
{!isCollapsed && (
<span>{isCreating ? "Creating..." : "New Chat"}</span>
)}
</Button>
{!isCollapsed && (
<div className="h-fit rounded-3xl border border-neutral-400 bg-secondary p-1">
<SidebarTrigger />
</div>
)}
</div>
{!isCollapsed && (
<div className="mt-4 flex flex-col gap-1">
{isLoadingSessions ? (
<div className="flex items-center justify-center py-4">
<SpinnerGapIcon className="h-5 w-5 animate-spin text-neutral-400" />
</div>
) : sessions.length === 0 ? (
<p className="py-4 text-center text-sm text-neutral-500">
No conversations yet
</p>
) : (
sessions.map((session) => (
<button
key={session.id}
onClick={() => handleSelectSession(session.id)}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-800",
sessionId === session.id &&
"bg-neutral-100 dark:bg-neutral-800",
)}
>
<ChatCircleIcon className="h-4 w-4 shrink-0 text-neutral-500" />
<div className="flex flex-col overflow-hidden">
<span className="truncate font-medium">
{session.title || `Untitled chat`}
</span>
<span className="text-xs text-neutral-500">
{formatDate(session.updated_at)}
</span>
</div>
</button>
))
)}
</div>
)}
</SidebarContent>
<SidebarFooter className="px-2"></SidebarFooter>
</Sidebar>
);
}

View File

@@ -1,16 +0,0 @@
"use client";
import { CopilotChatActionsContext } from "./useCopilotChatActions";
interface Props {
onSend: (message: string) => void | Promise<void>;
children: React.ReactNode;
}
export function CopilotChatActionsProvider({ onSend, children }: Props) {
return (
<CopilotChatActionsContext.Provider value={{ onSend }}>
{children}
</CopilotChatActionsContext.Provider>
);
}

View File

@@ -1,23 +0,0 @@
"use client";
import { createContext, useContext } from "react";
interface CopilotChatActions {
onSend: (message: string) => void | Promise<void>;
}
const CopilotChatActionsContext = createContext<CopilotChatActions | null>(
null,
);
export function useCopilotChatActions(): CopilotChatActions {
const ctx = useContext(CopilotChatActionsContext);
if (!ctx) {
throw new Error(
"useCopilotChatActions must be used within CopilotChatActionsProvider",
);
}
return ctx;
}
export { CopilotChatActionsContext };

View File

@@ -1,108 +0,0 @@
"use client";
import {
getGreetingName,
getQuickActions,
} from "@/app/(platform)/copilot/helpers";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { SparkleIcon, SpinnerGapIcon } from "@phosphor-icons/react";
import { motion } from "framer-motion";
import { useState } from "react";
interface Props {
inputLayoutId: string;
isCreatingSession: boolean;
onCreateSession: () => void | Promise<string>;
onSend: (message: string) => void | Promise<void>;
}
export function EmptySession({
inputLayoutId,
isCreatingSession,
onSend,
}: Props) {
const { user } = useSupabase();
const greetingName = getGreetingName(user);
const quickActions = getQuickActions();
const [loadingAction, setLoadingAction] = useState<string | null>(null);
async function handleQuickActionClick(action: string) {
if (isCreatingSession || loadingAction) return;
setLoadingAction(action);
try {
await onSend(action);
} finally {
setLoadingAction(null);
}
}
return (
<div className="relative flex h-full flex-1 flex-col items-center justify-center overflow-hidden px-6 py-10 dark:bg-background">
<motion.div
className="relative w-full max-w-3xl"
initial={{ opacity: 0, y: 14, filter: "blur(6px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ type: "spring", bounce: 0.2, duration: 0.7 }}
>
<div className="mx-auto flex flex-col items-center text-center">
<div className="mb-5 flex items-center gap-2 rounded-full border border-border/60 bg-white/70 px-3 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur dark:bg-neutral-950/40">
<SparkleIcon className="h-3.5 w-3.5 text-purple-600" />
<span>Autopilot runs for you 24/7</span>
</div>
<Text variant="h3" className="mb-3 !text-[1.375rem] text-zinc-700">
Hey, <span className="text-violet-600">{greetingName}</span>
</Text>
<Text variant="h3" className="!font-normal">
What do you want to automate?
</Text>
</div>
<div className="mx-auto p-5 dark:bg-neutral-950/40">
<motion.div
layoutId={inputLayoutId}
transition={{ type: "spring", bounce: 0.2, duration: 0.65 }}
className="w-full"
>
<ChatInput
inputId="chat-input-empty"
onSend={onSend}
disabled={isCreatingSession}
placeholder='You can search or just ask - e.g. "create a blog post outline"'
className="w-full"
/>
</motion.div>
<div className="mt-8 flex w-full flex-nowrap items-center justify-center gap-3 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{quickActions.map((action) => (
<Button
key={action}
type="button"
variant="outline"
size="small"
onClick={() => void handleQuickActionClick(action)}
disabled={isCreatingSession || loadingAction !== null}
aria-busy={loadingAction === action}
leftIcon={
loadingAction === action ? (
<SpinnerGapIcon
className="h-4 w-4 animate-spin"
weight="bold"
/>
) : null
}
className="h-auto shrink-0 border-zinc-600 !px-4 !py-2 text-[1rem] text-zinc-600"
>
{action}
</Button>
))}
</div>
</div>
</motion.div>
</div>
);
}

View File

@@ -1,52 +0,0 @@
import { AnimatePresence, motion } from "framer-motion";
interface Props {
text: string;
}
export function MorphingTextAnimation({ text }: Props) {
const letters = text.split("");
return (
<div>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div key={text} className="whitespace-nowrap">
<motion.span className="inline-flex overflow-hidden">
{letters.map((char, index) => (
<motion.span
key={`${text}-${index}`}
initial={{
opacity: 0,
y: 8,
rotateX: "80deg",
filter: "blur(6px)",
}}
animate={{
opacity: 1,
y: 0,
rotateX: "0deg",
filter: "blur(0px)",
}}
exit={{
opacity: 0,
y: -8,
rotateX: "-80deg",
filter: "blur(6px)",
}}
style={{ willChange: "transform" }}
transition={{
delay: 0.015 * index,
type: "spring",
bounce: 0.5,
}}
className="inline-block"
>
{char === " " ? "\u00A0" : char}
</motion.span>
))}
</motion.span>
</motion.div>
</AnimatePresence>
</div>
);
}

View File

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

View File

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

View File

@@ -1,128 +0,0 @@
import type { UIMessage, UIDataTypes, UITools } from "ai";
interface SessionChatMessage {
role: string;
content: string | null;
tool_call_id: string | null;
tool_calls: unknown[] | null;
}
function coerceSessionChatMessages(
rawMessages: unknown[],
): SessionChatMessage[] {
return rawMessages
.map((m) => {
if (!m || typeof m !== "object") return null;
const msg = m as Record<string, unknown>;
const role = typeof msg.role === "string" ? msg.role : null;
if (!role) return null;
return {
role,
content:
typeof msg.content === "string"
? msg.content
: msg.content == null
? null
: String(msg.content),
tool_call_id:
typeof msg.tool_call_id === "string"
? msg.tool_call_id
: msg.tool_call_id == null
? null
: String(msg.tool_call_id),
tool_calls: Array.isArray(msg.tool_calls) ? msg.tool_calls : null,
};
})
.filter((m): m is SessionChatMessage => m !== null);
}
function safeJsonParse(value: string): unknown {
try {
return JSON.parse(value) as unknown;
} catch {
return value;
}
}
function toToolInput(rawArguments: unknown): unknown {
if (typeof rawArguments === "string") {
const trimmed = rawArguments.trim();
return trimmed ? safeJsonParse(trimmed) : {};
}
if (rawArguments && typeof rawArguments === "object") return rawArguments;
return {};
}
export function convertChatSessionMessagesToUiMessages(
sessionId: string,
rawMessages: unknown[],
): UIMessage<unknown, UIDataTypes, UITools>[] {
const messages = coerceSessionChatMessages(rawMessages);
const toolOutputsByCallId = new Map<string, unknown>();
for (const msg of messages) {
if (msg.role !== "tool") continue;
if (!msg.tool_call_id) continue;
if (msg.content == null) continue;
toolOutputsByCallId.set(msg.tool_call_id, msg.content);
}
const uiMessages: UIMessage<unknown, UIDataTypes, UITools>[] = [];
messages.forEach((msg, index) => {
if (msg.role === "tool") return;
if (msg.role !== "user" && msg.role !== "assistant") return;
const parts: UIMessage<unknown, UIDataTypes, UITools>["parts"] = [];
if (typeof msg.content === "string" && msg.content.trim()) {
parts.push({ type: "text", text: msg.content, state: "done" });
}
if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) {
for (const rawToolCall of msg.tool_calls) {
if (!rawToolCall || typeof rawToolCall !== "object") continue;
const toolCall = rawToolCall as {
id?: unknown;
function?: { name?: unknown; arguments?: unknown };
};
const toolCallId = String(toolCall.id ?? "").trim();
const toolName = String(toolCall.function?.name ?? "").trim();
if (!toolCallId || !toolName) continue;
const input = toToolInput(toolCall.function?.arguments);
const output = toolOutputsByCallId.get(toolCallId);
if (output !== undefined) {
parts.push({
type: `tool-${toolName}`,
toolCallId,
state: "output-available",
input,
output: typeof output === "string" ? safeJsonParse(output) : output,
});
} else {
parts.push({
type: `tool-${toolName}`,
toolCallId,
state: "input-available",
input,
});
}
}
}
if (parts.length === 0) return;
uiMessages.push({
id: `${sessionId}-${index}`,
role: msg.role,
parts,
});
});
return uiMessages;
}

View File

@@ -1,216 +0,0 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useEffect, useMemo, useRef, useState } from "react";
import { parseAsString, useQueryState } from "nuqs";
import { ChatSidebar } from "./components/ChatSidebar/ChatSidebar";
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { CopyIcon, CheckIcon } from "@phosphor-icons/react";
import {
getGetV2ListSessionsQueryKey,
getV2GetSession,
postV2CreateSession,
} from "@/app/api/__generated__/endpoints/chat/chat";
import { convertChatSessionMessagesToUiMessages } from "./helpers/convertChatSessionToUiMessages";
import { useQueryClient } from "@tanstack/react-query";
export default function Page() {
const [copied, setCopied] = useState(false);
const [isCreatingSession, setIsCreatingSession] = useState(false);
const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString);
const hydrationSeq = useRef(0);
const lastHydratedSessionIdRef = useRef<string | null>(null);
const createSessionPromiseRef = useRef<Promise<string> | null>(null);
const queuedFirstMessageRef = useRef<string | null>(null);
const queuedFirstMessageResolverRef = useRef<(() => void) | null>(null);
const queryClient = useQueryClient();
function handleCopySessionId() {
if (!sessionId) return;
navigator.clipboard.writeText(sessionId);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
const transport = useMemo(() => {
if (!sessionId) return null;
return new DefaultChatTransport({
api: `/api/chat/sessions/${sessionId}/stream`,
prepareSendMessagesRequest: ({ messages }) => {
const last = messages[messages.length - 1];
return {
body: {
message: last.parts
?.map((p) => (p.type === "text" ? p.text : ""))
.join(""),
is_user_message: last.role === "user",
context: null,
},
};
},
});
}, [sessionId]);
const { messages, sendMessage, status, error, setMessages } = useChat({
id: sessionId ?? undefined,
transport: transport ?? undefined,
});
const messagesRef = useRef(messages);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
async function createSession(): Promise<string> {
if (sessionId) return sessionId;
if (createSessionPromiseRef.current) return createSessionPromiseRef.current;
setIsCreatingSession(true);
const promise = (async () => {
const response = await postV2CreateSession({
body: JSON.stringify({}),
});
if (response.status !== 200 || !response.data?.id) {
throw new Error("Failed to create chat session");
}
setSessionId(response.data.id);
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey(),
});
return response.data.id;
})();
createSessionPromiseRef.current = promise;
try {
return await promise;
} finally {
createSessionPromiseRef.current = null;
setIsCreatingSession(false);
}
}
useEffect(() => {
hydrationSeq.current += 1;
const seq = hydrationSeq.current;
const controller = new AbortController();
if (!sessionId) {
setMessages([]);
lastHydratedSessionIdRef.current = null;
return;
}
const currentSessionId = sessionId;
if (lastHydratedSessionIdRef.current !== currentSessionId) {
setMessages([]);
lastHydratedSessionIdRef.current = currentSessionId;
}
async function hydrate() {
try {
const response = await getV2GetSession(currentSessionId, {
signal: controller.signal,
});
if (response.status !== 200) return;
const uiMessages = convertChatSessionMessagesToUiMessages(
currentSessionId,
response.data.messages ?? [],
);
if (controller.signal.aborted) return;
if (hydrationSeq.current !== seq) return;
const localMessagesCount = messagesRef.current.length;
const remoteMessagesCount = uiMessages.length;
if (remoteMessagesCount === 0) return;
if (localMessagesCount > remoteMessagesCount) return;
setMessages(uiMessages);
} catch (error) {
if ((error as { name?: string } | null)?.name === "AbortError") return;
console.warn("Failed to hydrate chat session:", error);
}
}
void hydrate();
return () => controller.abort();
}, [sessionId, setMessages]);
useEffect(() => {
if (!sessionId) return;
const firstMessage = queuedFirstMessageRef.current;
if (!firstMessage) return;
queuedFirstMessageRef.current = null;
sendMessage({ text: firstMessage });
queuedFirstMessageResolverRef.current?.();
queuedFirstMessageResolverRef.current = null;
}, [sendMessage, sessionId]);
async function onSend(message: string) {
const trimmed = message.trim();
if (!trimmed) return;
if (sessionId) {
sendMessage({ text: trimmed });
return;
}
queuedFirstMessageRef.current = trimmed;
const sentPromise = new Promise<void>((resolve) => {
queuedFirstMessageResolverRef.current = resolve;
});
await createSession();
await sentPromise;
}
return (
<SidebarProvider
defaultOpen={false}
className="h-[calc(100vh-72px)] min-h-0"
>
<ChatSidebar />
<SidebarInset className="relative flex h-[calc(100vh-80px)] flex-col overflow-hidden ring-1 ring-zinc-300">
{sessionId && (
<div className="absolute flex items-center px-4 py-4">
<div className="flex items-center gap-2 rounded-3xl border border-neutral-400 bg-neutral-100 px-3 py-1.5 text-sm text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400">
<span className="text-xs">{sessionId.slice(0, 8)}...</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleCopySessionId}
>
{copied ? (
<CheckIcon className="h-3.5 w-3.5 text-green-500" />
) : (
<CopyIcon className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
)}
<div className="flex-1 overflow-hidden">
<ChatContainer
messages={messages}
status={status}
error={error}
sessionId={sessionId}
isCreatingSession={isCreatingSession}
onCreateSession={createSession}
onSend={onSend}
/>
</div>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -1,211 +0,0 @@
"use client";
import type { ToolUIPart } from "ai";
import Link from "next/link";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
import {
ClarificationQuestionsWidget,
type ClarifyingQuestion as WidgetClarifyingQuestion,
} from "@/components/contextual/Chat/components/ClarificationQuestionsWidget/ClarificationQuestionsWidget";
import {
formatMaybeJson,
getAnimationText,
getCreateAgentToolOutput,
isAgentPreviewOutput,
isAgentSavedOutput,
isClarificationNeededOutput,
isErrorOutput,
isOperationInProgressOutput,
isOperationPendingOutput,
isOperationStartedOutput,
StateIcon,
truncateText,
type CreateAgentToolOutput,
} from "./helpers";
export interface CreateAgentToolPart {
type: string;
toolCallId: string;
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}
interface Props {
part: CreateAgentToolPart;
}
function getAccordionMeta(output: CreateAgentToolOutput): {
badgeText: string;
title: string;
description?: string;
} {
if (isAgentSavedOutput(output)) {
return { badgeText: "Create agent", title: output.agent_name };
}
if (isAgentPreviewOutput(output)) {
return {
badgeText: "Create agent",
title: output.agent_name,
description: `${output.node_count} block${output.node_count === 1 ? "" : "s"}`,
};
}
if (isClarificationNeededOutput(output)) {
const questions = output.questions ?? [];
return {
badgeText: "Create agent",
title: "Needs clarification",
description: `${questions.length} question${questions.length === 1 ? "" : "s"}`,
};
}
if (
isOperationStartedOutput(output) ||
isOperationPendingOutput(output) ||
isOperationInProgressOutput(output)
) {
return { badgeText: "Create agent", title: "Creating agent" };
}
return { badgeText: "Create agent", title: "Error" };
}
export function CreateAgentTool({ part }: Props) {
const text = getAnimationText(part);
const { onSend } = useCopilotChatActions();
const output = getCreateAgentToolOutput(part);
const hasExpandableContent =
part.state === "output-available" &&
!!output &&
(isOperationStartedOutput(output) ||
isOperationPendingOutput(output) ||
isOperationInProgressOutput(output) ||
isAgentPreviewOutput(output) ||
isAgentSavedOutput(output) ||
isClarificationNeededOutput(output) ||
isErrorOutput(output));
function handleClarificationAnswers(answers: Record<string, string>) {
const contextMessage = Object.entries(answers)
.map(([keyword, answer]) => `${keyword}: ${answer}`)
.join("\n");
onSend(
`I have the answers to your questions:\n\n${contextMessage}\n\nPlease proceed with creating the agent.`,
);
}
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
</div>
{hasExpandableContent && output && (
<ToolAccordion
{...getAccordionMeta(output)}
defaultExpanded={isClarificationNeededOutput(output)}
>
{(isOperationStartedOutput(output) ||
isOperationPendingOutput(output)) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
<p className="text-xs text-muted-foreground">
Operation: {output.operation_id}
</p>
<p className="text-xs italic text-muted-foreground">
Check your library in a few minutes.
</p>
</div>
)}
{isOperationInProgressOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
<p className="text-xs italic text-muted-foreground">
Please wait for the current operation to finish.
</p>
</div>
)}
{isAgentSavedOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
<div className="flex flex-wrap gap-2">
<Link
href={output.library_agent_link}
className="text-xs font-medium text-purple-600 hover:text-purple-700"
>
Open in library
</Link>
<Link
href={output.agent_page_link}
className="text-xs font-medium text-purple-600 hover:text-purple-700"
>
Open in builder
</Link>
</div>
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{truncateText(
formatMaybeJson({ agent_id: output.agent_id }),
800,
)}
</pre>
</div>
)}
{isAgentPreviewOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{output.description?.trim() && (
<p className="text-xs text-muted-foreground">
{output.description}
</p>
)}
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{truncateText(formatMaybeJson(output.agent_json), 1600)}
</pre>
</div>
)}
{isClarificationNeededOutput(output) && (
<ClarificationQuestionsWidget
questions={(output.questions ?? []).map((q) => {
const item: WidgetClarifyingQuestion = {
question: q.question,
keyword: q.keyword,
};
const example =
typeof q.example === "string" && q.example.trim()
? q.example.trim()
: null;
if (example) item.example = example;
return item;
})}
message={output.message}
onSubmitAnswers={handleClarificationAnswers}
/>
)}
{isErrorOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{output.error && (
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{formatMaybeJson(output.error)}
</pre>
)}
{output.details && (
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{formatMaybeJson(output.details)}
</pre>
)}
</div>
)}
</ToolAccordion>
)}
</div>
);
}

View File

@@ -1,183 +0,0 @@
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import type { AgentPreviewResponse } from "@/app/api/__generated__/models/agentPreviewResponse";
import type { AgentSavedResponse } from "@/app/api/__generated__/models/agentSavedResponse";
import type { ClarificationNeededResponse } from "@/app/api/__generated__/models/clarificationNeededResponse";
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
import type { OperationInProgressResponse } from "@/app/api/__generated__/models/operationInProgressResponse";
import type { OperationPendingResponse } from "@/app/api/__generated__/models/operationPendingResponse";
import type { OperationStartedResponse } from "@/app/api/__generated__/models/operationStartedResponse";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
export type CreateAgentToolOutput =
| OperationStartedResponse
| OperationPendingResponse
| OperationInProgressResponse
| AgentPreviewResponse
| AgentSavedResponse
| ClarificationNeededResponse
| ErrorResponse;
function parseOutput(output: unknown): CreateAgentToolOutput | null {
if (!output) return null;
if (typeof output === "string") {
const trimmed = output.trim();
if (!trimmed) return null;
try {
return parseOutput(JSON.parse(trimmed) as unknown);
} catch {
return null;
}
}
if (typeof output === "object") {
const type = (output as { type?: unknown }).type;
if (
type === ResponseType.operation_started ||
type === ResponseType.operation_pending ||
type === ResponseType.operation_in_progress ||
type === ResponseType.agent_preview ||
type === ResponseType.agent_saved ||
type === ResponseType.clarification_needed ||
type === ResponseType.error
) {
return output as CreateAgentToolOutput;
}
if ("operation_id" in output && "tool_name" in output)
return output as OperationStartedResponse | OperationPendingResponse;
if ("tool_call_id" in output) return output as OperationInProgressResponse;
if ("agent_json" in output && "agent_name" in output)
return output as AgentPreviewResponse;
if ("agent_id" in output && "library_agent_id" in output)
return output as AgentSavedResponse;
if ("questions" in output) return output as ClarificationNeededResponse;
if ("error" in output || "details" in output)
return output as ErrorResponse;
}
return null;
}
export function getCreateAgentToolOutput(
part: unknown,
): CreateAgentToolOutput | null {
if (!part || typeof part !== "object") return null;
return parseOutput((part as { output?: unknown }).output);
}
export function isOperationStartedOutput(
output: CreateAgentToolOutput,
): output is OperationStartedResponse {
return (
output.type === ResponseType.operation_started ||
("operation_id" in output && "tool_name" in output)
);
}
export function isOperationPendingOutput(
output: CreateAgentToolOutput,
): output is OperationPendingResponse {
return output.type === ResponseType.operation_pending;
}
export function isOperationInProgressOutput(
output: CreateAgentToolOutput,
): output is OperationInProgressResponse {
return (
output.type === ResponseType.operation_in_progress ||
"tool_call_id" in output
);
}
export function isAgentPreviewOutput(
output: CreateAgentToolOutput,
): output is AgentPreviewResponse {
return output.type === ResponseType.agent_preview || "agent_json" in output;
}
export function isAgentSavedOutput(
output: CreateAgentToolOutput,
): output is AgentSavedResponse {
return (
output.type === ResponseType.agent_saved || "agent_page_link" in output
);
}
export function isClarificationNeededOutput(
output: CreateAgentToolOutput,
): output is ClarificationNeededResponse {
return (
output.type === ResponseType.clarification_needed || "questions" in output
);
}
export function isErrorOutput(
output: CreateAgentToolOutput,
): output is ErrorResponse {
return output.type === ResponseType.error || "error" in output;
}
export function getAnimationText(part: {
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}): string {
switch (part.state) {
case "input-streaming":
return "Creating agent";
case "input-available":
return "Generating agent workflow";
case "output-available": {
const output = parseOutput(part.output);
if (!output) return "Agent created";
if (isOperationStartedOutput(output)) return "Agent creation started";
if (isOperationPendingOutput(output)) return "Agent creation in progress";
if (isOperationInProgressOutput(output))
return "Agent creation already in progress";
if (isAgentSavedOutput(output)) return `Saved: ${output.agent_name}`;
if (isAgentPreviewOutput(output)) return `Preview: ${output.agent_name}`;
if (isClarificationNeededOutput(output)) return "Needs clarification";
return "Error creating agent";
}
case "output-error":
return "Error creating agent";
default:
return "Processing";
}
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
}
export function formatMaybeJson(value: unknown): string {
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
export function truncateText(text: string, maxChars: number): string {
const trimmed = text.trim();
if (trimmed.length <= maxChars) return trimmed;
return `${trimmed.slice(0, maxChars).trimEnd()}`;
}

View File

@@ -1,211 +0,0 @@
"use client";
import type { ToolUIPart } from "ai";
import Link from "next/link";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
import {
ClarificationQuestionsWidget,
type ClarifyingQuestion as WidgetClarifyingQuestion,
} from "@/components/contextual/Chat/components/ClarificationQuestionsWidget/ClarificationQuestionsWidget";
import {
formatMaybeJson,
getAnimationText,
getEditAgentToolOutput,
isAgentPreviewOutput,
isAgentSavedOutput,
isClarificationNeededOutput,
isErrorOutput,
isOperationInProgressOutput,
isOperationPendingOutput,
isOperationStartedOutput,
StateIcon,
truncateText,
type EditAgentToolOutput,
} from "./helpers";
export interface EditAgentToolPart {
type: string;
toolCallId: string;
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}
interface Props {
part: EditAgentToolPart;
}
function getAccordionMeta(output: EditAgentToolOutput): {
badgeText: string;
title: string;
description?: string;
} {
if (isAgentSavedOutput(output)) {
return { badgeText: "Edit agent", title: output.agent_name };
}
if (isAgentPreviewOutput(output)) {
return {
badgeText: "Edit agent",
title: output.agent_name,
description: `${output.node_count} block${output.node_count === 1 ? "" : "s"}`,
};
}
if (isClarificationNeededOutput(output)) {
const questions = output.questions ?? [];
return {
badgeText: "Edit agent",
title: "Needs clarification",
description: `${questions.length} question${questions.length === 1 ? "" : "s"}`,
};
}
if (
isOperationStartedOutput(output) ||
isOperationPendingOutput(output) ||
isOperationInProgressOutput(output)
) {
return { badgeText: "Edit agent", title: "Editing agent" };
}
return { badgeText: "Edit agent", title: "Error" };
}
export function EditAgentTool({ part }: Props) {
const text = getAnimationText(part);
const { onSend } = useCopilotChatActions();
const output = getEditAgentToolOutput(part);
const hasExpandableContent =
part.state === "output-available" &&
!!output &&
(isOperationStartedOutput(output) ||
isOperationPendingOutput(output) ||
isOperationInProgressOutput(output) ||
isAgentPreviewOutput(output) ||
isAgentSavedOutput(output) ||
isClarificationNeededOutput(output) ||
isErrorOutput(output));
function handleClarificationAnswers(answers: Record<string, string>) {
const contextMessage = Object.entries(answers)
.map(([keyword, answer]) => `${keyword}: ${answer}`)
.join("\n");
onSend(
`I have the answers to your questions:\n\n${contextMessage}\n\nPlease proceed with editing the agent.`,
);
}
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
</div>
{hasExpandableContent && output && (
<ToolAccordion
{...getAccordionMeta(output)}
defaultExpanded={isClarificationNeededOutput(output)}
>
{(isOperationStartedOutput(output) ||
isOperationPendingOutput(output)) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
<p className="text-xs text-muted-foreground">
Operation: {output.operation_id}
</p>
<p className="text-xs italic text-muted-foreground">
Check your library in a few minutes.
</p>
</div>
)}
{isOperationInProgressOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
<p className="text-xs italic text-muted-foreground">
Please wait for the current operation to finish.
</p>
</div>
)}
{isAgentSavedOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
<div className="flex flex-wrap gap-2">
<Link
href={output.library_agent_link}
className="text-xs font-medium text-purple-600 hover:text-purple-700"
>
Open in library
</Link>
<Link
href={output.agent_page_link}
className="text-xs font-medium text-purple-600 hover:text-purple-700"
>
Open in builder
</Link>
</div>
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{truncateText(
formatMaybeJson({ agent_id: output.agent_id }),
800,
)}
</pre>
</div>
)}
{isAgentPreviewOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{output.description?.trim() && (
<p className="text-xs text-muted-foreground">
{output.description}
</p>
)}
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{truncateText(formatMaybeJson(output.agent_json), 1600)}
</pre>
</div>
)}
{isClarificationNeededOutput(output) && (
<ClarificationQuestionsWidget
questions={(output.questions ?? []).map((q) => {
const item: WidgetClarifyingQuestion = {
question: q.question,
keyword: q.keyword,
};
const example =
typeof q.example === "string" && q.example.trim()
? q.example.trim()
: null;
if (example) item.example = example;
return item;
})}
message={output.message}
onSubmitAnswers={handleClarificationAnswers}
/>
)}
{isErrorOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{output.error && (
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{formatMaybeJson(output.error)}
</pre>
)}
{output.details && (
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{formatMaybeJson(output.details)}
</pre>
)}
</div>
)}
</ToolAccordion>
)}
</div>
);
}

View File

@@ -1,183 +0,0 @@
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import type { AgentPreviewResponse } from "@/app/api/__generated__/models/agentPreviewResponse";
import type { AgentSavedResponse } from "@/app/api/__generated__/models/agentSavedResponse";
import type { ClarificationNeededResponse } from "@/app/api/__generated__/models/clarificationNeededResponse";
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
import type { OperationInProgressResponse } from "@/app/api/__generated__/models/operationInProgressResponse";
import type { OperationPendingResponse } from "@/app/api/__generated__/models/operationPendingResponse";
import type { OperationStartedResponse } from "@/app/api/__generated__/models/operationStartedResponse";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
export type EditAgentToolOutput =
| OperationStartedResponse
| OperationPendingResponse
| OperationInProgressResponse
| AgentPreviewResponse
| AgentSavedResponse
| ClarificationNeededResponse
| ErrorResponse;
function parseOutput(output: unknown): EditAgentToolOutput | null {
if (!output) return null;
if (typeof output === "string") {
const trimmed = output.trim();
if (!trimmed) return null;
try {
return parseOutput(JSON.parse(trimmed) as unknown);
} catch {
return null;
}
}
if (typeof output === "object") {
const type = (output as { type?: unknown }).type;
if (
type === ResponseType.operation_started ||
type === ResponseType.operation_pending ||
type === ResponseType.operation_in_progress ||
type === ResponseType.agent_preview ||
type === ResponseType.agent_saved ||
type === ResponseType.clarification_needed ||
type === ResponseType.error
) {
return output as EditAgentToolOutput;
}
if ("operation_id" in output && "tool_name" in output)
return output as OperationStartedResponse | OperationPendingResponse;
if ("tool_call_id" in output) return output as OperationInProgressResponse;
if ("agent_json" in output && "agent_name" in output)
return output as AgentPreviewResponse;
if ("agent_id" in output && "library_agent_id" in output)
return output as AgentSavedResponse;
if ("questions" in output) return output as ClarificationNeededResponse;
if ("error" in output || "details" in output)
return output as ErrorResponse;
}
return null;
}
export function getEditAgentToolOutput(
part: unknown,
): EditAgentToolOutput | null {
if (!part || typeof part !== "object") return null;
return parseOutput((part as { output?: unknown }).output);
}
export function isOperationStartedOutput(
output: EditAgentToolOutput,
): output is OperationStartedResponse {
return (
output.type === ResponseType.operation_started ||
("operation_id" in output && "tool_name" in output)
);
}
export function isOperationPendingOutput(
output: EditAgentToolOutput,
): output is OperationPendingResponse {
return output.type === ResponseType.operation_pending;
}
export function isOperationInProgressOutput(
output: EditAgentToolOutput,
): output is OperationInProgressResponse {
return (
output.type === ResponseType.operation_in_progress ||
"tool_call_id" in output
);
}
export function isAgentPreviewOutput(
output: EditAgentToolOutput,
): output is AgentPreviewResponse {
return output.type === ResponseType.agent_preview || "agent_json" in output;
}
export function isAgentSavedOutput(
output: EditAgentToolOutput,
): output is AgentSavedResponse {
return (
output.type === ResponseType.agent_saved || "agent_page_link" in output
);
}
export function isClarificationNeededOutput(
output: EditAgentToolOutput,
): output is ClarificationNeededResponse {
return (
output.type === ResponseType.clarification_needed || "questions" in output
);
}
export function isErrorOutput(
output: EditAgentToolOutput,
): output is ErrorResponse {
return output.type === ResponseType.error || "error" in output;
}
export function getAnimationText(part: {
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}): string {
switch (part.state) {
case "input-streaming":
return "Editing agent";
case "input-available":
return "Updating agent workflow";
case "output-available": {
const output = parseOutput(part.output);
if (!output) return "Agent updated";
if (isOperationStartedOutput(output)) return "Agent update started";
if (isOperationPendingOutput(output)) return "Agent update in progress";
if (isOperationInProgressOutput(output))
return "Agent update already in progress";
if (isAgentSavedOutput(output)) return `Saved: ${output.agent_name}`;
if (isAgentPreviewOutput(output)) return `Preview: ${output.agent_name}`;
if (isClarificationNeededOutput(output)) return "Needs clarification";
return "Error editing agent";
}
case "output-error":
return "Error editing agent";
default:
return "Processing";
}
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
}
export function formatMaybeJson(value: unknown): string {
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
export function truncateText(text: string, maxChars: number): string {
const trimmed = text.trim();
if (trimmed.length <= maxChars) return trimmed;
return `${trimmed.slice(0, maxChars).trimEnd()}`;
}

View File

@@ -1,119 +0,0 @@
"use client";
import { ToolUIPart } from "ai";
import Link from "next/link";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import {
getAgentHref,
getAnimationText,
getFindAgentsOutput,
getSourceLabelFromToolType,
isAgentsFoundOutput,
StateIcon,
} from "./helpers";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
export interface FindAgentsToolPart {
type: string;
toolCallId: string;
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}
interface Props {
part: FindAgentsToolPart;
}
export function FindAgentsTool({ part }: Props) {
const text = getAnimationText(part);
const output = getFindAgentsOutput(part);
const query =
typeof part.input === "object" && part.input !== null
? String((part.input as { query?: unknown }).query ?? "").trim()
: "";
const agentsFoundOutput =
part.state === "output-available" && output && isAgentsFoundOutput(output)
? output
: null;
const hasAgents =
!!agentsFoundOutput &&
agentsFoundOutput.agents.length > 0 &&
(typeof agentsFoundOutput.count !== "number" ||
agentsFoundOutput.count > 0);
const totalCount = agentsFoundOutput ? agentsFoundOutput.count : 0;
const { label: sourceLabel, source } = getSourceLabelFromToolType(part.type);
const scopeText =
source === "library"
? "in your library"
: source === "marketplace"
? "in marketplace"
: "";
const accordionDescription = `Found ${totalCount}${scopeText ? ` ${scopeText}` : ""}${
query ? ` for "${query}"` : ""
}`;
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
</div>
{hasAgents && agentsFoundOutput && (
<ToolAccordion
badgeText={sourceLabel}
title="Agent results"
description={accordionDescription}
>
<div className="grid gap-2 sm:grid-cols-2">
{agentsFoundOutput.agents.map((agent) => {
const href = getAgentHref(agent);
const agentSource =
agent.source === "library"
? "Library"
: agent.source === "marketplace"
? "Marketplace"
: null;
return (
<div
key={agent.id}
className="rounded-2xl border bg-background p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium text-foreground">
{agent.name}
</p>
{agentSource && (
<span className="shrink-0 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{agentSource}
</span>
)}
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{agent.description}
</p>
</div>
{href && (
<Link
href={href}
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
>
Open
</Link>
)}
</div>
</div>
);
})}
</div>
</ToolAccordion>
)}
</div>
);
}

View File

@@ -1,178 +0,0 @@
import { ToolUIPart } from "ai";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import type { AgentInfo } from "@/app/api/__generated__/models/agentInfo";
import type { AgentsFoundResponse } from "@/app/api/__generated__/models/agentsFoundResponse";
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
import type { NoResultsResponse } from "@/app/api/__generated__/models/noResultsResponse";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
export interface FindAgentInput {
query: string;
}
export type FindAgentsOutput =
| AgentsFoundResponse
| NoResultsResponse
| ErrorResponse;
export type FindAgentsToolType =
| "tool-find_agent"
| "tool-find_library_agent"
| (string & {});
function parseOutput(output: unknown): FindAgentsOutput | null {
if (!output) return null;
if (typeof output === "string") {
const trimmed = output.trim();
if (!trimmed) return null;
try {
return parseOutput(JSON.parse(trimmed) as unknown);
} catch {
return null;
}
}
if (typeof output === "object") {
const type = (output as { type?: unknown }).type;
if (
type === ResponseType.agents_found ||
type === ResponseType.no_results ||
type === ResponseType.error
) {
return output as FindAgentsOutput;
}
if ("agents" in output && "count" in output)
return output as AgentsFoundResponse;
if ("suggestions" in output && !("error" in output))
return output as NoResultsResponse;
if ("error" in output || "details" in output)
return output as ErrorResponse;
}
return null;
}
export function getFindAgentsOutput(part: unknown): FindAgentsOutput | null {
if (!part || typeof part !== "object") return null;
return parseOutput((part as { output?: unknown }).output);
}
export function isAgentsFoundOutput(
output: FindAgentsOutput,
): output is AgentsFoundResponse {
return output.type === ResponseType.agents_found || "agents" in output;
}
export function isNoResultsOutput(
output: FindAgentsOutput,
): output is NoResultsResponse {
return (
output.type === ResponseType.no_results ||
("suggestions" in output && !("error" in output))
);
}
export function isErrorOutput(
output: FindAgentsOutput,
): output is ErrorResponse {
return output.type === ResponseType.error || "error" in output;
}
export function getSourceLabelFromToolType(toolType?: FindAgentsToolType): {
source: "marketplace" | "library" | "unknown";
label: string;
} {
if (toolType === "tool-find_library_agent") {
return { source: "library", label: "Library" };
}
if (toolType === "tool-find_agent") {
return { source: "marketplace", label: "Marketplace" };
}
return { source: "unknown", label: "Agents" };
}
export function getAnimationText(part: {
type?: FindAgentsToolType;
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}): string {
const { label, source } = getSourceLabelFromToolType(part.type);
switch (part.state) {
case "input-streaming":
return `Searching ${label.toLowerCase()} agents for you`;
case "input-available": {
const query = (part.input as FindAgentInput | undefined)?.query?.trim();
if (query) {
return source === "library"
? `Finding library agents matching "${query}"`
: `Finding marketplace agents matching "${query}"`;
}
return source === "library" ? "Finding library agents" : "Finding agents";
}
case "output-available": {
const output = parseOutput(part.output);
const query = (part.input as FindAgentInput | undefined)?.query?.trim();
const scope = source === "library" ? "in your library" : "in marketplace";
if (!output) {
return query ? `Found agents ${scope} for "${query}"` : "Found agents";
}
if (isNoResultsOutput(output)) {
return query
? `No agents found ${scope} for "${query}"`
: `No agents found ${scope}`;
}
if (isAgentsFoundOutput(output)) {
const count = output.count ?? output.agents?.length ?? 0;
const countText = `Found ${count} agent${count === 1 ? "" : "s"}`;
if (query) return `${countText} ${scope} for "${query}"`;
return `${countText} ${scope}`;
}
if (isErrorOutput(output)) {
return `Error finding agents ${scope}`;
}
return `Found agents ${scope}`;
}
case "output-error":
return source === "library"
? "Error finding agents in your library"
: "Error finding agents in marketplace";
default:
return "Processing";
}
}
export function getAgentHref(agent: AgentInfo): string | null {
if (agent.source === "library") {
return `/library/agents/${encodeURIComponent(agent.id)}`;
}
const [creator, slug, ...rest] = agent.id.split("/");
if (!creator || !slug || rest.length > 0) return null;
return `/marketplace/agent/${encodeURIComponent(creator)}/${encodeURIComponent(slug)}`;
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
}

View File

@@ -1,35 +0,0 @@
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import type { BlockListResponse } from "@/app/api/__generated__/models/blockListResponse";
import { ToolUIPart } from "ai";
import { getAnimationText, StateIcon } from "./helpers";
export interface FindBlockInput {
query: string;
}
export type FindBlockOutput = BlockListResponse;
export interface FindBlockToolPart {
type: string;
toolName?: string;
toolCallId: string;
state: ToolUIPart["state"];
input?: FindBlockInput | unknown;
output?: string | FindBlockOutput | unknown;
title?: string;
}
interface Props {
part: FindBlockToolPart;
}
export function FindBlocksTool({ part }: Props) {
const text = getAnimationText(part);
return (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
</div>
);
}

View File

@@ -1,74 +0,0 @@
import { ToolUIPart } from "ai";
import type { BlockListResponse } from "@/app/api/__generated__/models/blockListResponse";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
import { FindBlockInput, FindBlockToolPart } from "./FindBlocks";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
} from "@phosphor-icons/react";
function parseOutput(output: unknown): BlockListResponse | null {
if (!output) return null;
if (typeof output === "string") {
const trimmed = output.trim();
if (!trimmed) return null;
try {
return parseOutput(JSON.parse(trimmed) as unknown);
} catch {
return null;
}
}
if (typeof output === "object") {
const type = (output as { type?: unknown }).type;
if (type === ResponseType.block_list || "blocks" in output) {
return output as BlockListResponse;
}
}
return null;
}
export function getAnimationText(part: FindBlockToolPart): string {
switch (part.state) {
case "input-streaming":
return "Searching blocks for you";
case "input-available": {
const query = (part.input as FindBlockInput).query;
return `Finding "${query}" blocks`;
}
case "output-available": {
const parsed = parseOutput(part.output);
if (parsed) {
return `Found ${parsed.count} "${(part.input as FindBlockInput).query}" blocks`;
}
return "Found blocks";
}
case "output-error":
return "Error finding blocks";
default:
return "Processing";
}
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
}

View File

@@ -1,369 +0,0 @@
"use client";
import type { ToolUIPart } from "ai";
import Link from "next/link";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
import {
ChatCredentialsSetup,
type CredentialInfo,
} from "@/components/contextual/Chat/components/ChatCredentialsSetup/ChatCredentialsSetup";
import {
formatMaybeJson,
getAnimationText,
getRunAgentToolOutput,
isRunAgentAgentDetailsOutput,
isRunAgentErrorOutput,
isRunAgentExecutionStartedOutput,
isRunAgentNeedLoginOutput,
isRunAgentSetupRequirementsOutput,
StateIcon,
type RunAgentToolOutput,
} from "./helpers";
export interface RunAgentToolPart {
type: string;
toolCallId: string;
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}
interface Props {
part: RunAgentToolPart;
}
function getAccordionMeta(output: RunAgentToolOutput): {
badgeText: string;
title: string;
description?: string;
} {
if (isRunAgentExecutionStartedOutput(output)) {
const statusText =
typeof output.status === "string" && output.status.trim()
? output.status.trim()
: "started";
return {
badgeText: "Run agent",
title: output.graph_name,
description: `Status: ${statusText}`,
};
}
if (isRunAgentAgentDetailsOutput(output)) {
return {
badgeText: "Run agent",
title: output.agent.name,
description: "Inputs required",
};
}
if (isRunAgentSetupRequirementsOutput(output)) {
const missingCredsCount = Object.keys(
(output.setup_info.user_readiness?.missing_credentials ?? {}) as Record<
string,
unknown
>,
).length;
return {
badgeText: "Run agent",
title: output.setup_info.agent_name,
description:
missingCredsCount > 0
? `Missing ${missingCredsCount} credential${missingCredsCount === 1 ? "" : "s"}`
: output.message,
};
}
if (isRunAgentNeedLoginOutput(output)) {
return { badgeText: "Run agent", title: "Sign in required" };
}
return { badgeText: "Run agent", title: "Error" };
}
function coerceMissingCredentials(
rawMissingCredentials: unknown,
): CredentialInfo[] {
const missing =
rawMissingCredentials && typeof rawMissingCredentials === "object"
? (rawMissingCredentials as Record<string, unknown>)
: {};
const validTypes = new Set([
"api_key",
"oauth2",
"user_password",
"host_scoped",
]);
const results: CredentialInfo[] = [];
Object.values(missing).forEach((value) => {
if (!value || typeof value !== "object") return;
const cred = value as Record<string, unknown>;
const provider =
typeof cred.provider === "string" ? cred.provider.trim() : "";
if (!provider) return;
const providerName =
typeof cred.provider_name === "string" && cred.provider_name.trim()
? cred.provider_name.trim()
: provider.replace(/_/g, " ");
const title =
typeof cred.title === "string" && cred.title.trim()
? cred.title.trim()
: providerName;
const types =
Array.isArray(cred.types) && cred.types.length > 0
? cred.types
: typeof cred.type === "string"
? [cred.type]
: [];
const credentialTypes = types
.map((t) => (typeof t === "string" ? t.trim() : ""))
.filter(
(t): t is "api_key" | "oauth2" | "user_password" | "host_scoped" =>
validTypes.has(t),
);
if (credentialTypes.length === 0) return;
const scopes = Array.isArray(cred.scopes)
? cred.scopes.filter((s): s is string => typeof s === "string")
: undefined;
const item: CredentialInfo = {
provider,
providerName,
credentialTypes,
title,
};
if (scopes && scopes.length > 0) {
item.scopes = scopes;
}
results.push(item);
});
return results;
}
function coerceExpectedInputs(rawInputs: unknown): Array<{
name: string;
title: string;
type: string;
description?: string;
required: boolean;
}> {
if (!Array.isArray(rawInputs)) return [];
const results: Array<{
name: string;
title: string;
type: string;
description?: string;
required: boolean;
}> = [];
rawInputs.forEach((value, index) => {
if (!value || typeof value !== "object") return;
const input = value as Record<string, unknown>;
const name =
typeof input.name === "string" && input.name.trim()
? input.name.trim()
: `input-${index}`;
const title =
typeof input.title === "string" && input.title.trim()
? input.title.trim()
: name;
const type = typeof input.type === "string" ? input.type : "unknown";
const description =
typeof input.description === "string" && input.description.trim()
? input.description.trim()
: undefined;
const required = Boolean(input.required);
const item: {
name: string;
title: string;
type: string;
description?: string;
required: boolean;
} = { name, title, type, required };
if (description) item.description = description;
results.push(item);
});
return results;
}
export function RunAgentTool({ part }: Props) {
const text = getAnimationText(part);
const { onSend } = useCopilotChatActions();
const output = getRunAgentToolOutput(part);
const hasExpandableContent =
part.state === "output-available" &&
!!output &&
(isRunAgentExecutionStartedOutput(output) ||
isRunAgentAgentDetailsOutput(output) ||
isRunAgentSetupRequirementsOutput(output) ||
isRunAgentNeedLoginOutput(output) ||
isRunAgentErrorOutput(output));
function handleAllCredentialsComplete() {
onSend(
"I've configured the required credentials. Please check if everything is ready and proceed with running the agent.",
);
}
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
</div>
{hasExpandableContent && output && (
<ToolAccordion
{...getAccordionMeta(output)}
defaultExpanded={
isRunAgentSetupRequirementsOutput(output) ||
isRunAgentAgentDetailsOutput(output)
}
>
{isRunAgentExecutionStartedOutput(output) && (
<div className="grid gap-2">
<div className="rounded-2xl border bg-background p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">
Execution started
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{output.execution_id}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{output.message}
</p>
</div>
{output.library_agent_link && (
<Link
href={output.library_agent_link}
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
>
Open
</Link>
)}
</div>
</div>
</div>
)}
{isRunAgentAgentDetailsOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{output.agent.description?.trim() && (
<p className="text-xs text-muted-foreground">
{output.agent.description}
</p>
)}
<div className="rounded-2xl border bg-background p-3">
<p className="text-xs font-medium text-foreground">Inputs</p>
<p className="mt-1 text-xs text-muted-foreground">
Provide required inputs in chat, or ask to run with defaults.
</p>
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
{formatMaybeJson(output.agent.inputs)}
</pre>
</div>
</div>
)}
{isRunAgentSetupRequirementsOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{coerceMissingCredentials(
output.setup_info.user_readiness?.missing_credentials,
).length > 0 && (
<ChatCredentialsSetup
credentials={coerceMissingCredentials(
output.setup_info.user_readiness?.missing_credentials,
)}
agentName={output.setup_info.agent_name}
message={output.message}
onAllCredentialsComplete={handleAllCredentialsComplete}
onCancel={() => {}}
/>
)}
{coerceExpectedInputs(
(output.setup_info.requirements as Record<string, unknown>)
?.inputs,
).length > 0 && (
<div className="rounded-2xl border bg-background p-3">
<p className="text-xs font-medium text-foreground">
Expected inputs
</p>
<div className="mt-2 grid gap-2">
{coerceExpectedInputs(
(
output.setup_info.requirements as Record<
string,
unknown
>
)?.inputs,
).map((input) => (
<div key={input.name} className="rounded-xl border p-2">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-xs font-medium text-foreground">
{input.title}
</p>
<span className="shrink-0 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{input.required ? "Required" : "Optional"}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{input.name} {input.type}
{input.description ? `${input.description}` : ""}
</p>
</div>
))}
</div>
</div>
)}
</div>
)}
{isRunAgentNeedLoginOutput(output) && (
<p className="text-sm text-foreground">{output.message}</p>
)}
{isRunAgentErrorOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{output.error && (
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{formatMaybeJson(output.error)}
</pre>
)}
{output.details && (
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{formatMaybeJson(output.details)}
</pre>
)}
</div>
)}
</ToolAccordion>
)}
</div>
);
}

View File

@@ -1,187 +0,0 @@
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import type { AgentDetailsResponse } from "@/app/api/__generated__/models/agentDetailsResponse";
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
import type { ExecutionStartedResponse } from "@/app/api/__generated__/models/executionStartedResponse";
import type { NeedLoginResponse } from "@/app/api/__generated__/models/needLoginResponse";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
import type { SetupRequirementsResponse } from "@/app/api/__generated__/models/setupRequirementsResponse";
export interface RunAgentInput {
username_agent_slug?: string;
library_agent_id?: string;
inputs?: Record<string, unknown>;
use_defaults?: boolean;
schedule_name?: string;
cron?: string;
timezone?: string;
}
export type RunAgentToolOutput =
| SetupRequirementsResponse
| ExecutionStartedResponse
| AgentDetailsResponse
| NeedLoginResponse
| ErrorResponse;
const RUN_AGENT_OUTPUT_TYPES = new Set<string>([
ResponseType.setup_requirements,
ResponseType.execution_started,
ResponseType.agent_details,
ResponseType.need_login,
ResponseType.error,
]);
export function isRunAgentSetupRequirementsOutput(
output: RunAgentToolOutput,
): output is SetupRequirementsResponse {
return (
output.type === ResponseType.setup_requirements ||
("setup_info" in output && typeof output.setup_info === "object")
);
}
export function isRunAgentExecutionStartedOutput(
output: RunAgentToolOutput,
): output is ExecutionStartedResponse {
return (
output.type === ResponseType.execution_started || "execution_id" in output
);
}
export function isRunAgentAgentDetailsOutput(
output: RunAgentToolOutput,
): output is AgentDetailsResponse {
return output.type === ResponseType.agent_details || "agent" in output;
}
export function isRunAgentNeedLoginOutput(
output: RunAgentToolOutput,
): output is NeedLoginResponse {
return output.type === ResponseType.need_login;
}
export function isRunAgentErrorOutput(
output: RunAgentToolOutput,
): output is ErrorResponse {
return output.type === ResponseType.error || "error" in output;
}
function parseOutput(output: unknown): RunAgentToolOutput | null {
if (!output) return null;
if (typeof output === "string") {
const trimmed = output.trim();
if (!trimmed) return null;
try {
return parseOutput(JSON.parse(trimmed) as unknown);
} catch {
return null;
}
}
if (typeof output === "object") {
const type = (output as { type?: unknown }).type;
if (typeof type === "string" && RUN_AGENT_OUTPUT_TYPES.has(type)) {
return output as RunAgentToolOutput;
}
if ("execution_id" in output) return output as ExecutionStartedResponse;
if ("setup_info" in output) return output as SetupRequirementsResponse;
if ("agent" in output) return output as AgentDetailsResponse;
if ("error" in output || "details" in output)
return output as ErrorResponse;
if (type === ResponseType.need_login) return output as NeedLoginResponse;
}
return null;
}
export function getRunAgentToolOutput(
part: unknown,
): RunAgentToolOutput | null {
if (!part || typeof part !== "object") return null;
return parseOutput((part as { output?: unknown }).output);
}
function getAgentIdentifierText(
input: RunAgentInput | undefined,
): string | null {
if (!input) return null;
const slug = input.username_agent_slug?.trim();
if (slug) return slug;
const libraryId = input.library_agent_id?.trim();
if (libraryId) return `Library agent ${libraryId}`;
return null;
}
function getExecutionModeText(input: RunAgentInput | undefined): string | null {
if (!input) return null;
const isSchedule = Boolean(input.schedule_name?.trim() || input.cron?.trim());
return isSchedule ? "Scheduled run" : "Run";
}
export function getAnimationText(part: {
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}): string {
const input = part.input as RunAgentInput | undefined;
const agentIdentifier = getAgentIdentifierText(input);
const mode = getExecutionModeText(input);
switch (part.state) {
case "input-streaming":
return "Preparing to run agent";
case "input-available":
return agentIdentifier ? `${mode}: ${agentIdentifier}` : "Running agent";
case "output-available": {
const output = parseOutput(part.output);
if (!output) return "Agent run updated";
if (isRunAgentExecutionStartedOutput(output)) {
return `Started: ${output.graph_name}`;
}
if (isRunAgentAgentDetailsOutput(output)) {
return `Agent inputs: ${output.agent.name}`;
}
if (isRunAgentSetupRequirementsOutput(output)) {
return `Needs setup: ${output.setup_info.agent_name}`;
}
if (isRunAgentNeedLoginOutput(output))
return "Sign in required to run agent";
return "Error running agent";
}
case "output-error":
return "Error running agent";
default:
return "Processing";
}
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
}
export function formatMaybeJson(value: unknown): string {
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}

View File

@@ -1,317 +0,0 @@
"use client";
import type { ToolUIPart } from "ai";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
import {
ChatCredentialsSetup,
type CredentialInfo,
} from "@/components/contextual/Chat/components/ChatCredentialsSetup/ChatCredentialsSetup";
import {
formatMaybeJson,
getAnimationText,
getRunBlockToolOutput,
isRunBlockBlockOutput,
isRunBlockErrorOutput,
isRunBlockSetupRequirementsOutput,
StateIcon,
type RunBlockToolOutput,
} from "./helpers";
export interface RunBlockToolPart {
type: string;
toolCallId: string;
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}
interface Props {
part: RunBlockToolPart;
}
function getAccordionMeta(output: RunBlockToolOutput): {
badgeText: string;
title: string;
description?: string;
} {
if (isRunBlockBlockOutput(output)) {
const keys = Object.keys(output.outputs ?? {});
return {
badgeText: "Run block",
title: output.block_name,
description:
keys.length > 0
? `${keys.length} output key${keys.length === 1 ? "" : "s"}`
: output.message,
};
}
if (isRunBlockSetupRequirementsOutput(output)) {
const missingCredsCount = Object.keys(
(output.setup_info.user_readiness?.missing_credentials ?? {}) as Record<
string,
unknown
>,
).length;
return {
badgeText: "Run block",
title: output.setup_info.agent_name,
description:
missingCredsCount > 0
? `Missing ${missingCredsCount} credential${missingCredsCount === 1 ? "" : "s"}`
: output.message,
};
}
return { badgeText: "Run block", title: "Error" };
}
function coerceMissingCredentials(
rawMissingCredentials: unknown,
): CredentialInfo[] {
const missing =
rawMissingCredentials && typeof rawMissingCredentials === "object"
? (rawMissingCredentials as Record<string, unknown>)
: {};
const validTypes = new Set([
"api_key",
"oauth2",
"user_password",
"host_scoped",
]);
const results: CredentialInfo[] = [];
Object.values(missing).forEach((value) => {
if (!value || typeof value !== "object") return;
const cred = value as Record<string, unknown>;
const provider =
typeof cred.provider === "string" ? cred.provider.trim() : "";
if (!provider) return;
const providerName =
typeof cred.provider_name === "string" && cred.provider_name.trim()
? cred.provider_name.trim()
: provider.replace(/_/g, " ");
const title =
typeof cred.title === "string" && cred.title.trim()
? cred.title.trim()
: providerName;
const types =
Array.isArray(cred.types) && cred.types.length > 0
? cred.types
: typeof cred.type === "string"
? [cred.type]
: [];
const credentialTypes = types
.map((t) => (typeof t === "string" ? t.trim() : ""))
.filter(
(t): t is "api_key" | "oauth2" | "user_password" | "host_scoped" =>
validTypes.has(t),
);
if (credentialTypes.length === 0) return;
const scopes = Array.isArray(cred.scopes)
? cred.scopes.filter((s): s is string => typeof s === "string")
: undefined;
const item: CredentialInfo = {
provider,
providerName,
credentialTypes,
title,
};
if (scopes && scopes.length > 0) {
item.scopes = scopes;
}
results.push(item);
});
return results;
}
function coerceExpectedInputs(rawInputs: unknown): Array<{
name: string;
title: string;
type: string;
description?: string;
required: boolean;
}> {
if (!Array.isArray(rawInputs)) return [];
const results: Array<{
name: string;
title: string;
type: string;
description?: string;
required: boolean;
}> = [];
rawInputs.forEach((value, index) => {
if (!value || typeof value !== "object") return;
const input = value as Record<string, unknown>;
const name =
typeof input.name === "string" && input.name.trim()
? input.name.trim()
: `input-${index}`;
const title =
typeof input.title === "string" && input.title.trim()
? input.title.trim()
: name;
const type = typeof input.type === "string" ? input.type : "unknown";
const description =
typeof input.description === "string" && input.description.trim()
? input.description.trim()
: undefined;
const required = Boolean(input.required);
const item: {
name: string;
title: string;
type: string;
description?: string;
required: boolean;
} = { name, title, type, required };
if (description) item.description = description;
results.push(item);
});
return results;
}
export function RunBlockTool({ part }: Props) {
const text = getAnimationText(part);
const { onSend } = useCopilotChatActions();
const output = getRunBlockToolOutput(part);
const hasExpandableContent =
part.state === "output-available" &&
!!output &&
(isRunBlockBlockOutput(output) ||
isRunBlockSetupRequirementsOutput(output) ||
isRunBlockErrorOutput(output));
function handleAllCredentialsComplete() {
onSend(
"I've configured the required credentials. Please re-run the block now.",
);
}
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
</div>
{hasExpandableContent && output && (
<ToolAccordion
{...getAccordionMeta(output)}
defaultExpanded={isRunBlockSetupRequirementsOutput(output)}
>
{isRunBlockBlockOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{Object.entries(output.outputs ?? {}).map(([key, items]) => (
<div key={key} className="rounded-2xl border bg-background p-3">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-xs font-medium text-foreground">
{key}
</p>
<span className="shrink-0 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{items.length} item{items.length === 1 ? "" : "s"}
</span>
</div>
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
{formatMaybeJson(items.slice(0, 3))}
</pre>
</div>
))}
</div>
)}
{isRunBlockSetupRequirementsOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{coerceMissingCredentials(
output.setup_info.user_readiness?.missing_credentials,
).length > 0 && (
<ChatCredentialsSetup
credentials={coerceMissingCredentials(
output.setup_info.user_readiness?.missing_credentials,
)}
agentName={output.setup_info.agent_name}
message={output.message}
onAllCredentialsComplete={handleAllCredentialsComplete}
onCancel={() => {}}
/>
)}
{coerceExpectedInputs(
(output.setup_info.requirements as Record<string, unknown>)
?.inputs,
).length > 0 && (
<div className="rounded-2xl border bg-background p-3">
<p className="text-xs font-medium text-foreground">
Expected inputs
</p>
<div className="mt-2 grid gap-2">
{coerceExpectedInputs(
(
output.setup_info.requirements as Record<
string,
unknown
>
)?.inputs,
).map((input) => (
<div key={input.name} className="rounded-xl border p-2">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-xs font-medium text-foreground">
{input.title}
</p>
<span className="shrink-0 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{input.required ? "Required" : "Optional"}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{input.name} {input.type}
{input.description ? `${input.description}` : ""}
</p>
</div>
))}
</div>
</div>
)}
</div>
)}
{isRunBlockErrorOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{output.error && (
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{formatMaybeJson(output.error)}
</pre>
)}
{output.details && (
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{formatMaybeJson(output.details)}
</pre>
)}
</div>
)}
</ToolAccordion>
)}
</div>
);
}

View File

@@ -1,142 +0,0 @@
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import type { BlockOutputResponse } from "@/app/api/__generated__/models/blockOutputResponse";
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
import type { SetupRequirementsResponse } from "@/app/api/__generated__/models/setupRequirementsResponse";
export interface RunBlockInput {
block_id?: string;
input_data?: Record<string, unknown>;
}
export type RunBlockToolOutput =
| SetupRequirementsResponse
| BlockOutputResponse
| ErrorResponse;
const RUN_BLOCK_OUTPUT_TYPES = new Set<string>([
ResponseType.setup_requirements,
ResponseType.block_output,
ResponseType.error,
]);
export function isRunBlockSetupRequirementsOutput(
output: RunBlockToolOutput,
): output is SetupRequirementsResponse {
return (
output.type === ResponseType.setup_requirements ||
("setup_info" in output && typeof output.setup_info === "object")
);
}
export function isRunBlockBlockOutput(
output: RunBlockToolOutput,
): output is BlockOutputResponse {
return output.type === ResponseType.block_output || "block_id" in output;
}
export function isRunBlockErrorOutput(
output: RunBlockToolOutput,
): output is ErrorResponse {
return output.type === ResponseType.error || "error" in output;
}
function parseOutput(output: unknown): RunBlockToolOutput | null {
if (!output) return null;
if (typeof output === "string") {
const trimmed = output.trim();
if (!trimmed) return null;
try {
return parseOutput(JSON.parse(trimmed) as unknown);
} catch {
return null;
}
}
if (typeof output === "object") {
const type = (output as { type?: unknown }).type;
if (typeof type === "string" && RUN_BLOCK_OUTPUT_TYPES.has(type)) {
return output as RunBlockToolOutput;
}
if ("block_id" in output) return output as BlockOutputResponse;
if ("setup_info" in output) return output as SetupRequirementsResponse;
if ("error" in output || "details" in output)
return output as ErrorResponse;
}
return null;
}
export function getRunBlockToolOutput(
part: unknown,
): RunBlockToolOutput | null {
if (!part || typeof part !== "object") return null;
return parseOutput((part as { output?: unknown }).output);
}
function getBlockLabel(input: RunBlockInput | undefined): string | null {
const blockId = input?.block_id?.trim();
if (!blockId) return null;
return `Block ${blockId.slice(0, 8)}`;
}
export function getAnimationText(part: {
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}): string {
const input = part.input as RunBlockInput | undefined;
const blockLabel = getBlockLabel(input);
switch (part.state) {
case "input-streaming":
return "Preparing to run block";
case "input-available":
return blockLabel ? `Running ${blockLabel}` : "Running block";
case "output-available": {
const output = parseOutput(part.output);
if (!output) return "Block run updated";
if (isRunBlockBlockOutput(output))
return `Block ran: ${output.block_name}`;
if (isRunBlockSetupRequirementsOutput(output)) {
return `Needs setup: ${output.setup_info.agent_name}`;
}
return "Error running block";
}
case "output-error":
return "Error running block";
default:
return "Processing";
}
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
}
export function formatMaybeJson(value: unknown): string {
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}

View File

@@ -1,186 +0,0 @@
"use client";
import type { ToolUIPart } from "ai";
import Link from "next/link";
import { useMemo } from "react";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import {
getDocsToolOutput,
getDocsToolTitle,
getToolLabel,
getAnimationText,
isDocPageOutput,
isDocSearchResultsOutput,
isErrorOutput,
isNoResultsOutput,
StateIcon,
toDocsUrl,
type DocsToolType,
} from "./helpers";
export interface DocsToolPart {
type: DocsToolType;
toolCallId: string;
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}
interface Props {
part: DocsToolPart;
}
function truncate(text: string, maxChars: number): string {
const trimmed = text.trim();
if (trimmed.length <= maxChars) return trimmed;
return `${trimmed.slice(0, maxChars).trimEnd()}`;
}
export function SearchDocsTool({ part }: Props) {
const output = getDocsToolOutput(part);
const text = getAnimationText(part);
const normalized = useMemo(() => {
if (!output) return null;
const title = getDocsToolTitle(part.type, output);
const label = getToolLabel(part.type);
return { title, label };
}, [output, part.type]);
const isOutputAvailable = part.state === "output-available" && !!output;
const docSearchOutput =
isOutputAvailable && output && isDocSearchResultsOutput(output)
? output
: null;
const docPageOutput =
isOutputAvailable && output && isDocPageOutput(output) ? output : null;
const noResultsOutput =
isOutputAvailable && output && isNoResultsOutput(output) ? output : null;
const errorOutput =
isOutputAvailable && output && isErrorOutput(output) ? output : null;
const hasExpandableContent =
isOutputAvailable &&
((!!docSearchOutput && docSearchOutput.count > 0) ||
!!docPageOutput ||
!!noResultsOutput ||
!!errorOutput);
const accordionDescription =
hasExpandableContent && docSearchOutput
? `Found ${docSearchOutput.count} result${docSearchOutput.count === 1 ? "" : "s"} for "${docSearchOutput.query}"`
: hasExpandableContent && docPageOutput
? docPageOutput.path
: hasExpandableContent && (noResultsOutput || errorOutput)
? ((noResultsOutput ?? errorOutput)?.message ?? null)
: null;
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
</div>
{hasExpandableContent && normalized && (
<ToolAccordion
badgeText={normalized.label}
title={normalized.title}
description={accordionDescription}
>
{docSearchOutput && (
<div className="grid gap-2">
{docSearchOutput.results.map((r) => {
const href = r.doc_url ?? toDocsUrl(r.path);
return (
<div
key={r.path}
className="rounded-2xl border bg-background p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{r.title}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{r.path}
{r.section ? `${r.section}` : ""}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{truncate(r.snippet, 240)}
</p>
</div>
<Link
href={href}
target="_blank"
rel="noreferrer"
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
>
Open
</Link>
</div>
</div>
);
})}
</div>
)}
{docPageOutput && (
<div>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{docPageOutput.title}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{docPageOutput.path}
</p>
</div>
<Link
href={docPageOutput.doc_url ?? toDocsUrl(docPageOutput.path)}
target="_blank"
rel="noreferrer"
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
>
Open
</Link>
</div>
<p className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
{truncate(docPageOutput.content, 800)}
</p>
</div>
)}
{noResultsOutput && (
<div>
<p className="text-sm text-foreground">
{noResultsOutput.message}
</p>
{noResultsOutput.suggestions &&
noResultsOutput.suggestions.length > 0 && (
<ul className="mt-2 list-disc space-y-1 pl-5 text-xs text-muted-foreground">
{noResultsOutput.suggestions.slice(0, 5).map((s) => (
<li key={s}>{s}</li>
))}
</ul>
)}
</div>
)}
{errorOutput && (
<div>
<p className="text-sm text-foreground">{errorOutput.message}</p>
{errorOutput.error && (
<p className="mt-2 text-xs text-muted-foreground">
{errorOutput.error}
</p>
)}
</div>
)}
</ToolAccordion>
)}
</div>
);
}

View File

@@ -1,210 +0,0 @@
import { ToolUIPart } from "ai";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import type { DocPageResponse } from "@/app/api/__generated__/models/docPageResponse";
import type { DocSearchResultsResponse } from "@/app/api/__generated__/models/docSearchResultsResponse";
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
import type { NoResultsResponse } from "@/app/api/__generated__/models/noResultsResponse";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
export interface SearchDocsInput {
query: string;
}
export interface GetDocPageInput {
path: string;
}
export type DocsToolOutput =
| DocSearchResultsResponse
| DocPageResponse
| NoResultsResponse
| ErrorResponse;
export type DocsToolType = "tool-search_docs" | "tool-get_doc_page" | string;
export function getToolLabel(toolType: DocsToolType): string {
switch (toolType) {
case "tool-search_docs":
return "Docs";
case "tool-get_doc_page":
return "Docs page";
default:
return "Docs";
}
}
function parseOutput(output: unknown): DocsToolOutput | null {
if (!output) return null;
if (typeof output === "string") {
const trimmed = output.trim();
if (!trimmed) return null;
try {
return parseOutput(JSON.parse(trimmed) as unknown);
} catch {
return null;
}
}
if (typeof output === "object") {
const type = (output as { type?: unknown }).type;
if (
type === ResponseType.doc_search_results ||
type === ResponseType.doc_page ||
type === ResponseType.no_results ||
type === ResponseType.error
) {
return output as DocsToolOutput;
}
if ("results" in output && "query" in output)
return output as DocSearchResultsResponse;
if ("content" in output && "path" in output)
return output as DocPageResponse;
if ("suggestions" in output && !("error" in output))
return output as NoResultsResponse;
if ("error" in output || "details" in output)
return output as ErrorResponse;
}
return null;
}
export function getDocsToolOutput(part: unknown): DocsToolOutput | null {
if (!part || typeof part !== "object") return null;
return parseOutput((part as { output?: unknown }).output);
}
export function isDocSearchResultsOutput(
output: DocsToolOutput,
): output is DocSearchResultsResponse {
return output.type === ResponseType.doc_search_results || "results" in output;
}
export function isDocPageOutput(
output: DocsToolOutput,
): output is DocPageResponse {
return output.type === ResponseType.doc_page || "content" in output;
}
export function isNoResultsOutput(
output: DocsToolOutput,
): output is NoResultsResponse {
return (
output.type === ResponseType.no_results ||
("suggestions" in output && !("error" in output))
);
}
export function isErrorOutput(output: DocsToolOutput): output is ErrorResponse {
return output.type === ResponseType.error || "error" in output;
}
export function getDocsToolTitle(
toolType: DocsToolType,
output: DocsToolOutput,
): string {
if (toolType === "tool-search_docs") {
if (isDocSearchResultsOutput(output)) return "Documentation results";
if (isNoResultsOutput(output)) return "No documentation found";
return "Documentation search error";
}
if (isDocPageOutput(output)) return "Documentation page";
if (isNoResultsOutput(output)) return "No documentation found";
return "Documentation page error";
}
export function getAnimationText(part: {
type: DocsToolType;
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}): string {
switch (part.type) {
case "tool-search_docs": {
switch (part.state) {
case "input-streaming":
return "Searching docs for you";
case "input-available": {
const query = (
part.input as SearchDocsInput | undefined
)?.query?.trim();
return query ? `Searching docs for "${query}"` : "Searching docs";
}
case "output-available": {
const output = parseOutput(part.output);
const query = (
part.input as SearchDocsInput | undefined
)?.query?.trim();
if (!output) return "Found documentation";
if (isDocSearchResultsOutput(output)) {
const count = output.count ?? output.results.length;
return query
? `Found ${count} doc result${count === 1 ? "" : "s"} for "${query}"`
: `Found ${count} doc result${count === 1 ? "" : "s"}`;
}
if (isNoResultsOutput(output)) {
return query ? `No docs found for "${query}"` : "No docs found";
}
return "Error searching docs";
}
case "output-error":
return "Error searching docs";
default:
return "Processing";
}
}
case "tool-get_doc_page": {
switch (part.state) {
case "input-streaming":
return "Loading documentation page";
case "input-available": {
const path = (
part.input as GetDocPageInput | undefined
)?.path?.trim();
return path ? `Loading "${path}"` : "Loading documentation page";
}
case "output-available": {
const output = parseOutput(part.output);
if (!output) return "Loaded documentation page";
if (isDocPageOutput(output)) return `Loaded "${output.title}"`;
if (isNoResultsOutput(output)) return "Documentation page not found";
return "Error loading documentation page";
}
case "output-error":
return "Error loading documentation page";
default:
return "Processing";
}
}
}
return "Processing";
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
}
export function toDocsUrl(path: string): string {
const urlPath = path.includes(".")
? path.slice(0, path.lastIndexOf("."))
: path;
return `https://docs.agpt.co/${urlPath}`;
}

View File

@@ -1,174 +0,0 @@
"use client";
import type { ToolUIPart } from "ai";
import Link from "next/link";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import {
formatMaybeJson,
getAnimationText,
getViewAgentOutputToolOutput,
isAgentOutputResponse,
isErrorResponse,
isNoResultsResponse,
StateIcon,
type ViewAgentOutputToolOutput,
} from "./helpers";
export interface ViewAgentOutputToolPart {
type: string;
toolCallId: string;
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}
interface Props {
part: ViewAgentOutputToolPart;
}
function getAccordionMeta(output: ViewAgentOutputToolOutput): {
badgeText: string;
title: string;
description?: string;
} {
if (isAgentOutputResponse(output)) {
const status = output.execution?.status;
return {
badgeText: "Agent output",
title: output.agent_name,
description: status ? `Status: ${status}` : output.message,
};
}
if (isNoResultsResponse(output)) {
return { badgeText: "Agent output", title: "No results" };
}
return { badgeText: "Agent output", title: "Error" };
}
export function ViewAgentOutputTool({ part }: Props) {
const text = getAnimationText(part);
const output = getViewAgentOutputToolOutput(part);
const hasExpandableContent =
part.state === "output-available" &&
!!output &&
(isAgentOutputResponse(output) ||
isNoResultsResponse(output) ||
isErrorResponse(output));
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
</div>
{hasExpandableContent && output && (
<ToolAccordion {...getAccordionMeta(output)}>
{isAgentOutputResponse(output) && (
<div className="grid gap-2">
<div className="flex items-start justify-between gap-3">
<p className="text-sm text-foreground">{output.message}</p>
{output.library_agent_link && (
<Link
href={output.library_agent_link}
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
>
Open
</Link>
)}
</div>
{output.execution ? (
<div className="grid gap-2">
<div className="rounded-2xl border bg-background p-3">
<p className="text-xs font-medium text-foreground">
Execution
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
{output.execution.execution_id}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Status: {output.execution.status}
</p>
</div>
{output.execution.inputs_summary && (
<div className="rounded-2xl border bg-background p-3">
<p className="text-xs font-medium text-foreground">
Inputs summary
</p>
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
{formatMaybeJson(output.execution.inputs_summary)}
</pre>
</div>
)}
{Object.entries(output.execution.outputs ?? {}).map(
([key, items]) => (
<div
key={key}
className="rounded-2xl border bg-background p-3"
>
<div className="flex items-center justify-between gap-2">
<p className="truncate text-xs font-medium text-foreground">
{key}
</p>
<span className="shrink-0 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{items.length} item{items.length === 1 ? "" : "s"}
</span>
</div>
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
{formatMaybeJson(items.slice(0, 3))}
</pre>
</div>
),
)}
</div>
) : (
<div className="rounded-2xl border bg-background p-3">
<p className="text-sm text-foreground">
No execution selected.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Try asking for a specific run or execution_id.
</p>
</div>
)}
</div>
)}
{isNoResultsResponse(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{output.suggestions && output.suggestions.length > 0 && (
<ul className="mt-1 list-disc space-y-1 pl-5 text-xs text-muted-foreground">
{output.suggestions.slice(0, 5).map((s) => (
<li key={s}>{s}</li>
))}
</ul>
)}
</div>
)}
{isErrorResponse(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{output.error && (
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{formatMaybeJson(output.error)}
</pre>
)}
{output.details && (
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{formatMaybeJson(output.details)}
</pre>
)}
</div>
)}
</ToolAccordion>
)}
</div>
);
}

View File

@@ -1,155 +0,0 @@
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import type { AgentOutputResponse } from "@/app/api/__generated__/models/agentOutputResponse";
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
import type { NoResultsResponse } from "@/app/api/__generated__/models/noResultsResponse";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
export interface ViewAgentOutputInput {
agent_name?: string;
library_agent_id?: string;
store_slug?: string;
execution_id?: string;
run_time?: string;
}
export type ViewAgentOutputToolOutput =
| AgentOutputResponse
| NoResultsResponse
| ErrorResponse;
function parseOutput(output: unknown): ViewAgentOutputToolOutput | null {
if (!output) return null;
if (typeof output === "string") {
const trimmed = output.trim();
if (!trimmed) return null;
try {
return parseOutput(JSON.parse(trimmed) as unknown);
} catch {
return null;
}
}
if (typeof output === "object") {
const type = (output as { type?: unknown }).type;
if (
type === ResponseType.agent_output ||
type === ResponseType.no_results ||
type === ResponseType.error
) {
return output as ViewAgentOutputToolOutput;
}
if ("agent_id" in output && "agent_name" in output) {
return output as AgentOutputResponse;
}
if ("suggestions" in output && !("error" in output)) {
return output as NoResultsResponse;
}
if ("error" in output || "details" in output)
return output as ErrorResponse;
}
return null;
}
export function isAgentOutputResponse(
output: ViewAgentOutputToolOutput,
): output is AgentOutputResponse {
return output.type === ResponseType.agent_output || "agent_id" in output;
}
export function isNoResultsResponse(
output: ViewAgentOutputToolOutput,
): output is NoResultsResponse {
return (
output.type === ResponseType.no_results ||
("suggestions" in output && !("error" in output))
);
}
export function isErrorResponse(
output: ViewAgentOutputToolOutput,
): output is ErrorResponse {
return output.type === ResponseType.error || "error" in output;
}
export function getViewAgentOutputToolOutput(
part: unknown,
): ViewAgentOutputToolOutput | null {
if (!part || typeof part !== "object") return null;
return parseOutput((part as { output?: unknown }).output);
}
function getAgentIdentifierText(
input: ViewAgentOutputInput | undefined,
): string | null {
if (!input) return null;
const libraryId = input.library_agent_id?.trim();
if (libraryId) return `Library agent ${libraryId}`;
const slug = input.store_slug?.trim();
if (slug) return slug;
const name = input.agent_name?.trim();
if (name) return name;
return null;
}
export function getAnimationText(part: {
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}): string {
const input = part.input as ViewAgentOutputInput | undefined;
const agent = getAgentIdentifierText(input);
switch (part.state) {
case "input-streaming":
return "Looking up agent outputs";
case "input-available":
return agent ? `Loading outputs: ${agent}` : "Loading agent outputs";
case "output-available": {
const output = parseOutput(part.output);
if (!output) return "Loaded agent outputs";
if (isAgentOutputResponse(output)) {
if (output.execution)
return `Loaded output (${output.execution.status})`;
return "Loaded agent outputs";
}
if (isNoResultsResponse(output)) return "No outputs found";
return "Error loading agent output";
}
case "output-error":
return "Error loading agent output";
default:
return "Processing";
}
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
}
export function formatMaybeJson(value: unknown): string {
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}

View File

@@ -1,7 +1,6 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@source "../node_modules/streamdown/dist/*.js";
@layer base {
:root {
@@ -30,14 +29,6 @@
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
@@ -65,14 +56,6 @@
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
* {

View File

@@ -1,104 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ArrowDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className,
)}
{...props}
>
{children ?? (
<>
{icon && (
<div className="text-neutral-500 dark:text-neutral-400">{icon}</div>
)}
<div className="space-y-1">
<h3 className="text-sm font-medium">{title}</h3>
{description && (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{description}
</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-white dark:dark:bg-neutral-950 dark:dark:hover:bg-neutral-800 dark:hover:bg-neutral-100",
className,
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};

View File

@@ -1,338 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className,
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-full min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:w-fit",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-neutral-100 group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-neutral-950 dark:group-[.is-user]:bg-neutral-800 dark:group-[.is-user]:text-neutral-50",
"group-[.is-assistant]:text-neutral-950 dark:group-[.is-assistant]:text-neutral-50",
className,
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
interface MessageBranchContextType {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
}
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null,
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error("MessageBranch components must be used within");
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
const goToPrevious = () => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
};
const goToNext = () => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
};
const contextValue: MessageBranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
};
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({
children,
...props
}: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = Array.isArray(children) ? children : [children];
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden",
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const MessageBranchSelector = ({
className,
from: _from,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className={cn(
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
className,
)}
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-neutral-500 shadow-none dark:text-neutral-400",
className,
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className,
)}
plugins={{ code, mermaid, math, cjk }}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children,
);
MessageResponse.displayName = "MessageResponse";
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className,
)}
{...props}
>
{children}
</div>
);

View File

@@ -11,13 +11,12 @@ import { useChatInput } from "./useChatInput";
import { useVoiceRecording } from "./useVoiceRecording";
export interface Props {
onSend: (message: string) => void | Promise<void>;
onSend: (message: string) => void;
disabled?: boolean;
isStreaming?: boolean;
onStop?: () => void;
placeholder?: string;
className?: string;
inputId?: string;
}
export function ChatInput({
@@ -27,7 +26,6 @@ export function ChatInput({
onStop,
placeholder = "Type your message...",
className,
inputId = "chat-input",
}: Props) {
const inputId = "chat-input";
const {

View File

@@ -21,7 +21,6 @@ export function useChatInput({
}: Args) {
const [value, setValue] = useState("");
const [hasMultipleLines, setHasMultipleLines] = useState(false);
const [isSending, setIsSending] = useState(false);
useEffect(
function focusOnMount() {
@@ -101,40 +100,34 @@ export function useChatInput({
}
}, [value, maxRows, inputId]);
async function handleSend() {
if (disabled || isSending || !value.trim()) return;
setIsSending(true);
try {
await onSend(value.trim());
setValue("");
setHasMultipleLines(false);
const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
const wrapper = document.getElementById(
`${inputId}-wrapper`,
) as HTMLDivElement;
if (textarea) {
textarea.style.height = "auto";
}
if (wrapper) {
wrapper.style.height = "";
wrapper.style.maxHeight = "";
}
} finally {
setIsSending(false);
const handleSend = () => {
if (disabled || !value.trim()) return;
onSend(value.trim());
setValue("");
setHasMultipleLines(false);
const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
const wrapper = document.getElementById(
`${inputId}-wrapper`,
) as HTMLDivElement;
if (textarea) {
textarea.style.height = "auto";
}
}
if (wrapper) {
wrapper.style.height = "";
wrapper.style.maxHeight = "";
}
};
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void handleSend();
handleSend();
}
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
void handleSend();
handleSend();
}
function handleChange(e: ChangeEvent<HTMLTextAreaElement>) {
@@ -149,6 +142,5 @@ export function useChatInput({
handleSubmit,
handleChange,
hasMultipleLines,
isSending,
};
}

View File

@@ -62,7 +62,7 @@ export function Navbar() {
<PreviewBanner branchName={previewBranchName} />
) : null}
<nav
className="inline-flex w-full items-center border border-none bg-[#FAFAFA] p-3 backdrop-blur-[26px]"
className="border-zinc-[#EFEFF0] inline-flex w-full items-center border border-[#EFEFF0] bg-[#F3F4F6]/20 p-3 backdrop-blur-[26px]"
style={{ height: NAVBAR_HEIGHT_PX }}
>
{/* Left section */}

View File

@@ -1,83 +0,0 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
const buttonGroupVariants = cva(
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
},
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "div";
return (
<Comp
className={cn(
"shadow-xs flex items-center gap-2 rounded-md border border-neutral-200 bg-neutral-100 px-4 text-sm font-medium dark:border-neutral-800 dark:bg-neutral-800 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className,
)}
{...props}
/>
);
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative !m-0 self-stretch bg-neutral-200 data-[orientation=vertical]:h-auto dark:bg-neutral-800",
className,
)}
{...props}
/>
);
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};

View File

@@ -1,59 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-neutral-300",
{
variants: {
variant: {
default:
"bg-neutral-900 text-neutral-50 shadow hover:bg-neutral-900/90 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90",
destructive:
"bg-red-500 text-neutral-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/90",
outline:
"border border-neutral-200 bg-white shadow-sm hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
secondary:
"bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80",
ghost:
"hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
link: "text-neutral-900 underline-offset-4 hover:underline dark:text-neutral-50",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
"icon-sm": "h-8 w-8",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -1,22 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-neutral-200 bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-neutral-950 placeholder:text-neutral-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-800 dark:file:text-neutral-50 dark:placeholder:text-neutral-400 dark:focus-visible:ring-neutral-300 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -1,31 +0,0 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-neutral-200 dark:bg-neutral-800",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -1,143 +0,0 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-white p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-neutral-950",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity data-[state=open]:bg-neutral-100 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none dark:ring-offset-neutral-950 dark:data-[state=open]:bg-neutral-800 dark:focus:ring-neutral-300">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold text-neutral-950 dark:text-neutral-50",
className,
)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-neutral-500 dark:text-neutral-400", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -1,781 +0,0 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "20rem";
const SIDEBAR_WIDTH_MOBILE = "20rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref,
) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
},
);
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref,
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
},
);
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
});
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 hover:after:bg-sidebar-border sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
});
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-white dark:bg-neutral-950",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className,
)}
{...props}
/>
);
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-white shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring dark:bg-neutral-950",
className,
)}
{...props}
/>
);
});
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
});
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
));
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
));
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-white shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))] dark:bg-neutral-950",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
},
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
));
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
));
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />);
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -1,18 +0,0 @@
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"animate-pulse rounded-md bg-neutral-900/10 dark:bg-neutral-50/10",
className,
)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -1,32 +0,0 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md bg-neutral-900 px-3 py-1.5 text-xs text-neutral-50 animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:bg-neutral-50 dark:text-neutral-900",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -1,21 +0,0 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

View File

@@ -33,7 +33,7 @@ const defaultFlags = {
[Flag.AGENT_FAVORITING]: false,
[Flag.MARKETPLACE_SEARCH_TERMS]: DEFAULT_SEARCH_TERMS,
[Flag.ENABLE_PLATFORM_PAYMENT]: false,
[Flag.CHAT]: true,
[Flag.CHAT]: false,
};
type FlagValues = typeof defaultFlags;

View File

@@ -65,16 +65,6 @@ const config = {
"600": "#282828",
"700": "#272727",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
},
spacing: {
"0": "0rem",