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

@ -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>
</>
)
}