Feature/composio tools library (#461)

* first version of composio

* Enhance error handling in Composio tool execution. Added try-catch block to log errors and return a structured error response when executing tools fails.

* Add tool search functionality to settings dialog

Implemented a debounced search feature for tools within the toolkit, allowing users to filter tools based on a search query. Added state management for search results and loading status. Updated the UI to accommodate the search input and results display. Additionally, modified the API call to use 'query' instead of 'search' for consistency.

* Enhance Composio OAuth flow management and improve tool handling

- Updated the activeFlows management to prevent concurrent OAuth flows for the same toolkit by using toolkitSlug as the key.
- Implemented cleanup logic for existing flows, ensuring proper resource management by aborting and closing servers as needed.
- Introduced a timeout mechanism for abandoned flows, enhancing reliability.
- Refactored the Composio tools repository to use an in-memory cache for improved performance and added methods for persisting changes to disk.
- Updated the detailed tools listing to use a consistent API call structure and improved input parameter handling.
- Made connectionData in the response optional for better flexibility in handling connected accounts.

* Improve error handling in Composio API calls

- Enhanced error reporting by extracting human-readable messages from the JSON response body when the API call fails.
- Added logic to parse the response and include specific error details in the thrown error message, improving debugging and user feedback.

* Refactor Composio integration handling and improve UI components

- Updated composio-handler.ts to invalidate the copilot instructions cache upon connection and disconnection.
- Removed unused functions related to tool management in composio-handler.ts.
- Enhanced IPC handlers in ipc.ts to streamline Composio connection processes.
- Introduced ComposioConnectCard in the renderer to display connection status and handle events.
- Refactored tool rendering in App.tsx and chat-sidebar.tsx to utilize new tabbed content for parameters and results.
- Improved Composio tools prompt generation in instructions.ts to clarify integration usage and discovery flow.
- Cleaned up unused code and improved overall structure for better maintainability.

* Refactor listToolkits function to remove cursor parameter and implement pagination

- Updated listToolkits in composio-handler.ts to paginate through API results, collecting all curated toolkits.
- Adjusted IPC handler in ipc.ts to call the modified listToolkits without cursor argument.
- Made properties in ToolkitInfo optional in settings-dialog.tsx for improved flexibility.
- Removed the unused enabled-tools-repo.ts file to clean up the codebase.

* Refactor Composio toolkit management for improved structure and maintainability

- Consolidated toolkit definitions and display names into a single source of truth in shared/composio.ts.
- Updated core composio/curated-toolkits.ts to re-export types and constants for backward compatibility.
- Enhanced the organization of toolkit data, ensuring clarity and ease of access for future development.

* Refactor Composio integration and improve component structure

- Updated imports in composio-handler.ts and various components to utilize shared/composio.js for consistency.
- Simplified ComposioConnectCard by removing unnecessary state management and improving event handling.
- Enhanced chat-conversation.ts to directly reference COMPOSIO_DISPLAY_NAMES from shared/composio.js.
- Cleaned up unused functions and types in client.ts and types.ts for better maintainability.
- Removed deprecated curated-toolkits.ts file to streamline the codebase.

* Refactor Composio connection handling and improve tool display logic

- Removed the connection bridge for Composio toolkit OAuth, simplifying the connection process.
- Updated ComposioConnectCard to display a more user-friendly connection message.
- Introduced a new utility function, getToolDisplayName, to provide human-friendly names for builtin tools.
- Refactored App and ChatSidebar components to utilize the new getToolDisplayName function for improved clarity in tool titles.
- Cleaned up imports and removed unused code for better maintainability.

* remove from diff

* Address PR review: consolidate types, refactor CopilotAgent, sort toolkits

- Move ZSearchResultTool and ZNormalizedToolResult into composio/types.ts
- Convert CopilotAgent from static const to async buildCopilotAgent()
- Simplify loadAgent to delegate to buildCopilotAgent()
- Sort CURATED_TOOLKITS alphabetically by slug
- Remove inline type annotations in composio-handler, use inferred types
- Bump search limit from 15→50 for unscoped queries
- Add docstrings explaining inferToolkitSlug fallback behavior
- Add IPC schema reference comment for composio channels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Enhance Composio connection handling and improve rendering logic

- Added a 'hidden' property to ComposioConnectCardData to prevent rendering of duplicate connection cards.
- Updated App and ChatSidebar components to skip rendering if the card is marked as hidden.
- Refactored ComposioConnectCard to utilize a ref for callback firing, ensuring onConnected is only called once.
- Improved instructions for Composio integration to clarify usage and loading of the composio-integration skill.

This update streamlines the user experience by avoiding duplicate connection prompts and enhances the overall clarity of integration instructions.

* Address PR round 2: use query param, remove inferToolkitSlug, consolidate types

- Rename deprecated `search` param to `query` per Composio docs
- Remove inferToolkitSlug fallback; make toolkit required in ZSearchResultTool
- Replace inline Awaited<ReturnType<...>> with concrete Toolkit type in handler
- Move ZToolkitMeta/ZToolkitItem/ZListToolkitsResponse to shared/composio.ts
- Reference shared schemas in ipc.ts and core/types.ts (single source of truth)
- Remove unused ZTool import from client.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add Toolkit type inference to composio/types.ts

- Introduced a new type `Toolkit` inferred from `ZToolkit` to enhance type safety and clarity in type definitions.
- This addition supports better integration and usage of the toolkit within the Composio framework.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tushar 2026-04-06 13:30:46 +05:30 committed by GitHub
parent bc929b6c1b
commit e0aaa9a27e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1324 additions and 214 deletions

View file

@ -2,18 +2,21 @@ import { shell, BrowserWindow } from 'electron';
import { createAuthServer } from './auth-server.js';
import * as composioClient from '@x/core/dist/composio/client.js';
import { composioAccountsRepo } from '@x/core/dist/composio/repo.js';
import type { LocalConnectedAccount, ZExecuteActionResponse } from '@x/core/dist/composio/types.js';
import { z } from 'zod';
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
import { CURATED_TOOLKIT_SLUGS } from '@x/shared/dist/composio.js';
import type { LocalConnectedAccount, Toolkit } from '@x/core/dist/composio/types.js';
import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js';
const REDIRECT_URI = 'http://localhost:8081/oauth/callback';
// Store active OAuth flows
// Store active OAuth flows (keyed by toolkitSlug to prevent concurrent flows for the same toolkit)
const activeFlows = new Map<string, {
toolkitSlug: string;
connectedAccountId: string;
authConfigId: string;
server: import('http').Server;
timeout: NodeJS.Timeout;
}>();
/**
@ -125,13 +128,14 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
};
}
// Store flow state
const flowKey = `${toolkitSlug}-${Date.now()}`;
activeFlows.set(flowKey, {
toolkitSlug,
connectedAccountId,
authConfigId,
});
// Abort any existing flow for this toolkit before starting a new one
const existingFlow = activeFlows.get(toolkitSlug);
if (existingFlow) {
console.log(`[Composio] Aborting existing flow for ${toolkitSlug}`);
clearTimeout(existingFlow.timeout);
existingFlow.server.close();
activeFlows.delete(toolkitSlug);
}
// Save initial account state
const account: LocalConnectedAccount = {
@ -145,7 +149,7 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
composioAccountsRepo.saveAccount(account);
// Set up callback server
let cleanupTimeout: NodeJS.Timeout;
const timeoutRef: { current: NodeJS.Timeout | null } = { current: null };
let callbackHandled = false;
const { server } = await createAuthServer(8081, async () => {
// Guard against duplicate callbacks (browser may send multiple requests)
@ -157,6 +161,8 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status);
if (accountStatus.status === 'ACTIVE') {
// Invalidate instructions cache so the copilot knows about the new connection
invalidateCopilotInstructionsCache();
emitComposioEvent({ toolkitSlug, success: true });
if (toolkitSlug === 'gmail') {
triggerGmailSync();
@ -179,17 +185,17 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
error: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
activeFlows.delete(flowKey);
activeFlows.delete(toolkitSlug);
server.close();
clearTimeout(cleanupTimeout);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
}
});
// Timeout for abandoned flows (5 minutes)
cleanupTimeout = setTimeout(() => {
if (activeFlows.has(flowKey)) {
const cleanupTimeout = setTimeout(() => {
if (activeFlows.has(toolkitSlug)) {
console.log(`[Composio] Cleaning up abandoned flow for ${toolkitSlug}`);
activeFlows.delete(flowKey);
activeFlows.delete(toolkitSlug);
server.close();
emitComposioEvent({
toolkitSlug,
@ -198,6 +204,16 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
});
}
}, 5 * 60 * 1000);
timeoutRef.current = cleanupTimeout;
// Store flow state (keyed by toolkit to prevent concurrent flows)
activeFlows.set(toolkitSlug, {
toolkitSlug,
connectedAccountId,
authConfigId,
server,
timeout: cleanupTimeout,
});
// Open browser for OAuth
shell.openExternal(redirectUrl);
@ -257,18 +273,16 @@ export async function disconnect(toolkitSlug: string): Promise<{ success: boolea
try {
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (account) {
// Delete from Composio
await composioClient.deleteConnectedAccount(account.id);
// Delete local record
composioAccountsRepo.deleteAccount(toolkitSlug);
}
return { success: true };
} catch (error) {
console.error('[Composio] Disconnect failed:', error);
// Still delete local record even if API call fails
} finally {
// Always clean up local state, even if the API call fails
composioAccountsRepo.deleteAccount(toolkitSlug);
return { success: true };
invalidateCopilotInstructionsCache();
}
return { success: true };
}
/**
@ -293,36 +307,24 @@ export async function useComposioForGoogleCalendar(): Promise<{ enabled: boolean
}
/**
* Execute a Composio action
* List available Composio toolkits filtered to curated list only.
* Return type matches the ZToolkit schema from core/composio/types.ts.
*/
export async function executeAction(
actionSlug: string,
toolkitSlug: string,
input: Record<string, unknown>
): Promise<z.infer<typeof ZExecuteActionResponse>> {
try {
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (!account || account.status !== 'ACTIVE') {
return {
data: null,
successful: false,
error: `Toolkit ${toolkitSlug} is not connected`,
};
}
const result = await composioClient.executeAction(actionSlug, {
connected_account_id: account.id,
user_id: 'rowboat-user',
version: 'latest',
arguments: input,
});
return result;
} catch (error) {
console.error('[Composio] Action execution failed:', error);
return {
successful: false,
data: null,
error: error instanceof Error ? error.message : 'Unknown error',
};
export async function listToolkits() {
// Paginate through all API pages to collect every curated toolkit
const allItems: Toolkit[] = [];
let cursor: string | null = null;
const maxPages = 10; // safety limit
for (let page = 0; page < maxPages; page++) {
const result = await composioClient.listToolkits(cursor);
allItems.push(...result.items);
cursor = result.next_cursor;
if (!cursor) break;
}
const filtered = allItems.filter(item => CURATED_TOOLKIT_SLUGS.has(item.slug));
return {
items: filtered,
nextCursor: null as string | null,
totalItems: filtered.length,
};
}

View file

@ -559,8 +559,9 @@ export function setupIpcHandlers() {
'composio:list-connected': async () => {
return composioHandler.listConnected();
},
'composio:execute-action': async (_event, args) => {
return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input);
// Composio Tools Library handlers
'composio:list-toolkits': async () => {
return composioHandler.listToolkits();
},
'composio:use-composio-for-google': async () => {
return composioHandler.useComposioForGoogle();

View file

@ -34,9 +34,10 @@ import {
import { Shimmer } from '@/components/ai-elements/shimmer';
import { useSmoothedText } from './hooks/useSmoothedText';
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool';
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool';
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
import { AppActionCard } from '@/components/ai-elements/app-action-card';
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
import { PermissionRequest } from '@/components/ai-elements/permission-request';
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
import { Suggestions } from '@/components/ai-elements/suggestions';
@ -67,6 +68,8 @@ import {
createEmptyChatTabViewState,
getWebSearchCardData,
getAppActionCardData,
getComposioConnectCardData,
getToolDisplayName,
inferRunTitleFromMessage,
isChatMessage,
isErrorMessage,
@ -76,6 +79,7 @@ import {
parseAttachedFiles,
toToolState,
} from '@/lib/chat-conversation'
import { COMPOSIO_DISPLAY_NAMES as composioDisplayNames } from '@x/shared/src/composio.js'
import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
import { toast } from "sonner"
@ -2242,6 +2246,12 @@ function App() {
}
handlePromptSubmitRef.current = handlePromptSubmit
const handleComposioConnected = useCallback((toolkitSlug: string) => {
// Auto-send a continuation message when a Composio toolkit connects
const name = composioDisplayNames[toolkitSlug] || toolkitSlug
handlePromptSubmitRef.current?.({ text: `${name} connected successfully.`, files: [] })
}, [])
const handleStop = useCallback(async () => {
if (!runId) return
const now = Date.now()
@ -3826,6 +3836,22 @@ function App() {
/>
)
}
const composioConnectData = getComposioConnectCardData(item)
if (composioConnectData) {
// Skip rendering if this is a duplicate "already connected" card
if (composioConnectData.hidden) return null
return (
<ComposioConnectCard
key={item.id}
toolkitSlug={composioConnectData.toolkitSlug}
toolkitDisplayName={composioConnectData.toolkitDisplayName}
status={item.status}
alreadyConnected={composioConnectData.alreadyConnected}
onConnected={handleComposioConnected}
/>
)
}
const toolTitle = getToolDisplayName(item)
const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
@ -3836,15 +3862,12 @@ function App() {
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
>
<ToolHeader
title={item.name}
title={toolTitle}
type={`tool-${item.name}`}
state={toToolState(item.status)}
/>
<ToolContent>
<ToolInput input={input} />
{output !== null ? (
<ToolOutput output={output} errorText={errorText} />
) : null}
<ToolTabbedContent input={input} output={output} errorText={errorText} />
</ToolContent>
</Tool>
)
@ -4470,6 +4493,7 @@ function App() {
ttsMode={ttsMode}
onToggleTts={handleToggleTts}
onTtsModeChange={handleTtsModeChange}
onComposioConnected={handleComposioConnected}
/>
)}
{/* Rendered last so its no-drag region paints over the sidebar drag region */}

View file

@ -0,0 +1,127 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
CheckCircleIcon,
Link2Icon,
LoaderIcon,
XCircleIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
interface ComposioConnectCardProps {
toolkitSlug: string;
toolkitDisplayName: string;
status: "pending" | "running" | "completed" | "error";
alreadyConnected?: boolean;
onConnected?: (toolkitSlug: string) => void;
}
export function ComposioConnectCard({
toolkitSlug,
toolkitDisplayName,
status,
alreadyConnected,
onConnected,
}: ComposioConnectCardProps) {
const [connectionState, setConnectionState] = useState<
"idle" | "connecting" | "connected" | "error"
>(alreadyConnected ? "connected" : "idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const didFireCallback = useRef(alreadyConnected ?? false);
// Listen for composio:didConnect events
useEffect(() => {
const cleanup = window.ipc.on(
"composio:didConnect",
(event: { toolkitSlug: string; success: boolean; error?: string }) => {
if (event.toolkitSlug !== toolkitSlug) return;
if (event.success) {
setConnectionState("connected");
setErrorMessage(null);
if (!didFireCallback.current) {
didFireCallback.current = true;
onConnected?.(toolkitSlug);
}
} else {
setConnectionState("error");
setErrorMessage(event.error || "Connection failed");
}
}
);
return cleanup;
}, [toolkitSlug, onConnected]);
const handleConnect = useCallback(async () => {
setConnectionState("connecting");
setErrorMessage(null);
try {
const result = await window.ipc.invoke("composio:initiate-connection", {
toolkitSlug,
});
if (!result.success) {
setConnectionState("error");
setErrorMessage(result.error || "Failed to initiate connection");
}
} catch {
setConnectionState("error");
setErrorMessage("Failed to initiate connection");
}
}, [toolkitSlug]);
const isToolRunning = status === "pending" || status === "running";
const displayName = toolkitDisplayName || toolkitSlug;
return (
<div className="not-prose mb-4 flex items-center gap-3 rounded-lg border px-3 py-2.5">
{/* Toolkit initial */}
<div className="size-7 rounded bg-muted flex items-center justify-center flex-shrink-0">
<span className="text-xs font-bold text-muted-foreground">
{displayName.charAt(0).toUpperCase()}
</span>
</div>
{/* Name & status */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">{displayName}</span>
{connectionState === "connected" && (
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
Connected
</span>
)}
</div>
{connectionState === "error" && errorMessage && (
<p className="text-xs text-destructive truncate">{errorMessage}</p>
)}
{connectionState === "idle" && isToolRunning && (
<p className="text-xs text-muted-foreground">Waiting to connect...</p>
)}
</div>
{/* Action area */}
{connectionState === "connected" ? (
<CheckCircleIcon className="size-4 text-green-600 flex-shrink-0" />
) : connectionState === "connecting" ? (
<Button size="sm" disabled className="text-xs h-7 flex-shrink-0">
<LoaderIcon className="size-3 animate-spin mr-1" />
Connecting...
</Button>
) : connectionState === "error" ? (
<div className="flex items-center gap-1.5 flex-shrink-0">
<XCircleIcon className="size-3.5 text-destructive" />
<Button size="sm" variant="outline" onClick={handleConnect} className="text-xs h-7">
Retry
</Button>
</div>
) : isToolRunning ? (
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground flex-shrink-0" />
) : (
<Button size="sm" onClick={handleConnect} className="text-xs h-7 flex-shrink-0">
<Link2Icon className="size-3 mr-1" />
Connect
</Button>
)}
</div>
);
}

View file

@ -16,8 +16,8 @@ import {
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement } from "react";
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
const formatToolValue = (value: unknown) => {
if (typeof value === "string") return value;
try {
@ -37,7 +37,7 @@ const ToolCode = ({
}) => (
<pre
className={cn(
"whitespace-pre-wrap text-xs font-mono",
"whitespace-pre-wrap text-xs font-mono break-all",
className
)}
>
@ -129,64 +129,90 @@ export const ToolContent = ({ className, ...props }: ToolContentProps) => (
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
/* ── Tabbed content (Parameters / Result) ────────────────────────── */
export type ToolTabbedContentProps = {
input: ToolUIPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden p-4", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md border bg-muted/50 p-4 text-foreground">
<ToolCode code={formatToolValue(input ?? {})} />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolUIPart["output"];
errorText: ToolUIPart["errorText"];
errorText?: ToolUIPart["errorText"];
};
export const ToolOutput = ({
className,
export const ToolTabbedContent = ({
input,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
}: ToolTabbedContentProps) => {
const [activeTab, setActiveTab] = useState<"parameters" | "result">("parameters");
const hasOutput = output != null || !!errorText;
let Output = <div>{output as ReactNode}</div>;
if (typeof output === "object" && !isValidElement(output)) {
Output = <ToolCode code={formatToolValue(output ?? null)} />;
} else if (typeof output === "string") {
Output = <ToolCode code={formatToolValue(output)} />;
let OutputNode: ReactNode = null;
if (errorText) {
OutputNode = <ToolCode code={errorText} className="text-destructive" />;
} else if (output != null) {
if (typeof output === "object" && !isValidElement(output)) {
OutputNode = <ToolCode code={formatToolValue(output)} />;
} else if (typeof output === "string") {
OutputNode = <ToolCode code={output} />;
} else {
OutputNode = <div>{output as ReactNode}</div>;
}
}
return (
<div className={cn("space-y-2 p-4", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md border p-4 text-xs [&_table]:w-full",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
)}
>
{errorText && (
<div className="mb-2 font-sans text-xs text-destructive">
{errorText}
<div className="border-t">
{/* Tabs */}
<div className="flex">
<button
type="button"
className={cn(
"px-4 py-2 text-xs font-medium transition-colors border-b-2",
activeTab === "parameters"
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("parameters")}
>
Parameters
</button>
<button
type="button"
className={cn(
"px-4 py-2 text-xs font-medium transition-colors border-b-2",
activeTab === "result"
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("result")}
>
Result
</button>
</div>
{/* Tab content */}
<div className="p-3">
{activeTab === "parameters" && (
<div className="rounded-md border bg-muted/50 p-3 max-h-64 overflow-auto">
<ToolCode code={formatToolValue(input ?? {})} />
</div>
)}
{activeTab === "result" && (
<div
className={cn(
"rounded-md border p-3 max-h-64 overflow-auto",
errorText ? "bg-destructive/10" : "bg-muted/50"
)}
>
{hasOutput ? (
<div className={cn(errorText && "text-destructive")}>
{OutputNode}
</div>
) : (
<span className="text-xs text-muted-foreground">(pending...)</span>
)}
</div>
)}
{Output}
</div>
</div>
);
};

View file

@ -16,8 +16,9 @@ import {
MessageResponse,
} from '@/components/ai-elements/message'
import { Shimmer } from '@/components/ai-elements/shimmer'
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
import { PermissionRequest } from '@/components/ai-elements/permission-request'
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
import { Suggestions } from '@/components/ai-elements/suggestions'
@ -34,6 +35,8 @@ import {
type PermissionResponse,
createEmptyChatTabViewState,
getWebSearchCardData,
getComposioConnectCardData,
getToolDisplayName,
isChatMessage,
isErrorMessage,
isToolCall,
@ -121,6 +124,7 @@ interface ChatSidebarProps {
ttsMode?: 'summary' | 'full'
onToggleTts?: () => void
onTtsModeChange?: (mode: 'summary' | 'full') => void
onComposioConnected?: (toolkitSlug: string) => void
}
export function ChatSidebar({
@ -171,6 +175,7 @@ export function ChatSidebar({
ttsMode,
onToggleTts,
onTtsModeChange,
onComposioConnected,
}: ChatSidebarProps) {
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
const [isResizing, setIsResizing] = useState(false)
@ -337,6 +342,21 @@ export function ChatSidebar({
/>
)
}
const composioConnectData = getComposioConnectCardData(item)
if (composioConnectData) {
if (composioConnectData.hidden) return null
return (
<ComposioConnectCard
key={item.id}
toolkitSlug={composioConnectData.toolkitSlug}
toolkitDisplayName={composioConnectData.toolkitDisplayName}
status={item.status}
alreadyConnected={composioConnectData.alreadyConnected}
onConnected={onComposioConnected}
/>
)
}
const toolTitle = getToolDisplayName(item)
const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
@ -346,10 +366,9 @@ export function ChatSidebar({
open={isToolOpenForTab?.(tabId, item.id) ?? false}
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
>
<ToolHeader title={item.name} type={`tool-${item.name}`} state={toToolState(item.status)} />
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
<ToolContent>
<ToolInput input={input} />
{output !== null ? <ToolOutput output={output} errorText={errorText} /> : null}
<ToolTabbedContent input={input} output={output} errorText={errorText} />
</ToolContent>
</Tool>
)

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, Mail, BookOpen, ChevronRight, Plus, X, User, Plug } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug } from "lucide-react"
import {
Dialog,
@ -25,7 +25,7 @@ import { toast } from "sonner"
import { AccountSettings } from "@/components/settings/account-settings"
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "note-tagging"
type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "tools" | "note-tagging"
interface TabConfig {
id: ConfigTab
@ -75,6 +75,12 @@ const tabs: TabConfig[] = [
icon: Palette,
description: "Customize the look and feel",
},
{
id: "tools",
label: "Tools Library",
icon: Wrench,
description: "Browse and enable Composio toolkits",
},
{
id: "note-tagging",
label: "Note Tagging",
@ -707,6 +713,323 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
)
}
// --- Tools Library Settings ---
interface ToolkitInfo {
slug: string
name: string
meta: { description: string; logo: string; tools_count: number; triggers_count: number }
no_auth?: boolean
auth_schemes?: string[]
composio_managed_auth_schemes?: string[]
}
function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
// API key state
const [apiKeyConfigured, setApiKeyConfigured] = useState(false)
const [apiKeyInput, setApiKeyInput] = useState("")
const [apiKeySaving, setApiKeySaving] = useState(false)
const [showApiKeyInput, setShowApiKeyInput] = useState(false)
// Toolkit browsing state
const [toolkits, setToolkits] = useState<ToolkitInfo[]>([])
const [toolkitsLoading, setToolkitsLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
// Connection state
const [connectedToolkits, setConnectedToolkits] = useState<Set<string>>(new Set())
const [connectingToolkit, setConnectingToolkit] = useState<string | null>(null)
// Check API key configuration
const checkApiKey = useCallback(async () => {
try {
const result = await window.ipc.invoke("composio:is-configured", null)
setApiKeyConfigured(result.configured)
if (!result.configured) {
setShowApiKeyInput(true)
}
} catch {
setApiKeyConfigured(false)
}
}, [])
// Load connected toolkits
const loadConnected = useCallback(async () => {
try {
const result = await window.ipc.invoke("composio:list-connected", null)
setConnectedToolkits(new Set(result.toolkits))
} catch {
// ignore
}
}, [])
// Load toolkits
const loadToolkits = useCallback(async () => {
setToolkitsLoading(true)
try {
const result = await window.ipc.invoke("composio:list-toolkits", {})
setToolkits(result.items)
} catch {
toast.error("Failed to load toolkits")
} finally {
setToolkitsLoading(false)
}
}, [])
// Initial load
useEffect(() => {
if (!dialogOpen) return
checkApiKey()
loadConnected()
}, [dialogOpen, checkApiKey, loadConnected])
// Load toolkits when API key is configured
useEffect(() => {
if (dialogOpen && apiKeyConfigured) {
loadToolkits()
}
}, [dialogOpen, apiKeyConfigured, loadToolkits])
// Listen for composio connection events
useEffect(() => {
const cleanup = window.ipc.on('composio:didConnect', (event) => {
const { toolkitSlug, success, error } = event
setConnectingToolkit(null)
if (success) {
setConnectedToolkits(prev => new Set([...prev, toolkitSlug]))
toast.success(`Connected to ${toolkitSlug}`)
} else {
toast.error(error || `Failed to connect to ${toolkitSlug}`)
}
})
return cleanup
}, [])
// Save API key
const handleSaveApiKey = async () => {
const trimmed = apiKeyInput.trim()
if (!trimmed) return
setApiKeySaving(true)
try {
const result = await window.ipc.invoke("composio:set-api-key", { apiKey: trimmed })
if (result.success) {
setApiKeyConfigured(true)
setShowApiKeyInput(false)
setApiKeyInput("")
toast.success("Composio API key saved")
} else {
toast.error(result.error || "Failed to save API key")
}
} catch {
toast.error("Failed to save API key")
} finally {
setApiKeySaving(false)
}
}
// Connect a toolkit
const handleConnect = async (toolkitSlug: string) => {
setConnectingToolkit(toolkitSlug)
try {
const result = await window.ipc.invoke("composio:initiate-connection", { toolkitSlug })
if (!result.success) {
toast.error(result.error || "Failed to connect")
setConnectingToolkit(null)
}
// Success will be handled by composio:didConnect event
} catch {
toast.error("Failed to connect")
setConnectingToolkit(null)
}
}
// Disconnect a toolkit
const handleDisconnect = async (toolkitSlug: string) => {
try {
await window.ipc.invoke("composio:disconnect", { toolkitSlug })
setConnectedToolkits(prev => {
const next = new Set(prev)
next.delete(toolkitSlug)
return next
})
toast.success(`Disconnected from ${toolkitSlug}`)
} catch {
toast.error("Failed to disconnect")
}
}
// Filter toolkits by search
const filteredToolkits = searchQuery.trim()
? toolkits.filter(t =>
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.meta.description.toLowerCase().includes(searchQuery.toLowerCase())
)
: toolkits
return (
<div className="space-y-4">
{/* Section A: API Key */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Composio API Key</span>
{apiKeyConfigured && !showApiKeyInput ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
API key configured
</div>
<button
onClick={() => setShowApiKeyInput(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Change
</button>
</div>
) : (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Enter your Composio API key to browse and enable tool integrations.
Get your key from{" "}
<a
href="https://app.composio.dev/settings"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
app.composio.dev/settings
</a>
</p>
<div className="flex gap-2">
<Input
type="password"
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
placeholder="Paste your Composio API key"
onKeyDown={(e) => e.key === "Enter" && handleSaveApiKey()}
className="flex-1"
/>
<Button
onClick={handleSaveApiKey}
disabled={!apiKeyInput.trim() || apiKeySaving}
size="sm"
>
{apiKeySaving ? <Loader2 className="size-4 animate-spin" /> : "Save"}
</Button>
{apiKeyConfigured && (
<Button
variant="outline"
size="sm"
onClick={() => { setShowApiKeyInput(false); setApiKeyInput("") }}
>
Cancel
</Button>
)}
</div>
</div>
)}
</div>
{/* Section B: Toolkit Browser (only when API key configured) */}
{apiKeyConfigured && (
<>
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Available Toolkits</span>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search toolkits..."
className="pl-8"
/>
</div>
</div>
{toolkitsLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin mr-2" />
Loading toolkits...
</div>
) : (
<div className="space-y-1.5 max-h-[400px] overflow-y-auto pr-1">
{filteredToolkits.map((toolkit) => {
const isConnected = connectedToolkits.has(toolkit.slug)
const isConnecting = connectingToolkit === toolkit.slug
return (
<div key={toolkit.slug} className="border rounded-lg overflow-hidden">
<div className="flex items-center gap-3 px-3 py-2.5">
{/* Logo */}
{toolkit.meta.logo ? (
<img
src={toolkit.meta.logo}
alt=""
className="size-7 rounded object-contain flex-shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
) : (
<div className="size-7 rounded bg-muted flex items-center justify-center flex-shrink-0">
<Wrench className="size-3.5 text-muted-foreground" />
</div>
)}
{/* Name & description */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">{toolkit.name}</span>
{isConnected && (
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
Connected
</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{toolkit.meta.description}
</p>
</div>
{/* Connect / Disconnect button */}
{isConnected ? (
<Button
variant="outline"
size="sm"
onClick={() => handleDisconnect(toolkit.slug)}
className="text-xs h-7 flex-shrink-0"
>
Disconnect
</Button>
) : (
<Button
size="sm"
onClick={() => handleConnect(toolkit.slug)}
disabled={isConnecting}
className="text-xs h-7 flex-shrink-0"
>
{isConnecting ? (
<><Loader2 className="size-3 animate-spin mr-1" />Connecting...</>
) : (
<><Link2 className="size-3 mr-1" />Connect</>
)}
</Button>
)}
</div>
</div>
)
})}
{filteredToolkits.length === 0 && !toolkitsLoading && (
<div className="text-center py-6 text-sm text-muted-foreground">
{searchQuery ? "No toolkits match your search" : "No toolkits available"}
</div>
)}
</div>
)}
</>
)}
</div>
)
}
// --- Rowboat Model Settings (when signed in via Rowboat) ---
function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
@ -1312,7 +1635,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : activeTab === "account" || activeTab === "connected-accounts" ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "tools" || activeTab === "account" || activeTab === "connected-accounts") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "account" ? (
<AccountSettings dialogOpen={open} />
) : activeTab === "connected-accounts" ? (
@ -1325,6 +1648,8 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
<NoteTaggingSettings dialogOpen={open} />
) : activeTab === "appearance" ? (
<AppearanceSettings />
) : activeTab === "tools" ? (
<ToolsLibrarySettings dialogOpen={open} />
) : loading ? (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Loading...

View file

@ -1,6 +1,7 @@
import type { ToolUIPart } from 'ai'
import z from 'zod'
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
export interface MessageAttachment {
path: string
@ -253,6 +254,142 @@ export const parseAttachedFiles = (content: string): { message: string; files: s
return { message: cleanMessage.trim(), files }
}
// Composio connect card data
export type ComposioConnectCardData = {
toolkitSlug: string
toolkitDisplayName: string
alreadyConnected: boolean
/** When true, the connect card should not be rendered (toolkit was already connected). */
hidden: boolean
}
export const getComposioConnectCardData = (tool: ToolCall): ComposioConnectCardData | null => {
if (tool.name !== 'composio-connect-toolkit') return null
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
const toolkitSlug = (input?.toolkitSlug as string) || ''
const alreadyConnected = result?.alreadyConnected === true
return {
toolkitSlug,
toolkitDisplayName: COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug,
alreadyConnected,
// Don't render a connect card if the toolkit was already connected —
// the original card from the first connect call already shows the "Connected" state.
hidden: alreadyConnected,
}
}
// Human-friendly display names for builtin tools
const TOOL_DISPLAY_NAMES: Record<string, string> = {
'workspace-readFile': 'Reading file',
'workspace-writeFile': 'Writing file',
'workspace-edit': 'Editing file',
'workspace-readdir': 'Reading directory',
'workspace-exists': 'Checking path',
'workspace-stat': 'Getting file info',
'workspace-glob': 'Finding files',
'workspace-grep': 'Searching files',
'workspace-mkdir': 'Creating directory',
'workspace-rename': 'Renaming',
'workspace-copy': 'Copying file',
'workspace-remove': 'Removing',
'workspace-getRoot': 'Getting workspace root',
'loadSkill': 'Loading skill',
'parseFile': 'Parsing file',
'LLMParse': 'Extracting content',
'analyzeAgent': 'Analyzing agent',
'executeCommand': 'Running command',
'addMcpServer': 'Adding MCP server',
'listMcpServers': 'Listing MCP servers',
'listMcpTools': 'Listing MCP tools',
'executeMcpTool': 'Running MCP tool',
'web-search': 'Searching the web',
'save-to-memory': 'Saving to memory',
'app-navigation': 'Navigating app',
'composio-list-toolkits': 'Listing integrations',
'composio-search-tools': 'Searching tools',
'composio-execute-tool': 'Running tool',
'composio-connect-toolkit': 'Connecting service',
}
/**
* Get a human-friendly display name for a tool call.
* For Composio tools, returns a contextual label (e.g., "Found 3 tools for 'send email' in Gmail").
* For builtin tools, returns a static friendly name (e.g., "Reading file").
* Falls back to the raw tool name if no mapping exists.
*/
export const getToolDisplayName = (tool: ToolCall): string => {
const composioData = getComposioActionCardData(tool)
if (composioData) return composioData.label
return TOOL_DISPLAY_NAMES[tool.name] || tool.name
}
// Composio action card data (for search, execute, list tools)
export type ComposioActionCardData = {
actionType: 'search' | 'execute' | 'list'
label: string
}
export const getComposioActionCardData = (tool: ToolCall): ComposioActionCardData | null => {
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
if (tool.name === 'composio-search-tools') {
const query = (input?.query as string) || 'tools'
const toolkitSlug = input?.toolkitSlug as string | undefined
const toolkit = toolkitSlug ? COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug : null
const count = (result?.resultCount as number) ?? null
let label = `Searching for "${query}"`
if (toolkit) label += ` in ${toolkit}`
if (count !== null && tool.status === 'completed') {
label = count > 0 ? `Found ${count} tool${count !== 1 ? 's' : ''} for "${query}"` : `No tools found for "${query}"`
if (toolkit) label += ` in ${toolkit}`
}
return { actionType: 'search', label }
}
if (tool.name === 'composio-execute-tool') {
const toolSlug = (input?.toolSlug as string) || ''
const toolkitSlug = (input?.toolkitSlug as string) || ''
const toolkit = COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug
const successful = result?.successful as boolean | undefined
// Make the tool slug human-readable: GITHUB_ISSUES_LIST_FOR_REPO → "Issues list for repo"
const readableName = toolSlug
.replace(/^[A-Z]+_/, '') // Remove toolkit prefix
.toLowerCase()
.replace(/_/g, ' ')
.replace(/^\w/, c => c.toUpperCase())
let label = `Running ${readableName}`
if (toolkit) label += ` on ${toolkit}`
if (tool.status === 'completed') {
label = successful === false ? `Failed: ${readableName}` : `${readableName}`
if (toolkit) label += ` on ${toolkit}`
}
return { actionType: 'execute', label }
}
if (tool.name === 'composio-list-toolkits') {
const count = (result?.totalCount as number) ?? null
const connected = (result?.connectedCount as number) ?? null
let label = 'Listing available integrations'
if (count !== null && tool.status === 'completed') {
label = `${count} integrations available`
if (connected !== null && connected > 0) label += `, ${connected} connected`
}
return { actionType: 'list', label }
}
return null
}
export const inferRunTitleFromMessage = (content: string): string | undefined => {
const { message } = parseAttachedFiles(content)
const normalized = message.replace(/\s+/g, ' ').trim()