Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

302 changes: 302 additions & 0 deletions app/src/components/composio/ComposioConnectModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
/**
* Modal for connecting / managing a Composio toolkit.
*
* Mirrors the flow, positioning, and portal/backdrop plumbing of
* `SkillSetupModal` so the two feel identical to the user:
*
* disconnected → "Connect" button → POST composio_authorize →
* open connectUrl via tauri-opener → poll listConnections until
* the toolkit flips to ACTIVE → "Connected" success screen with
* a "Disconnect" action.
*
* Redundant refetches from the polling hook in `useComposioIntegrations`
* keep the Skills page badge in sync too, so the card reflects the new
* state as soon as the modal closes.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';

import { authorize, deleteConnection, listConnections } from '../../lib/composio/composioApi';
import { type ComposioConnection, deriveComposioState } from '../../lib/composio/types';
import { openUrl } from '../../utils/openUrl';
import type { ComposioToolkitMeta } from './toolkitMeta';

type Phase = 'idle' | 'authorizing' | 'waiting' | 'connected' | 'disconnecting' | 'error';

interface ComposioConnectModalProps {
toolkit: ComposioToolkitMeta;
/** Existing connection (if any) from the hook. */
connection?: ComposioConnection;
/** Invoked on successful connect/disconnect so the parent can refresh. */
onChanged?: () => void;
onClose: () => void;
}

const POLL_INTERVAL_MS = 4_000;
const POLL_TIMEOUT_MS = 5 * 60 * 1_000;

export default function ComposioConnectModal({
toolkit,
connection,
onChanged,
onClose,
}: ComposioConnectModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const pollTimerRef = useRef<number | null>(null);
const pollDeadlineRef = useRef<number>(0);

const initiallyConnected = deriveComposioState(connection) === 'connected';
const [phase, setPhase] = useState<Phase>(initiallyConnected ? 'connected' : 'idle');
const [error, setError] = useState<string | null>(null);
const [connectUrl, setConnectUrl] = useState<string | null>(null);
const [activeConnection, setActiveConnection] = useState<ComposioConnection | undefined>(
connection
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Escape to close
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onClose]);

// Focus trap
useEffect(() => {
const previousFocus = document.activeElement as HTMLElement | null;
modalRef.current?.focus();
return () => {
previousFocus?.focus?.();
};
}, []);

const stopPolling = useCallback(() => {
if (pollTimerRef.current != null) {
window.clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);

// Cleanup on unmount
useEffect(() => () => stopPolling(), [stopPolling]);

const startPolling = useCallback(() => {
stopPolling();
pollDeadlineRef.current = Date.now() + POLL_TIMEOUT_MS;
const tick = async () => {
try {
const resp = await listConnections();
const hit = resp.connections.find(
c => c.toolkit.toLowerCase() === toolkit.slug.toLowerCase()
);
if (hit) {
setActiveConnection(hit);
const state = deriveComposioState(hit);
if (state === 'connected') {
stopPolling();
setPhase('connected');
setError(null);
onChanged?.();
return;
}
if (state === 'error') {
stopPolling();
setPhase('error');
setError(`Connection failed (status: ${hit.status}).`);
return;
}
}
} catch (err) {
// Swallow transient errors during polling — we'll retry on next tick.
console.warn('[composio] poll failed:', err);
}
if (Date.now() > pollDeadlineRef.current) {
stopPolling();
setPhase('error');
setError(
'Timed out waiting for OAuth to complete. Please retry or check that the browser finished the flow.'
);
}
};
// Fire once immediately, then on interval.
void tick();
pollTimerRef.current = window.setInterval(() => void tick(), POLL_INTERVAL_MS);
}, [onChanged, stopPolling, toolkit.slug]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleConnect = useCallback(async () => {
setPhase('authorizing');
setError(null);
setConnectUrl(null);
try {
const resp = await authorize(toolkit.slug);
setConnectUrl(resp.connectUrl);
await openUrl(resp.connectUrl);
setPhase('waiting');
startPolling();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setPhase('error');
setError(`Authorization failed: ${msg}`);
}
}, [startPolling, toolkit.slug]);

const handleDisconnect = useCallback(async () => {
if (!activeConnection) return;
setPhase('disconnecting');
setError(null);
try {
await deleteConnection(activeConnection.id);
setActiveConnection(undefined);
setPhase('idle');
onChanged?.();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setPhase('error');
setError(`Disconnect failed: ${msg}`);
}
}, [activeConnection, onChanged]);

const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};

const headerTitle = phase === 'connected' ? `Manage ${toolkit.name}` : `Connect ${toolkit.name}`;

const modalContent = (
<div
className="fixed inset-0 z-[9999] bg-black/30 backdrop-blur-sm flex items-center justify-center p-4"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby="composio-setup-title">
<div
ref={modalRef}
className="bg-white border border-stone-200 rounded-3xl shadow-large w-full max-w-[460px] overflow-hidden animate-fade-up focus:outline-none focus:ring-0"
style={{
animationDuration: '200ms',
animationTimingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
animationFillMode: 'both',
}}
tabIndex={-1}
onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="p-4 border-b border-stone-200">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0 pr-2">
<div className="flex items-center gap-2">
<span className="text-lg">{toolkit.icon}</span>
<h2 id="composio-setup-title" className="text-base font-semibold text-stone-900">
{headerTitle}
</h2>
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-md bg-primary-500/15 text-primary-600">
composio
</span>
</div>
<p className="text-xs text-stone-400 mt-1.5 line-clamp-2">{toolkit.description}</p>
</div>
<button
type="button"
onClick={onClose}
className="p-1 text-stone-400 hover:text-stone-900 transition-colors rounded-lg hover:bg-stone-100 flex-shrink-0"
aria-label="Close">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>

{/* Body */}
<div className="p-4 space-y-3">
{phase === 'idle' && (
<>
<p className="text-sm text-stone-600">
Connect your {toolkit.name} account through Composio. We will open a browser window
where you can grant access, and then this app will detect the connection
automatically.
</p>
<button
type="button"
onClick={() => void handleConnect()}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors">
Connect {toolkit.name}
</button>
</>
)}

{phase === 'authorizing' && (
<p className="text-sm text-stone-500">Requesting connect URL…</p>
)}

{phase === 'waiting' && (
<>
<div className="flex items-center gap-2 text-sm text-stone-700">
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
Waiting for {toolkit.name} OAuth to complete…
</div>
{connectUrl && (
<button
type="button"
onClick={() => void openUrl(connectUrl)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 text-stone-700 text-xs font-medium py-2 hover:bg-stone-100 transition-colors">
Reopen browser
</button>
)}
<p className="text-xs text-stone-400">
Complete the sign-in in your browser. This window will update when the connection is
active.
</p>
</>
)}

{phase === 'connected' && (
<>
<div className="flex items-center gap-2 text-sm text-sage-700">
<div className="w-2 h-2 rounded-full bg-sage-500" />
{toolkit.name} is connected.
</div>
{activeConnection && (
<p className="text-[11px] text-stone-400 font-mono break-all">
id: {activeConnection.id}
</p>
)}
<button
type="button"
onClick={() => void handleDisconnect()}
className="w-full rounded-xl border border-coral-200 bg-coral-50 text-coral-700 text-sm font-medium py-2.5 hover:bg-coral-100 transition-colors">
Disconnect
</button>
</>
)}

{phase === 'disconnecting' && <p className="text-sm text-stone-500">Disconnecting…</p>}

{phase === 'error' && (
<>
<div className="rounded-xl border border-coral-200 bg-coral-50 p-3">
<p className="text-sm text-coral-700">{error ?? 'Something went wrong.'}</p>
</div>
<button
type="button"
onClick={() => {
setPhase(initiallyConnected ? 'connected' : 'idle');
setError(null);
}}
className="w-full rounded-xl border border-stone-200 bg-white text-stone-700 text-sm font-medium py-2 hover:bg-stone-50 transition-colors">
Dismiss
</button>
</>
)}
</div>
</div>
</div>
);

return createPortal(modalContent, document.body);
}
Loading
Loading