diff --git a/.gitignore b/.gitignore index 6459f04..d3055b1 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ yarn-error.log* /playwright-report/ /blob-report/ /playwright/* -tsconfig.tsbuildinfo \ No newline at end of file +tsconfig.tsbuildinfo +docs/composio-toolkit-metadata.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index b1efc81..c9d7c2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co This is a Next.js AI chatbot application using: ### Core Stack + - **Next.js 15** with App Router and React Server Components - **AI SDK** for LLM integration with Anthropic Claude models - **Drizzle ORM** with PostgreSQL for data persistence @@ -29,6 +30,7 @@ This is a Next.js AI chatbot application using: - **shadcn/ui** components with Tailwind CSS ### Project Structure + - `/app/(auth)/` - Authentication routes and components - `/app/(chat)/` - Main chat interface and API routes - `/artifacts/` - Different artifact types (code, image, text, sheet) @@ -39,18 +41,21 @@ This is a Next.js AI chatbot application using: - `/tests/` - Playwright e2e tests ### AI Integration + - Default model: `claude-4-sonnet-20250514` for chat, `claude-3-5-haiku-latest` for titles - Configurable via `/lib/ai/providers.ts` - Test environment uses mock models from `/lib/ai/models.test.ts` - AI tools defined in `/lib/ai/tools/` ### Database Schema + - Uses Drizzle ORM with PostgreSQL - Core tables: User, Chat, Message_v2 (new schema), Document, Vote, Suggestion - Migration files in `/lib/db/migrations/` - The Message table is deprecated in favor of Message_v2 ### Key Features + - Real-time chat with artifacts (code, documents, images, spreadsheets) - File upload and document processing - Chat history with public/private visibility @@ -58,6 +63,7 @@ This is a Next.js AI chatbot application using: - Multi-modal input support ## Code Quality + - Uses Biome for linting and formatting - ESLint for Next.js specific rules - TypeScript with strict configuration @@ -66,6 +72,7 @@ This is a Next.js AI chatbot application using: ## Development Best Practices ### Authentication Changes + - **Plan schema completely upfront** - Design all auth-related fields in single migration - **Exclude auth routes from middleware** - Always exempt `/login`, `/register` from auth checks first - **Use unique identifiers over emails** - OAuth provider IDs are more reliable than email addresses @@ -73,22 +80,31 @@ This is a Next.js AI chatbot application using: - **Avoid type safety compromises** - Use proper optional types instead of non-null assertions ### Database Migrations + - **Single comprehensive migration over incremental** - Plan all related fields together - **Include constraints from start** - Define unique, foreign key constraints in initial schema - **Clean slate approach for development** - Clear test data when making major auth changes ### Middleware and Routing + - **Auth exclusions first** - Always handle public routes before implementing auth redirects - **Avoid redirect loops** - Check middleware patterns don't redirect auth pages to themselves ### Error Handling + - **Detailed logging for auth flows** - Log profile data and validation steps for debugging - **Graceful fallbacks** - Design for missing optional data (emails, names) from OAuth providers ### Code Quality During Development + - **Run `pnpm format` and `pnpm lint`** after implementing any granular part of functionality - **Avoid type safety compromises** - No ts-ignores or lint ignores, use proper typing instead - **Test incrementally** - Verify each step works before moving to the next ### Git Workflow -- Use gh cli when working with git stuff. If and when asked, make PRs using it too. \ No newline at end of file + +- Use gh cli when working with git stuff. If and when asked, make PRs using it too. + +## Composio Documentation + +- For composio related documentation, read composio-docs.md to find the relevant URLs to read the documentation from. diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index fff5e21..6acaf96 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -33,6 +33,7 @@ import { after } from 'next/server'; import type { Chat } from '@/lib/db/schema'; import { differenceInSeconds } from 'date-fns'; import { ChatSDKError } from '@/lib/errors'; +import { getComposioTools } from '@/lib/ai/tools/composio'; export const maxDuration = 60; @@ -69,8 +70,13 @@ export async function POST(request: Request) { } try { - const { id, message, selectedChatModel, selectedVisibilityType } = - requestBody; + const { + id, + message, + selectedChatModel, + selectedVisibilityType, + enabledToolkits, + } = requestBody; const session = await auth(); @@ -142,7 +148,13 @@ export async function POST(request: Request) { await createStreamId({ streamId, chatId: id }); const stream = createDataStream({ - execute: (dataStream) => { + execute: async (dataStream) => { + // Fetch Composio tools if toolkits are enabled + const composioTools = await getComposioTools( + session.user.id, + enabledToolkits || [], + ); + const result = streamText({ model: myProvider.languageModel(selectedChatModel), system: systemPrompt({ selectedChatModel, requestHints }), @@ -154,6 +166,7 @@ export async function POST(request: Request) { experimental_generateMessageId: generateUUID, tools: { getWeather, + ...composioTools, }, onFinish: async ({ response }) => { if (session.user?.id) { diff --git a/app/(chat)/api/chat/schema.ts b/app/(chat)/api/chat/schema.ts index a452dc4..c568cc3 100644 --- a/app/(chat)/api/chat/schema.ts +++ b/app/(chat)/api/chat/schema.ts @@ -25,6 +25,7 @@ export const postRequestBodySchema = z.object({ }), selectedChatModel: z.enum(['chat-model', 'chat-model-reasoning']), selectedVisibilityType: z.enum(['public', 'private']), + enabledToolkits: z.array(z.string()).optional(), }); export type PostRequestBody = z.infer; diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx index 9310a10..e2b3c2b 100644 --- a/app/(chat)/layout.tsx +++ b/app/(chat)/layout.tsx @@ -2,6 +2,7 @@ import { cookies } from 'next/headers'; import { AppSidebar } from '@/components/app-sidebar'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { ToolBar, ToolbarProvider, ToolbarRail } from '@/components/toolbar'; import { auth } from '../(auth)/auth'; import Script from 'next/script'; @@ -21,10 +22,18 @@ export default async function Layout({ src="https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js" strategy="beforeInteractive" /> - - - {children} - + +
+ + + + {children} + + + + +
+
); } diff --git a/app/api/connections/initiate/route.ts b/app/api/connections/initiate/route.ts new file mode 100644 index 0000000..c2f4dcc --- /dev/null +++ b/app/api/connections/initiate/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/app/(auth)/auth'; +import composio from '@/lib/services/composio'; +import { initiateConnectionSchema } from './schema'; +import { ChatSDKError } from '@/lib/errors'; +// ConnectionRequest type is not exported from @composio/core +type ConnectionRequestResponse = { + id: string; + redirectUrl?: string | null; +}; + +export async function POST(request: Request) { + const session = await auth(); + + if (!session?.user?.id) { + return new ChatSDKError('unauthorized:chat').toResponse(); + } + + let requestBody: { authConfigId: string }; + + try { + const json = await request.json(); + requestBody = initiateConnectionSchema.parse(json); + } catch (_) { + return new ChatSDKError( + 'bad_request:api', + 'Invalid request body', + ).toResponse(); + } + + try { + const { authConfigId } = requestBody; + + // Initiate connection with Composio + const connectionRequest = (await composio.connectedAccounts.initiate( + session.user.id, + authConfigId, + )) as ConnectionRequestResponse; + + return NextResponse.json({ + redirectUrl: connectionRequest.redirectUrl, + connectionId: connectionRequest.id, + }); + } catch (error) { + console.error('Failed to initiate connection:', error); + + if (error instanceof Error && 'code' in error) { + // Handle Composio specific errors + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + return NextResponse.json( + { error: 'Failed to initiate connection' }, + { status: 500 }, + ); + } +} diff --git a/app/api/connections/initiate/schema.ts b/app/api/connections/initiate/schema.ts new file mode 100644 index 0000000..8f45e6b --- /dev/null +++ b/app/api/connections/initiate/schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const initiateConnectionSchema = z.object({ + authConfigId: z.string().min(1), +}); + +export type InitiateConnectionRequest = z.infer< + typeof initiateConnectionSchema +>; diff --git a/app/api/toolkits/route.ts b/app/api/toolkits/route.ts new file mode 100644 index 0000000..b8dbd7e --- /dev/null +++ b/app/api/toolkits/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/app/(auth)/auth'; +import composio from '@/lib/services/composio'; +import { ChatSDKError } from '@/lib/errors'; + +// Using custom types since they're not exported from @composio/core +type ToolkitResponse = { + name: string; + slug: string; + meta?: { + description?: string; + logo?: string; + categories?: Array<{ + name: string; + slug: string; + }>; + }; +}; + +type ConnectedAccount = { + id: string; + toolkit: { + slug: string; + }; +}; + +// Hardcoded list of supported toolkits +const SUPPORTED_TOOLKITS = [ + 'GMAIL', + 'GOOGLECALENDAR', + 'GITHUB', + 'NOTION', + 'SLACK', + 'LINEAR', +]; + +export async function GET() { + const session = await auth(); + + if (!session?.user?.id) { + return new ChatSDKError('unauthorized:chat').toResponse(); + } + + try { + // Fetch connected accounts for the user + const connectedToolkitSlugs: Set = new Set(); + + try { + const connectedAccounts = await composio.connectedAccounts.list({ + userIds: [session.user.id], + }); + + // Extract toolkit slugs from connected accounts + connectedAccounts.items.forEach((account: ConnectedAccount) => { + if (account.toolkit?.slug) { + connectedToolkitSlugs.add(account.toolkit.slug.toUpperCase()); + } + }); + } catch (error) { + console.error('Failed to fetch connected accounts:', error); + // Continue without connection status if this fails + } + + // Fetch all toolkits in parallel + const toolkitPromises = SUPPORTED_TOOLKITS.map(async (slug) => { + try { + const toolkit = (await composio.toolkits.get(slug)) as ToolkitResponse; + return { + name: toolkit.name, + slug: toolkit.slug, + description: toolkit.meta?.description, + logo: toolkit.meta?.logo, + categories: toolkit.meta?.categories, + isConnected: connectedToolkitSlugs.has(slug.toUpperCase()), + }; + } catch (error) { + console.error(`Failed to fetch toolkit ${slug}:`, error); + return null; + } + }); + + const results = await Promise.all(toolkitPromises); + const toolkits = results.filter((t) => t !== null); + + return NextResponse.json({ toolkits }); + } catch (error) { + console.error('Failed to fetch toolkits:', error); + return NextResponse.json( + { error: 'Failed to fetch toolkits' }, + { status: 500 }, + ); + } +} diff --git a/app/api/toolkits/schema.ts b/app/api/toolkits/schema.ts new file mode 100644 index 0000000..b258e80 --- /dev/null +++ b/app/api/toolkits/schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const toolkitsRequestSchema = z.object({ + toolkitSlugs: z.array(z.string()).min(1), +}); + +export type ToolkitsRequest = z.infer; diff --git a/components/chat-header.tsx b/components/chat-header.tsx index f21f151..d0b5cfb 100644 --- a/components/chat-header.tsx +++ b/components/chat-header.tsx @@ -1,18 +1,17 @@ 'use client'; - -import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useWindowSize } from 'usehooks-ts'; import { ModelSelector } from '@/components/model-selector'; import { SidebarToggle } from '@/components/sidebar-toggle'; import { Button } from '@/components/ui/button'; -import { PlusIcon, VercelIcon } from './icons'; +import { PlusIcon } from './icons'; import { useSidebar } from './ui/sidebar'; import { memo } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { type VisibilityType, VisibilitySelector } from './visibility-selector'; import type { Session } from 'next-auth'; +import { ToolBarTrigger } from './toolbar'; function PureChatHeader({ chatId, @@ -71,18 +70,7 @@ function PureChatHeader({ /> )} - + ); } diff --git a/components/chat.tsx b/components/chat.tsx index 89e472f..1f99a63 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -18,6 +18,7 @@ import { useSearchParams } from 'next/navigation'; import { useChatVisibility } from '@/hooks/use-chat-visibility'; import { useAutoResume } from '@/hooks/use-auto-resume'; import { ChatSDKError } from '@/lib/errors'; +import { useToolbarState } from './toolbar'; export function Chat({ id, @@ -37,6 +38,7 @@ export function Chat({ autoResume: boolean; }) { const { mutate } = useSWRConfig(); + const { state: toolbarState } = useToolbarState(); const { visibilityType } = useChatVisibility({ chatId: id, @@ -67,6 +69,7 @@ export function Chat({ message: body.messages.at(-1), selectedChatModel: initialChatModel, selectedVisibilityType: visibilityType, + enabledToolkits: Array.from(toolbarState.enabledTools), }), onFinish: () => { mutate(unstable_serialize(getChatHistoryPaginationKey)); diff --git a/components/toolbar.tsx b/components/toolbar.tsx new file mode 100644 index 0000000..7cf8739 --- /dev/null +++ b/components/toolbar.tsx @@ -0,0 +1,725 @@ +'use client'; + +import * as React from 'react'; +import Image from 'next/image'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, + DialogClose, +} from '@/components/ui/dialog'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { ExternalLink, Loader2, X, Wrench, Check } from 'lucide-react'; +import { useSession } from 'next-auth/react'; +import { useToast } from '@/hooks/use-toast'; +import { useIsMobile } from '@/hooks/use-mobile'; +import { cn } from '@/lib/utils'; + +// Define the toolkit slugs we want to show +const SUPPORTED_TOOLKITS = [ + 'GMAIL', + 'GOOGLECALENDAR', + 'GITHUB', + 'NOTION', + 'SLACK', + 'LINEAR', +]; + +// Map of toolkit slugs to their auth config environment variables +const TOOLKIT_AUTH_CONFIG: Record = { + GMAIL: process.env.NEXT_PUBLIC_COMPOSIO_AUTH_GMAIL || '', + GOOGLECALENDAR: process.env.NEXT_PUBLIC_COMPOSIO_AUTH_GOOGLECALENDAR || '', + GITHUB: process.env.NEXT_PUBLIC_COMPOSIO_AUTH_GITHUB || '', + NOTION: process.env.NEXT_PUBLIC_COMPOSIO_AUTH_NOTION || '', + SLACK: process.env.NEXT_PUBLIC_COMPOSIO_AUTH_SLACK || '', + LINEAR: process.env.NEXT_PUBLIC_COMPOSIO_AUTH_LINEAR || '', +}; + +const TOOLBAR_COOKIE_NAME = 'toolbar:state'; +const TOOLBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const TOOLBAR_WIDTH = '20rem'; +const TOOLBAR_WIDTH_MOBILE = '18rem'; +const TOOLBAR_KEYBOARD_SHORTCUT = 't'; + +// Define toolkit metadata type for our UI needs +type ToolkitMetadata = { + name: string; + slug: string; + description?: string; + logo?: string; + categories?: Array<{ + name: string; + slug: string; + }>; + isConnected?: boolean; +}; + +interface ToolCardProps { + toolkit: ToolkitMetadata; + isEnabled: boolean; + onToggle: (enabled: boolean) => void; + userId?: string; +} + +function ToolCard({ toolkit, isEnabled, onToggle, userId }: ToolCardProps) { + const [isDialogOpen, setIsDialogOpen] = React.useState(false); + const [isConnecting, setIsConnecting] = React.useState(false); + const { toast } = useToast(); + + const handleConnect = async () => { + if (!userId) { + toast({ + title: 'Authentication required', + description: 'Please sign in to connect tools.', + variant: 'destructive', + }); + return; + } + + const authConfigId = TOOLKIT_AUTH_CONFIG[toolkit.slug.toUpperCase()]; + + if (!authConfigId) { + toast({ + title: 'Configuration error', + description: `Auth configuration not found for ${toolkit.name}.`, + variant: 'destructive', + }); + return; + } + + setIsConnecting(true); + try { + const response = await fetch('/api/connections/initiate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ authConfigId }), + }); + + if (!response.ok) { + throw new Error('Failed to initiate connection'); + } + + const data = await response.json(); + const { redirectUrl } = data; + + if (redirectUrl) { + window.open(redirectUrl, '_blank'); + toast({ + title: 'Authorization started', + description: `Complete the authorization in the opened window for ${toolkit.name}.`, + }); + } else { + toast({ + title: 'Connection issue', + description: 'No authorization URL received. Please try again.', + variant: 'destructive', + }); + } + } catch (error) { + console.error('Failed to initiate connection:', error); + toast({ + title: 'Connection failed', + description: `Failed to connect ${toolkit.name}. Please try again.`, + variant: 'destructive', + }); + } finally { + setIsConnecting(false); + setIsDialogOpen(false); + } + }; + + return ( + + +
+
+ {toolkit.logo ? ( + {`${toolkit.name} + ) : ( +
+ )} + {toolkit.name} +
+ +
+ + + + {toolkit.description || `Connect to ${toolkit.name}`} + + + + {toolkit.isConnected ? ( + + ) : ( + + + + + + + Connect to {toolkit.name} + + To connect your {toolkit.name} account, you need to authorize + this application. + + +
+

+ Click the button below to proceed to the authorization page + for {toolkit.name}. A new window will open where you can + securely connect your account. +

+
+ + + + + + +
+
+ )} +
+ + ); +} + +type ToolbarContext = { + state: 'expanded' | 'collapsed'; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleToolbar: () => void; + enabledTools: Set; + setEnabledTools: React.Dispatch>>; +}; + +const ToolbarContext = React.createContext(null); + +function useToolbar() { + const context = React.useContext(ToolbarContext); + if (!context) { + throw new Error('useToolbar must be used within a ToolbarProvider.'); + } + return context; +} + +export function useToolbarState() { + const { enabledTools, setEnabledTools } = useToolbar(); + return { + state: { enabledTools }, + setState: ( + updater: React.SetStateAction<{ enabledTools: Set }>, + ) => { + if (typeof updater === 'function') { + const newState = updater({ enabledTools }); + setEnabledTools(newState.enabledTools); + } else { + setEnabledTools(updater.enabledTools); + } + }, + }; +} + +export function useToolbarVisibility() { + const { open, setOpen } = useToolbar(); + return { isOpen: open, setIsOpen: setOpen }; +} + +export function ToolbarProvider({ children }: { children: React.ReactNode }) { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + const [enabledTools, setEnabledTools] = React.useState>( + new Set(), + ); + + // This is the internal state of the toolbar. + const [_open, _setOpen] = React.useState(false); + const open = _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value; + _setOpen(openState); + + // This sets the cookie to keep the toolbar state. + document.cookie = `${TOOLBAR_COOKIE_NAME}=${openState}; path=/; max-age=${TOOLBAR_COOKIE_MAX_AGE}`; + }, + [open], + ); + + // Helper to toggle the toolbar. + const toggleToolbar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the toolbar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === TOOLBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + toggleToolbar(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [toggleToolbar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + const state = open ? 'expanded' : 'collapsed'; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleToolbar, + enabledTools, + setEnabledTools, + }), + [ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleToolbar, + enabledTools, + setEnabledTools, + ], + ); + + return ( + + {children} + + ); +} + +function ToolbarDesktop() { + const { data: session } = useSession(); + const { state, enabledTools, setEnabledTools } = useToolbar(); + const [toolkits, setToolkits] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(true); + const [fetchError, setFetchError] = React.useState(null); + + React.useEffect(() => { + async function fetchToolkits() { + setIsLoading(true); + setFetchError(null); + try { + const response = await fetch('/api/toolkits'); + + if (!response.ok) { + throw new Error('Failed to fetch toolkits'); + } + + const { toolkits } = await response.json(); + setToolkits(toolkits); + } catch (error) { + console.error('Failed to fetch toolkits:', error); + setFetchError('Failed to load tools. Please try again.'); + // Retry after 3 seconds + setTimeout(() => setFetchError(null), 3000); + } finally { + setIsLoading(false); + } + } + + fetchToolkits(); + }, []); + + const handleToggle = (toolSlug: string, enabled: boolean) => { + setEnabledTools((prev) => { + const newEnabledTools = new Set(prev); + if (enabled) { + newEnabledTools.add(toolSlug); + } else { + newEnabledTools.delete(toolSlug); + } + return newEnabledTools; + }); + }; + + return ( +
+ {/* This is what handles the toolbar gap on desktop */} +
+ +
+ ); +} + +function ToolbarMobile() { + const { data: session } = useSession(); + const { openMobile, setOpenMobile, enabledTools, setEnabledTools } = + useToolbar(); + const [toolkits, setToolkits] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(true); + const [fetchError, setFetchError] = React.useState(null); + + React.useEffect(() => { + async function fetchToolkits() { + if (!openMobile) return; + + setIsLoading(true); + setFetchError(null); + try { + const response = await fetch('/api/toolkits'); + + if (!response.ok) { + throw new Error('Failed to fetch toolkits'); + } + + const { toolkits } = await response.json(); + setToolkits(toolkits); + } catch (error) { + console.error('Failed to fetch toolkits:', error); + setFetchError('Failed to load tools. Please try again.'); + // Retry after 3 seconds + setTimeout(() => setFetchError(null), 3000); + } finally { + setIsLoading(false); + } + } + + fetchToolkits(); + }, [openMobile]); + + const handleToggle = (toolSlug: string, enabled: boolean) => { + setEnabledTools((prev) => { + const newEnabledTools = new Set(prev); + if (enabled) { + newEnabledTools.add(toolSlug); + } else { + newEnabledTools.delete(toolSlug); + } + return newEnabledTools; + }); + }; + + return ( + + +
+ +
+ + Toolbox +
+
+ +
+
+ {isLoading ? ( +
+ +
+ ) : fetchError ? ( +
+

{fetchError}

+ +
+ ) : ( + <> +
+

+ Enable tools to enhance your chat capabilities +

+
+ {toolkits.map((toolkit) => ( + + handleToggle(toolkit.slug, enabled) + } + userId={session?.user?.id} + /> + ))} + + )} +
+
+
+
+
+ ); +} + +export function ToolBar() { + const { isMobile } = useToolbar(); + + if (isMobile) { + return ; + } + + return ; +} + +export function ToolbarTrigger() { + const { toggleToolbar } = useToolbar(); + + return ( + + + + + + + Close Toolbox (⌘T) + + + + ); +} + +export function ToolBarTrigger() { + const { toggleToolbar, open, enabledTools } = useToolbar(); + const hasEnabledTools = enabledTools.size > 0; + + return ( + + + + + + + {open + ? 'Close Toolbox' + : hasEnabledTools + ? `Open Toolbox (${enabledTools.size} active)` + : 'Open Toolbox'}{' '} + (⌘T) + + + + ); +} + +// Rail component for easier opening when collapsed +export function ToolbarRail() { + const { toggleToolbar, state } = useToolbar(); + + if (state === 'expanded') return null; + + return ( +