feat: slack integration with composio

Allow users to ask copilot to use Slack on their behalf via Composio integration.
Adds composio client, OAuth flow, slack skill with tool catalog, and UI for
connecting Slack in onboarding and connectors popover.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
tusharmagar 2026-02-02 21:34:47 +05:30 committed by Ramnique Singh
parent bbe82c124d
commit aa2a830f23
18 changed files with 2309 additions and 81 deletions

View file

@ -4,6 +4,16 @@ import { URL } from 'url';
const OAUTH_CALLBACK_PATH = '/oauth/callback';
const DEFAULT_PORT = 8080;
/** Escape HTML special characters to prevent XSS */
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
export interface AuthServerResult {
server: Server;
port: number;
@ -15,7 +25,7 @@ export interface AuthServerResult {
*/
export function createAuthServer(
port: number = DEFAULT_PORT,
onCallback: (code: string, state: string) => void
onCallback: (code: string, state: string) => void | Promise<void>
): Promise<AuthServerResult> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
@ -46,7 +56,7 @@ export function createAuthServer(
</head>
<body>
<h1 class="error">Authorization Failed</h1>
<p>Error: ${error}</p>
<p>Error: ${escapeHtml(error)}</p>
<p>You can close this window.</p>
<script>setTimeout(() => window.close(), 3000);</script>
</body>
@ -55,48 +65,28 @@ export function createAuthServer(
return;
}
if (code && state) {
onCallback(code, state);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>Authorization Successful</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
.success { color: #2e7d32; }
</style>
</head>
<body>
<h1 class="success">Authorization Successful</h1>
<p>You can close this window.</p>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>
`);
} else {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>OAuth Error</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
.error { color: #d32f2f; }
</style>
</head>
<body>
<h1 class="error">Invalid Request</h1>
<p>Missing code or state parameter.</p>
<p>You can close this window.</p>
<script>setTimeout(() => window.close(), 3000);</script>
</body>
</html>
`);
}
// Handle callback - either traditional OAuth with code/state or Composio-style notification
// Composio callbacks may not have code/state, just a notification that the flow completed
onCallback(code || '', state || '');
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>Authorization Successful</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
.success { color: #2e7d32; }
</style>
</head>
<body>
<h1 class="success">Authorization Successful</h1>
<p>You can close this window.</p>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>
`);
} else {
res.writeHead(404);
res.end('Not Found');

View file

@ -0,0 +1,296 @@
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 } from '@x/core/dist/composio/types.js';
const REDIRECT_URI = 'http://localhost:8081/oauth/callback';
// Store active OAuth flows
const activeFlows = new Map<string, {
toolkitSlug: string;
connectedAccountId: string;
authConfigId: string;
}>();
/**
* Emit Composio connection event to all renderer windows
*/
export function emitComposioEvent(event: { toolkitSlug: string; success: boolean; error?: string }): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('composio:didConnect', event);
}
}
}
/**
* Check if Composio is configured with an API key
*/
export function isConfigured(): { configured: boolean } {
return { configured: composioClient.isConfigured() };
}
/**
* Set the Composio API key
*/
export function setApiKey(apiKey: string): { success: boolean; error?: string } {
try {
composioClient.setApiKey(apiKey);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to set API key',
};
}
}
/**
* Initiate OAuth connection for a toolkit
*/
export async function initiateConnection(toolkitSlug: string): Promise<{
success: boolean;
redirectUrl?: string;
connectedAccountId?: string;
error?: string;
}> {
try {
console.log(`[Composio] Initiating connection for ${toolkitSlug}...`);
// Check if already connected
if (composioAccountsRepo.isConnected(toolkitSlug)) {
return { success: true };
}
// Get toolkit to check auth schemes
const toolkit = await composioClient.getToolkit(toolkitSlug);
// Check for managed OAuth2
if (!toolkit.composio_managed_auth_schemes.includes('OAUTH2')) {
return {
success: false,
error: `Toolkit ${toolkitSlug} does not support managed OAuth2`,
};
}
// Find or create managed OAuth2 auth config
const authConfigs = await composioClient.listAuthConfigs(toolkitSlug, null, true);
let authConfigId: string;
const managedOauth2 = authConfigs.items.find(
cfg => cfg.auth_scheme === 'OAUTH2' && cfg.is_composio_managed
);
if (managedOauth2) {
authConfigId = managedOauth2.id;
} else {
// Create new managed auth config
const created = await composioClient.createAuthConfig({
toolkit: { slug: toolkitSlug },
auth_config: {
type: 'use_composio_managed_auth',
name: `rowboat-${toolkitSlug}`,
},
});
authConfigId = created.auth_config.id;
}
// Create connected account with callback URL
const callbackUrl = REDIRECT_URI;
const response = await composioClient.createConnectedAccount({
auth_config: { id: authConfigId },
connection: {
user_id: 'rowboat-user',
callback_url: callbackUrl,
},
});
const connectedAccountId = response.id;
// Safely extract redirectUrl with type checking
const connectionVal = response.connectionData?.val;
const redirectUrl = typeof connectionVal === 'object' && connectionVal !== null && 'redirectUrl' in connectionVal
? String((connectionVal as Record<string, unknown>).redirectUrl)
: undefined;
if (!redirectUrl) {
return {
success: false,
error: 'No redirect URL received from Composio',
};
}
// Store flow state
const flowKey = `${toolkitSlug}-${Date.now()}`;
activeFlows.set(flowKey, {
toolkitSlug,
connectedAccountId,
authConfigId,
});
// Save initial account state
const account: LocalConnectedAccount = {
id: connectedAccountId,
authConfigId,
status: 'INITIATED',
toolkitSlug,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
composioAccountsRepo.saveAccount(account);
// Set up callback server
let cleanupTimeout: NodeJS.Timeout;
const { server } = await createAuthServer(8081, async (_code, _state) => {
// OAuth callback received - sync the account status
try {
const accountStatus = await composioClient.getConnectedAccount(connectedAccountId);
composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status);
if (accountStatus.status === 'ACTIVE') {
emitComposioEvent({ toolkitSlug, success: true });
} else {
emitComposioEvent({
toolkitSlug,
success: false,
error: `Connection status: ${accountStatus.status}`,
});
}
} catch (error) {
console.error('[Composio] Failed to sync account status:', error);
emitComposioEvent({
toolkitSlug,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
activeFlows.delete(flowKey);
server.close();
clearTimeout(cleanupTimeout);
}
});
// Timeout for abandoned flows (5 minutes)
cleanupTimeout = setTimeout(() => {
if (activeFlows.has(flowKey)) {
console.log(`[Composio] Cleaning up abandoned flow for ${toolkitSlug}`);
activeFlows.delete(flowKey);
server.close();
emitComposioEvent({
toolkitSlug,
success: false,
error: 'OAuth flow timed out',
});
}
}, 5 * 60 * 1000);
// Open browser for OAuth
shell.openExternal(redirectUrl);
return {
success: true,
redirectUrl,
connectedAccountId,
};
} catch (error) {
console.error('[Composio] Connection initiation failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Get connection status for a toolkit
*/
export async function getConnectionStatus(toolkitSlug: string): Promise<{
isConnected: boolean;
status?: string;
}> {
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (!account) {
return { isConnected: false };
}
return {
isConnected: account.status === 'ACTIVE',
status: account.status,
};
}
/**
* Sync connection status with Composio API
*/
export async function syncConnection(
toolkitSlug: string,
connectedAccountId: string
): Promise<{ status: string }> {
try {
const accountStatus = await composioClient.getConnectedAccount(connectedAccountId);
composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status);
return { status: accountStatus.status };
} catch (error) {
console.error('[Composio] Failed to sync connection:', error);
return { status: 'FAILED' };
}
}
/**
* Disconnect a toolkit
*/
export async function disconnect(toolkitSlug: string): Promise<{ success: boolean }> {
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
composioAccountsRepo.deleteAccount(toolkitSlug);
return { success: true };
}
}
/**
* List connected toolkits
*/
export function listConnected(): { toolkits: string[] } {
return { toolkits: composioAccountsRepo.getConnectedToolkits() };
}
/**
* Execute a Composio action
*/
export async function executeAction(
actionSlug: string,
toolkitSlug: string,
input: Record<string, unknown>
): Promise<{ success: boolean; data: unknown; error?: string }> {
try {
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (!account || account.status !== 'ACTIVE') {
return {
success: false,
data: null,
error: `Toolkit ${toolkitSlug} is not connected`,
};
}
const result = await composioClient.executeAction(actionSlug, account.id, input);
return result;
} catch (error) {
console.error('[Composio] Action execution failed:', error);
return {
success: false,
data: null,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}

View file

@ -17,11 +17,12 @@ import { bus } from '@x/core/dist/runs/bus.js';
import type { FSWatcher } from 'chokidar';
import fs from 'node:fs/promises';
import z from 'zod';
import { RunEvent } from 'packages/shared/dist/runs.js';
import { RunEvent } from '@x/shared/dist/runs.js';
import container from '@x/core/dist/di/container.js';
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import * as composioHandler from './composio-handler.js';
type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels;
@ -364,5 +365,30 @@ export function setupIpcHandlers() {
'auth:logout': async () => {
return await logoutRowboat();
},
// Composio integration handlers
'composio:is-configured': async () => {
return composioHandler.isConfigured();
},
'composio:set-api-key': async (_event, args) => {
return composioHandler.setApiKey(args.apiKey);
},
'composio:initiate-connection': async (_event, args) => {
return composioHandler.initiateConnection(args.toolkitSlug);
},
'composio:get-connection-status': async (_event, args) => {
return composioHandler.getConnectionStatus(args.toolkitSlug);
},
'composio:sync-connection': async (_event, args) => {
return composioHandler.syncConnection(args.toolkitSlug, args.connectedAccountId);
},
'composio:disconnect': async (_event, args) => {
return composioHandler.disconnect(args.toolkitSlug);
},
'composio:list-connected': async () => {
return composioHandler.listConnected();
},
'composio:execute-action': async (_event, args) => {
return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input);
},
});
}

View file

@ -0,0 +1,94 @@
"use client"
import { useEffect, useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
interface ComposioApiKeyModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (apiKey: string) => void
isSubmitting?: boolean
}
export function ComposioApiKeyModal({
open,
onOpenChange,
onSubmit,
isSubmitting = false,
}: ComposioApiKeyModalProps) {
const [apiKey, setApiKey] = useState("")
useEffect(() => {
if (!open) {
setApiKey("")
}
}, [open])
const trimmedApiKey = apiKey.trim()
const isValid = trimmedApiKey.length > 0
const handleSubmit = () => {
if (!isValid || isSubmitting) return
onSubmit(trimmedApiKey)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Enter Composio API Key</DialogTitle>
<DialogDescription>
Get your API key from{" "}
<a
href="https://app.composio.dev/settings"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
app.composio.dev/settings
</a>
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground" htmlFor="composio-api-key">
API Key
</label>
<Input
id="composio-api-key"
type="password"
placeholder="Enter your Composio API key"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
handleSubmit()
}
}}
autoFocus
/>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
Continue
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback } from "react"
import { Loader2, Mic, Mail } from "lucide-react"
import { Loader2, Mic, Mail, MessageSquare } from "lucide-react"
import {
Popover,
@ -17,6 +17,7 @@ import {
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
import { toast } from "sonner"
interface ProviderState {
@ -40,6 +41,12 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
const [granolaEnabled, setGranolaEnabled] = useState(false)
const [granolaLoading, setGranolaLoading] = useState(true)
// Composio/Slack state
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
const [slackConnected, setSlackConnected] = useState(false)
const [slackLoading, setSlackLoading] = useState(true)
const [slackConnecting, setSlackConnecting] = useState(false)
// Load available providers on mount
useEffect(() => {
async function loadProviders() {
@ -86,11 +93,89 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
}
}, [])
// Load Slack connection status
const refreshSlackStatus = useCallback(async () => {
try {
setSlackLoading(true)
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' })
setSlackConnected(result.isConnected)
} catch (error) {
console.error('Failed to load Slack status:', error)
setSlackConnected(false)
} finally {
setSlackLoading(false)
}
}, [])
// Connect to Slack via Composio
const startSlackConnect = useCallback(async () => {
try {
setSlackConnecting(true)
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' })
if (!result.success) {
toast.error(result.error || 'Failed to connect to Slack')
setSlackConnecting(false)
}
// Success will be handled by composio:didConnect event
} catch (error) {
console.error('Failed to connect to Slack:', error)
toast.error('Failed to connect to Slack')
setSlackConnecting(false)
}
}, [])
// Handle Slack connect button click
const handleConnectSlack = useCallback(async () => {
// Check if Composio is configured
const configResult = await window.ipc.invoke('composio:is-configured', null)
if (!configResult.configured) {
setComposioApiKeyOpen(true)
return
}
await startSlackConnect()
}, [startSlackConnect])
// Handle Composio API key submission
const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
try {
await window.ipc.invoke('composio:set-api-key', { apiKey })
setComposioApiKeyOpen(false)
toast.success('Composio API key saved')
// Now start the Slack connection
await startSlackConnect()
} catch (error) {
console.error('Failed to save Composio API key:', error)
toast.error('Failed to save API key')
}
}, [startSlackConnect])
// Disconnect from Slack
const handleDisconnectSlack = useCallback(async () => {
try {
setSlackLoading(true)
const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'slack' })
if (result.success) {
setSlackConnected(false)
toast.success('Disconnected from Slack')
} else {
toast.error('Failed to disconnect from Slack')
}
} catch (error) {
console.error('Failed to disconnect from Slack:', error)
toast.error('Failed to disconnect from Slack')
} finally {
setSlackLoading(false)
}
}, [])
// Check connection status for all providers
const refreshAllStatuses = useCallback(async () => {
// Refresh Granola
refreshGranolaConfig()
// Refresh Slack status
refreshSlackStatus()
// Refresh OAuth providers
if (providers.length === 0) return
@ -117,7 +202,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
)
setProviderStates(newStates)
}, [providers, refreshGranolaConfig])
}, [providers, refreshGranolaConfig, refreshSlackStatus])
// Refresh statuses when popover opens or providers list changes
useEffect(() => {
@ -161,6 +246,26 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
return cleanup
}, [refreshAllStatuses])
// Listen for Composio connection events
useEffect(() => {
const cleanup = window.ipc.on('composio:didConnect', (event) => {
const { toolkitSlug, success, error } = event
if (toolkitSlug === 'slack') {
setSlackConnected(success)
setSlackConnecting(false)
if (success) {
toast.success('Connected to Slack')
} else {
toast.error(error || 'Failed to connect to Slack')
}
}
})
return cleanup
}, [])
// Connect to a provider
const handleConnect = useCallback(async (provider: string) => {
setProviderStates(prev => ({
@ -289,6 +394,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
}
return (
<>
<Popover open={open} onOpenChange={setOpen}>
{tooltip ? (
<Tooltip open={open ? false : undefined}>
@ -368,10 +474,71 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
{/* Fireflies */}
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
<Separator className="my-2" />
{/* Team Communication Section - Slack */}
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Team Communication</span>
</div>
{/* Slack */}
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{slackLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
)}
</div>
</div>
<div className="shrink-0">
{slackLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : slackConnected ? (
<Button
variant="outline"
size="sm"
onClick={handleDisconnectSlack}
className="h-7 px-2 text-xs"
>
Disconnect
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={handleConnectSlack}
disabled={slackConnecting}
className="h-7 px-2 text-xs"
>
{slackConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
<ComposioApiKeyModal
open={composioApiKeyOpen}
onOpenChange={setComposioApiKeyOpen}
onSubmit={handleComposioApiKeySubmit}
isSubmitting={slackConnecting}
/>
</>
)
}

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback } from "react"
import { Loader2, Mic, Mail, CheckCircle2, Sailboat } from "lucide-react"
import { Loader2, Mic, Mail, CheckCircle2, Sailboat, MessageSquare } from "lucide-react"
import {
Dialog,
@ -14,6 +14,7 @@ import {
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
import { toast } from "sonner"
interface ProviderState {
@ -41,6 +42,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [granolaEnabled, setGranolaEnabled] = useState(false)
const [granolaLoading, setGranolaLoading] = useState(true)
// Composio/Slack state
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
const [slackConnected, setSlackConnected] = useState(false)
const [slackLoading, setSlackLoading] = useState(true)
const [slackConnecting, setSlackConnecting] = useState(false)
// Track connected providers for the completion step
const connectedProviders = Object.entries(providerStates)
.filter(([, state]) => state.isConnected)
@ -94,11 +101,70 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
}
}, [])
// Load Slack connection status
const refreshSlackStatus = useCallback(async () => {
try {
setSlackLoading(true)
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' })
setSlackConnected(result.isConnected)
} catch (error) {
console.error('Failed to load Slack status:', error)
setSlackConnected(false)
} finally {
setSlackLoading(false)
}
}, [])
// Start Slack connection
const startSlackConnect = useCallback(async () => {
try {
setSlackConnecting(true)
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' })
if (!result.success) {
toast.error(result.error || 'Failed to connect to Slack')
setSlackConnecting(false)
}
// Success will be handled by composio:didConnect event
} catch (error) {
console.error('Failed to connect to Slack:', error)
toast.error('Failed to connect to Slack')
setSlackConnecting(false)
}
}, [])
// Connect to Slack via Composio (checks if configured first)
const handleConnectSlack = useCallback(async () => {
// Check if Composio is configured
const configResult = await window.ipc.invoke('composio:is-configured', null)
if (!configResult.configured) {
setComposioApiKeyOpen(true)
return
}
await startSlackConnect()
}, [startSlackConnect])
// Handle Composio API key submission
const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
try {
await window.ipc.invoke('composio:set-api-key', { apiKey })
setComposioApiKeyOpen(false)
toast.success('Composio API key saved')
// Now start the Slack connection
await startSlackConnect()
} catch (error) {
console.error('Failed to save Composio API key:', error)
toast.error('Failed to save API key')
}
}, [startSlackConnect])
// Check connection status for all providers
const refreshAllStatuses = useCallback(async () => {
// Refresh Granola
refreshGranolaConfig()
// Refresh Slack status
refreshSlackStatus()
// Refresh OAuth providers
if (providers.length === 0) return
@ -125,7 +191,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)
setProviderStates(newStates)
}, [providers, refreshGranolaConfig])
}, [providers, refreshGranolaConfig, refreshSlackStatus])
// Refresh statuses when modal opens or providers list changes
useEffect(() => {
@ -159,6 +225,26 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
return cleanup
}, [])
// Listen for Composio connection events
useEffect(() => {
const cleanup = window.ipc.on('composio:didConnect', (event) => {
const { toolkitSlug, success, error } = event
if (toolkitSlug === 'slack') {
setSlackConnected(success)
setSlackConnecting(false)
if (success) {
toast.success('Connected to Slack')
} else {
toast.error(error || 'Failed to connect to Slack')
}
}
})
return cleanup
}, [])
// Connect to a provider
const handleConnect = useCallback(async (provider: string) => {
setProviderStates(prev => ({
@ -291,6 +377,50 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
</div>
)
// Render Slack row
const renderSlackRow = () => (
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-5" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{slackLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
)}
</div>
</div>
<div className="shrink-0">
{slackLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : slackConnected ? (
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
<span>Connected</span>
</div>
) : (
<Button
variant="default"
size="sm"
onClick={handleConnectSlack}
disabled={slackConnecting}
>
{slackConnecting ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
)
// Step 0: Welcome
const WelcomeStep = () => (
<div className="flex flex-col items-center text-center">
@ -358,6 +488,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
{renderGranolaRow()}
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-5" />, 'AI meeting transcripts')}
</div>
{/* Team Communication Section */}
<div className="space-y-2">
<div className="px-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Team Communication</span>
</div>
{renderSlackRow()}
</div>
</>
)}
</div>
@ -375,7 +513,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
// Step 2: Completion
const CompletionStep = () => {
const hasConnections = connectedProviders.length > 0 || granolaEnabled
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected
return (
<div className="flex flex-col items-center text-center">
@ -416,6 +554,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
<span>Granola (Local meeting notes)</span>
</div>
)}
{slackConnected && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="size-4 text-green-600" />
<span>Slack (Team communication)</span>
</div>
)}
</div>
</div>
</div>
@ -429,6 +573,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
}
return (
<>
<ComposioApiKeyModal
open={composioApiKeyOpen}
onOpenChange={setComposioApiKeyOpen}
onSubmit={handleComposioApiKeySubmit}
isSubmitting={slackConnecting}
/>
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
className="w-[60vw] max-w-3xl max-h-[80vh] overflow-y-auto"
@ -442,5 +593,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
{currentStep === 2 && <CompletionStep />}
</DialogContent>
</Dialog>
</>
)
}