diff --git a/agentex-sgp-app/.dockerignore b/agentex-sgp-app/.dockerignore index 3811faa..a6d87b0 100644 --- a/agentex-sgp-app/.dockerignore +++ b/agentex-sgp-app/.dockerignore @@ -30,6 +30,9 @@ out/ ehthumbs.db Thumbs.db +# Claude +.claude/ + # Editor directories and files .vscode/ .idea/ diff --git a/agentex-sgp-app/.gitignore b/agentex-sgp-app/.gitignore index 5ef6a52..75d23b6 100644 --- a/agentex-sgp-app/.gitignore +++ b/agentex-sgp-app/.gitignore @@ -13,6 +13,9 @@ # testing /coverage +# claude +.claude/ + # next.js /.next/ /out/ diff --git a/agentex-sgp-app/.prettierrc.json b/agentex-sgp-app/.prettierrc.json index 7225824..d7048de 100644 --- a/agentex-sgp-app/.prettierrc.json +++ b/agentex-sgp-app/.prettierrc.json @@ -4,5 +4,8 @@ "singleQuote": true, "printWidth": 80, "tabWidth": 2, - "useTabs": false + "bracketSameLine": false, + "arrowParens": "avoid", + + "plugins": ["prettier-plugin-tailwindcss"] } diff --git a/agentex-sgp-app/CLAUDE.md b/agentex-sgp-app/CLAUDE.md index d1008fc..142df71 100644 --- a/agentex-sgp-app/CLAUDE.md +++ b/agentex-sgp-app/CLAUDE.md @@ -62,7 +62,7 @@ Complex synchronization mechanism in `lib/pending-message.ts`: ### Component Organization - `components/agentex/*` — Domain-specific reusable components -- `components/ui/*` — Generic UI components (shadcn/ui based) +- `components/ui/*` — Generic UI components (shadcn/ui based) - only used by shadcn, do not add components to this directory unless they are shadcn - `entrypoints/*/` — Mode-specific page components and views - Follow pattern: **components should be presentational, controllers handle side effects** diff --git a/agentex-sgp-app/app/error.tsx b/agentex-sgp-app/app/error.tsx new file mode 100644 index 0000000..17d81a4 --- /dev/null +++ b/agentex-sgp-app/app/error.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { AlertCircle } from 'lucide-react'; + +import { TaskTopBar } from '@/components/agentex/task-top-bar'; +import { Button } from '@/components/ui/button'; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+ {/* Main Content Area */} +
+ + + {/* Error Message - Centered */} +
+
+
+
+ +
+
+ +
+

Something went wrong

+

+ We encountered an error while loading the agent. +

+
+ + {error.message && ( +
+

+ {error.message} +

+
+ )} + +
+ + +
+
+
+
+
+ ); +} diff --git a/agentex-sgp-app/app/globals.css b/agentex-sgp-app/app/globals.css index 7627cef..38c2417 100644 --- a/agentex-sgp-app/app/globals.css +++ b/agentex-sgp-app/app/globals.css @@ -114,7 +114,7 @@ --destructive: #7a1331; --destructive-foreground: #ffced5; --border: oklch(1 0 0 / 10%); - --input: rgba(113, 77, 255, 0.15); + --input: #28292a; --ring: #d1d5db; --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); @@ -154,85 +154,6 @@ body { @apply bg-background text-foreground; } - - textarea[data-slot='textarea'] { - background-color: var(--textarea-input); - color: var(--textarea-foreground); - } - - h1 { - scroll-margin-top: 5rem; - text-align: center; - font-size: 2.25rem; - line-height: 2.5rem; - font-weight: 800; - letter-spacing: -0.025em; - text-wrap: balance; - } - - h2 { - scroll-margin-top: 5rem; - font-size: 1.875rem; - line-height: 2.25rem; - font-weight: 600; - letter-spacing: -0.025em; - } - - h2:first-child { - margin-top: 0; - } - - h3 { - scroll-margin-top: 5rem; - font-size: 1.5rem; - line-height: 2rem; - font-weight: 600; - letter-spacing: -0.025em; - } - - h4 { - scroll-margin-top: 5rem; - font-size: 1.25rem; - line-height: 1.75rem; - font-weight: 600; - letter-spacing: -0.025em; - } - - p { - line-height: 1.75rem; - } - - blockquote { - margin-top: 1.5rem; - border-left: 2px solid; - padding-left: 1.5rem; - font-style: italic; - } - - ul { - margin-top: 1.5rem; - margin-bottom: 1.5rem; - margin-left: 1.5rem; - list-style-type: disc; - } - - ul > li { - margin-top: 0.5rem; - } -} - -.code-block { - background-color: var(--color-muted); - position: relative; - border-radius: var(--radius-sm); - padding-left: 0.3rem; - padding-right: 0.3rem; - padding-top: 0.2rem; - padding-bottom: 0.2rem; - font-family: var(--font-geist-mono); - font-size: 0.875rem; - line-height: 1.25rem; - font-weight: 600; } .hide-scrollbar { diff --git a/agentex-sgp-app/app/layout.tsx b/agentex-sgp-app/app/layout.tsx index 729648e..52d85cd 100644 --- a/agentex-sgp-app/app/layout.tsx +++ b/agentex-sgp-app/app/layout.tsx @@ -1,6 +1,8 @@ -import { ThemeProvider } from '@/components/agentex/theme-provider'; -import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; + +import { QueryProvider, ThemeProvider } from '@/components/providers'; + +import type { Metadata } from 'next'; import './globals.css'; const geistSans = Geist({ @@ -28,14 +30,16 @@ export default function RootLayout({ - - {children} - + + + {children} + + ); diff --git a/agentex-sgp-app/app/loading.tsx b/agentex-sgp-app/app/loading.tsx index 69da10c..d0beb16 100644 --- a/agentex-sgp-app/app/loading.tsx +++ b/agentex-sgp-app/app/loading.tsx @@ -1,16 +1,128 @@ +'use client'; + +import { Suspense } from 'react'; + +import { ArrowUp } from 'lucide-react'; + +import { IconButton } from '@/components/agentex/icon-button'; import { Skeleton } from '@/components/ui/skeleton'; +import { useSafeSearchParams } from '@/hooks/use-safe-search-params'; + +function LoadingContent() { + const { taskID } = useSafeSearchParams(); + + // If there's a task ID, show the task/chat loading state + if (taskID) { + return ( +
+ {/* Sidebar Skeleton */} +
+ {/* Header Section */} +
+
+ +
+ +
+ + {/* Task List */} +
+ {[...Array(8)].map((_, i) => ( + + ))} +
+
+ + {/* Main Content Area Skeleton */} +
+ {/* Top Bar */} +
+ +
+ + {/* Messages Area */} +
+
+ {/* User Message Skeleton */} +
+ +
+ + {/* Agent Message Skeleton */} +
+ + + +
+ + {/* User Message Skeleton */} +
+ +
+ + {/* Agent Message Skeleton */} +
+ + + +
+
+
+ + {/* Input Form Skeleton */} +
+
+ +
+
+
+
+ ); + } -export default function Loading() { return ( - <> -
-
-

Agentex: loading...

+
+
+
+
+
Select an Agent:
+
+ {[...Array(6)].map((_, i) => ( + + ))} +
+
+
+
+
+
+
+ + +
+
+
-
-
- -
- + + + ); +} + +export default function Loading() { + return ( + Loading...}> + + ); } diff --git a/agentex-sgp-app/app/main-content.tsx b/agentex-sgp-app/app/main-content.tsx new file mode 100644 index 0000000..3f2c726 --- /dev/null +++ b/agentex-sgp-app/app/main-content.tsx @@ -0,0 +1,320 @@ +'use client'; + +import { Suspense, useCallback, useEffect, useRef, useState } from 'react'; + +import { AnimatePresence, motion } from 'framer-motion'; +import { ArrowDown } from 'lucide-react'; +import { ToastContainer } from 'react-toastify'; + +import { AgentsList } from '@/components/agentex/agents-list'; +import { IconButton } from '@/components/agentex/icon-button'; +import { PromptInput } from '@/components/agentex/prompt-input'; +import { MemoizedTaskMessagesComponent } from '@/components/agentex/task-messages'; +import { TaskSidebar } from '@/components/agentex/task-sidebar'; +import { TaskTopBar } from '@/components/agentex/task-top-bar'; +import { TracesSidebar } from '@/components/agentex/traces-sidebar'; +import { + AgentexProvider, + TaskProvider, + useAgentexClient, +} from '@/components/providers'; +import { QueryProvider } from '@/components/providers/query-provider'; +import { useAgents } from '@/hooks/use-agents'; +import { useLocalStorageState } from '@/hooks/use-local-storage-state'; +import { + SearchParamKey, + useSafeSearchParams, +} from '@/hooks/use-safe-search-params'; + +function NoAgentImpl() { + const { taskID, agentName, updateParams } = useSafeSearchParams(); + const [isTracesSidebarOpen, setIsTracesSidebarOpen] = useState(false); + const [localAgentName] = useLocalStorageState( + 'lastSelectedAgent', + undefined + ); + + const selectedAgentName = agentName ?? localAgentName; + + const handleSelectTask = useCallback( + (taskId: string | null) => { + updateParams({ + [SearchParamKey.TASK_ID]: taskId, + }); + }, + [updateParams] + ); + + return ( +
+ + { + + + + } + + setIsTracesSidebarOpen(!isTracesSidebarOpen)} + /> + + {taskID && ( + + + + )} + +
+ ); +} + +interface ContentAreaProps { + taskID: string | null; + isTracesSidebarOpen: boolean; + toggleTracesSidebar: () => void; +} + +function ContentArea({ + taskID, + isTracesSidebarOpen, + toggleTracesSidebar, +}: ContentAreaProps) { + const { agentexClient } = useAgentexClient(); + const { data: agents = [], isLoading } = useAgents(agentexClient); + const { agentName, updateParams } = useSafeSearchParams(); + const [prompt, setPrompt] = useState(''); + const scrollContainerRef = useRef(null); + const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); + const [showScrollButton, setShowScrollButton] = useState(false); + const [localAgentName, setLocalAgentName] = useLocalStorageState< + string | undefined + >('lastSelectedAgent', undefined); + + useEffect(() => { + if (!isLoading) { + if (!agentName && localAgentName) { + updateParams({ [SearchParamKey.AGENT_NAME]: localAgentName }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoading]); + + // Show thread view when we have a task ID + const inThread = !!taskID; + + const handleSelectAgent = useCallback( + (agentName: string | undefined) => { + updateParams({ + [SearchParamKey.AGENT_NAME]: agentName ?? null, + [SearchParamKey.TASK_ID]: null, + }); + setPrompt(''); + }, + [updateParams, setPrompt] + ); + + // Scroll detection - track if user is near bottom + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = container; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + + const scrollThreshold = 100; // pixels from bottom + const isNearBottom = distanceFromBottom < scrollThreshold; + + setAutoScrollEnabled(isNearBottom); + setShowScrollButton(!isNearBottom); + }; + + container.addEventListener('scroll', handleScroll); + return () => container.removeEventListener('scroll', handleScroll); + }, [inThread]); + + // Scroll to absolute bottom when task loads or changes + useEffect(() => { + if (scrollContainerRef.current && taskID) { + // Use a small delay to ensure content is rendered and heights are calculated + setTimeout(() => { + if (scrollContainerRef.current) { + // Scroll to the maximum possible scroll position (includes blank space) + scrollContainerRef.current.scrollTo({ + top: scrollContainerRef.current.scrollHeight, + behavior: 'smooth', + }); + } + }, 150); + } + }, [taskID]); + + // Scroll to bottom handler for button + const scrollToBottom = () => { + scrollContainerRef.current?.scrollTo({ + top: scrollContainerRef.current.scrollHeight, + behavior: 'smooth', + }); + setAutoScrollEnabled(true); // Re-enable auto-scroll when user clicks button + }; + + return ( + + + {/* Top Bar */} + {taskID && agentName && ( + + + + )} + + {/* Content Area */} + {taskID ? ( + +
+
+ + + +
+
+
+ ) : ( + +
+
Agentex
+ + +
+
+ )} + + {/* Scroll to bottom button */} + {taskID && ( + + {showScrollButton && ( + + + + )} + + )} + + {/* Prompt Input */} + +
+ +
+
+
+
+ ); +} + +type Props = { + sgpAppURL: string; + agentexAPIBaseURL: string; +}; + +export function MainContent({ sgpAppURL, agentexAPIBaseURL }: Props) { + return ( + + + Loading...}> + + + + + + ); +} diff --git a/agentex-sgp-app/app/page.tsx b/agentex-sgp-app/app/page.tsx index 3a821ba..f2b4f7e 100644 --- a/agentex-sgp-app/app/page.tsx +++ b/agentex-sgp-app/app/page.tsx @@ -1,12 +1,11 @@ -'use server'; - -import { NoAgent } from '@/entrypoints/no-agent'; import { connection } from 'next/server'; +import { MainContent } from './main-content'; + export default async function RootPage() { await connection(); - const sgpAppURL = process.env.NEXT_PUBLIC_SGP_APP_URL; + const sgpAppURL = process.env.NEXT_PUBLIC_SGP_APP_URL ?? ''; const agentexAPIBaseURL = process.env.NEXT_PUBLIC_AGENTEX_API_BASE_URL ?? 'http://localhost:5003'; @@ -20,9 +19,6 @@ export default async function RootPage() { } return ( - + ); } diff --git a/agentex-sgp-app/components/agentex/agents-list.tsx b/agentex-sgp-app/components/agentex/agents-list.tsx new file mode 100644 index 0000000..89b71c3 --- /dev/null +++ b/agentex-sgp-app/components/agentex/agents-list.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef } from 'react'; + +import { Agent } from 'agentex/resources'; +import { AnimatePresence, motion } from 'framer-motion'; + +import { Skeleton } from '@/components/ui/skeleton'; +import { useLocalStorageState } from '@/hooks/use-local-storage-state'; +import { + SearchParamKey, + useSafeSearchParams, +} from '@/hooks/use-safe-search-params'; + +export type AgentsListProps = { + agents: Agent[]; + isLoading?: boolean; +}; + +export function AgentsList({ agents, isLoading = false }: AgentsListProps) { + const { agentName: selectedAgentName } = useSafeSearchParams(); + const hasMounted = useRef(false); + + useEffect(() => { + hasMounted.current = true; + }, []); + + // Filter agents: show only selected agent if one is selected, otherwise show all + const displayedAgents = selectedAgentName + ? agents.filter(agent => agent.name === selectedAgentName) + : agents; + + return ( + + {isLoading ? ( + <> + {[...Array(6)].map((_, i) => ( + + ))} + + ) : ( + + {displayedAgents?.map(agent => ( + + ))} + + )} + + ); +} + +interface AgentBadgeProps { + agent: Agent; +} + +function AgentBadge({ agent }: AgentBadgeProps) { + const { agentName, updateParams } = useSafeSearchParams(); + const [, setLocalAgentName] = useLocalStorageState( + 'lastSelectedAgent', + undefined + ); + const isSelected = agentName === agent.name; + + const handleClick = () => { + if (isSelected) { + updateParams({ [SearchParamKey.AGENT_NAME]: null }); + setLocalAgentName(undefined); + } else { + updateParams({ [SearchParamKey.AGENT_NAME]: agent.name }); + setLocalAgentName(agent.name); + } + }; + + return ( + + {agent.name} + + ); +} diff --git a/agentex-sgp-app/components/agentex/copy-button.tsx b/agentex-sgp-app/components/agentex/copy-button.tsx index 1ee45c9..cdab49a 100644 --- a/agentex-sgp-app/components/agentex/copy-button.tsx +++ b/agentex-sgp-app/components/agentex/copy-button.tsx @@ -1,20 +1,25 @@ 'use client'; -import { Button } from '@/components/ui/button'; -import { Copy, Check } from 'lucide-react'; +import { useCallback, useRef, useState } from 'react'; + +import { Check, Copy } from 'lucide-react'; +import { toast } from 'react-toastify'; + +import { IconButton } from '@/components/agentex/icon-button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; -import { useState, useCallback, useRef } from 'react'; +import { cn } from '@/lib/utils'; -interface CopyButtonProps { +export interface CopyButtonProps { tooltip?: string; onClick?: () => void; content?: string; className?: string; + timeout?: number; } export function CopyButton({ @@ -22,6 +27,7 @@ export function CopyButton({ onClick, content, className, + timeout = 4000, }: CopyButtonProps) { const [isCopying, setIsCopying] = useState(false); const timeoutRef = useRef(null); @@ -38,10 +44,10 @@ export function CopyButton({ setIsCopying(true); timeoutRef.current = setTimeout(() => { setIsCopying(false); - }, 4000); + }, timeout); }, - (err) => { - console.error('Failed to copy content:', err); + err => { + toast.error('Failed to copy content:', err); } ); } else if (onClick) { @@ -49,19 +55,22 @@ export function CopyButton({ setIsCopying(true); timeoutRef.current = setTimeout(() => { setIsCopying(false); - }, 4000); + }, timeout); } - }, [content, onClick]); + }, [content, onClick, timeout]); const buttonContent = ( - + className={cn( + 'hover:bg-muted hover:text-muted-foreground size-6 transition-colors', + className + )} + aria-label={isCopying ? 'Copied' : 'Copy'} + /> ); if (isCopying || !tooltip) { diff --git a/agentex-sgp-app/components/agentex/create-user-message-form.tsx b/agentex-sgp-app/components/agentex/create-user-message-form.tsx index 99f86c8..b487a4e 100644 --- a/agentex-sgp-app/components/agentex/create-user-message-form.tsx +++ b/agentex-sgp-app/components/agentex/create-user-message-form.tsx @@ -1,26 +1,5 @@ 'use client'; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form'; -import { SubmitButton } from '@/components/agentex/submit-button'; -import { UploadAttachmentButton } from '@/components/agentex/upload-attachment-button'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Textarea } from '@/components/ui/textarea'; -import { zodResolver } from '@hookform/resolvers/zod'; -import type { Agent, DataContent, TextContent } from 'agentex/resources'; import { createContext, ReactElement, @@ -30,6 +9,8 @@ import { useRef, useState, } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; import { Control, useForm, @@ -41,6 +22,28 @@ import { import { z } from 'zod'; import { createStore, useStore } from 'zustand'; +import { SubmitButton } from '@/components/agentex/submit-button'; +import { UploadAttachmentButton } from '@/components/agentex/upload-attachment-button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; + +import type { Agent, DataContent, TextContent } from 'agentex/resources'; + type FormStoreProps = { theme: 'light' | 'dark'; agentOptions: Agent[]; @@ -106,7 +109,7 @@ function createDefaultValues( defaultValues?: CustomPartial ): FormData { const defaultAgentIDFromDefaultValues = agentOptions.some( - (agent) => agent.id === defaultValues?.agentID + agent => agent.id === defaultValues?.agentID ) ? defaultValues?.agentID : undefined; @@ -131,12 +134,12 @@ function CreateUserMessageFormContent() { ); } - const control = useStore(store, (s) => s.control); - const agentOptions = useStore(store, (s) => s.agentOptions); - const onSubmit = useStore(store, (s) => s.onSubmit); - const formHandleSubmit = useStore(store, (s) => s.handleSubmit); - const setValue = useStore(store, (s) => s.setValue); - const watch = useStore(store, (s) => s.watch); + const control = useStore(store, s => s.control); + const agentOptions = useStore(store, s => s.agentOptions); + const onSubmit = useStore(store, s => s.onSubmit); + const formHandleSubmit = useStore(store, s => s.handleSubmit); + const setValue = useStore(store, s => s.setValue); + const watch = useStore(store, s => s.watch); // Add state to track the actual form values for button disabled state const [currentTextContent, setCurrentTextContent] = useState(''); @@ -152,8 +155,8 @@ function CreateUserMessageFormContent() { setCurrentDataContent(dataContent || ''); }, [textContent, dataContent]); - const handleSubmit = formHandleSubmit((data) => { - onSubmit(data, (resetFormData) => { + const handleSubmit = formHandleSubmit(data => { + onSubmit(data, resetFormData => { const currentStoreState = store.getState(); currentStoreState.reset( resetFormData ?? @@ -174,7 +177,7 @@ function CreateUserMessageFormContent() { { + onValueChange={value => { switch (value) { case 'text': setValue('kind', 'text'); @@ -185,7 +188,7 @@ function CreateUserMessageFormContent() { } }} > -
+
Message type: Text @@ -199,7 +202,7 @@ function CreateUserMessageFormContent() { render={({ field }) => ( { + onKeyDown={e => { if ( e.key === 'Enter' && !e.shiftKey && @@ -219,16 +222,16 @@ function CreateUserMessageFormContent() {