diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx new file mode 100644 index 00000000..fc416a39 --- /dev/null +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -0,0 +1,340 @@ +"use client" + +import * as React from "react" +import { useState, useEffect, useCallback } from "react" +import { Database, Loader2, Plug } from "lucide-react" + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Switch } from "@/components/ui/switch" +import { Separator } from "@/components/ui/separator" +import { toast } from "@/lib/toast" + +interface ProviderState { + isConnected: boolean + isLoading: boolean + isConnecting: boolean +} + +interface ConnectorsPopoverProps { + children: React.ReactNode +} + +export function ConnectorsPopover({ children }: ConnectorsPopoverProps) { + const [open, setOpen] = useState(false) + const [providers, setProviders] = useState([]) + const [providersLoading, setProvidersLoading] = useState(true) + const [providerStates, setProviderStates] = useState>({}) + + // Granola state + const [granolaEnabled, setGranolaEnabled] = useState(false) + const [granolaLoading, setGranolaLoading] = useState(true) + + // Load available providers on mount + useEffect(() => { + async function loadProviders() { + try { + setProvidersLoading(true) + const result = await window.ipc.invoke('oauth:list-providers', null) + setProviders(result.providers || []) + } catch (error) { + console.error('Failed to get available providers:', error) + setProviders([]) + } finally { + setProvidersLoading(false) + } + } + loadProviders() + }, []) + + // Load Granola config + const refreshGranolaConfig = useCallback(async () => { + try { + setGranolaLoading(true) + const result = await window.ipc.invoke('granola:getConfig', null) + setGranolaEnabled(result.enabled) + } catch (error) { + console.error('Failed to load Granola config:', error) + setGranolaEnabled(false) + } finally { + setGranolaLoading(false) + } + }, []) + + // Update Granola config + const handleGranolaToggle = useCallback(async (enabled: boolean) => { + try { + setGranolaLoading(true) + await window.ipc.invoke('granola:setConfig', { enabled }) + setGranolaEnabled(enabled) + toast(enabled ? 'Granola sync enabled' : 'Granola sync disabled', 'success') + } catch (error) { + console.error('Failed to update Granola config:', error) + toast('Failed to update Granola sync settings', 'error') + } finally { + setGranolaLoading(false) + } + }, []) + + // Check connection status for all providers + const refreshAllStatuses = useCallback(async () => { + // Refresh Granola + refreshGranolaConfig() + + // Refresh OAuth providers + if (providers.length === 0) return + + const newStates: Record = {} + + await Promise.all( + providers.map(async (provider) => { + try { + const result = await window.ipc.invoke('oauth:is-connected', { provider }) + newStates[provider] = { + isConnected: result.isConnected, + isLoading: false, + isConnecting: false, + } + } catch (error) { + console.error(`Failed to check connection status for ${provider}:`, error) + newStates[provider] = { + isConnected: false, + isLoading: false, + isConnecting: false, + } + } + }) + ) + + setProviderStates(newStates) + }, [providers, refreshGranolaConfig]) + + // Refresh statuses when popover opens or providers list changes + useEffect(() => { + if (open) { + refreshAllStatuses() + } + }, [open, providers, refreshAllStatuses]) + + // Connect to a provider + const handleConnect = useCallback(async (provider: string) => { + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isConnecting: true } + })) + + try { + const result = await window.ipc.invoke('oauth:connect', { provider }) + + if (result.success) { + toast(`Successfully connected to ${provider}`, 'success') + // Refresh the status after successful connection + const checkResult = await window.ipc.invoke('oauth:is-connected', { provider }) + setProviderStates(prev => ({ + ...prev, + [provider]: { + isConnected: checkResult.isConnected, + isLoading: false, + isConnecting: false, + } + })) + } else { + toast(result.error || `Failed to connect to ${provider}`, 'error') + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isConnecting: false } + })) + } + } catch (error) { + console.error('Failed to connect:', error) + toast(`Failed to connect to ${provider}`, 'error') + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isConnecting: false } + })) + } + }, []) + + // Disconnect from a provider + const handleDisconnect = useCallback(async (provider: string) => { + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isLoading: true } + })) + + try { + const result = await window.ipc.invoke('oauth:disconnect', { provider }) + + if (result.success) { + toast(`Disconnected from ${provider}`, 'success') + setProviderStates(prev => ({ + ...prev, + [provider]: { + isConnected: false, + isLoading: false, + isConnecting: false, + } + })) + } else { + toast(`Failed to disconnect from ${provider}`, 'error') + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isLoading: false } + })) + } + } catch (error) { + console.error('Failed to disconnect:', error) + toast(`Failed to disconnect from ${provider}`, 'error') + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isLoading: false } + })) + } + }, []) + + return ( + + + {children} + + +
+

Connectors

+

+ Connect accounts to sync data +

+
+
+ {/* Data Sources Section */} +
+ Data Sources +
+ + {/* Granola */} +
+
+
+ +
+
+ Granola + + Sync meeting notes + +
+
+
+ {granolaLoading && ( + + )} + +
+
+ + + + {/* OAuth Connectors Section */} +
+ Accounts +
+ + {providersLoading ? ( +
+ +
+ ) : providers.length === 0 ? ( +
+ No account connectors available +
+ ) : ( +
+ {providers.map((provider) => { + const state = providerStates[provider] || { + isConnected: false, + isLoading: true, + isConnecting: false, + } + const displayName = provider.charAt(0).toUpperCase() + provider.slice(1) + + return ( +
+
+
+ +
+
+ + {displayName} + + {state.isLoading ? ( + + Checking... + + ) : ( + + {state.isConnected ? "Connected" : "Not Connected"} + + )} +
+
+
+ {state.isConnected ? ( + + ) : ( + + )} +
+
+ ) + })} +
+ )} +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 21c99710..aabfc407 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -1,24 +1,21 @@ "use client" import * as React from "react" -import { useState, useEffect, useCallback } from "react" +import { useState } from "react" import { CalendarDays, ChevronRight, ChevronsDownUp, ChevronsUpDown, Copy, - Database, File, FilePlus, Folder, FolderPlus, - Loader2, Mail, Microscope, Network, Pencil, - Plug, Plus, Trash2, } from "lucide-react" @@ -46,9 +43,6 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import { Switch } from "@/components/ui/switch" import { ContextMenu, ContextMenuContent, @@ -58,7 +52,6 @@ import { } from "@/components/ui/context-menu" import { Input } from "@/components/ui/input" import { useSidebarSection } from "@/contexts/sidebar-context" -import { useOAuth, useAvailableProviders } from "@/hooks/useOAuth" import { toast } from "@/lib/toast" interface TreeNode { @@ -112,50 +105,6 @@ const agentPresets = [ }, ] -/** - * Hook for managing Granola sync config - */ -function useGranolaConfig() { - const [enabled, setEnabled] = useState(false) - const [isLoading, setIsLoading] = useState(true) - - const loadConfig = useCallback(async () => { - try { - setIsLoading(true) - const result = await window.ipc.invoke('granola:getConfig', null) - setEnabled(result.enabled) - } catch (error) { - console.error('Failed to load Granola config:', error) - setEnabled(false) - } finally { - setIsLoading(false) - } - }, []) - - useEffect(() => { - loadConfig() - }, [loadConfig]) - - const updateConfig = useCallback(async (newEnabled: boolean) => { - try { - setIsLoading(true) - await window.ipc.invoke('granola:setConfig', { enabled: newEnabled }) - setEnabled(newEnabled) - toast( - newEnabled ? 'Granola sync enabled' : 'Granola sync disabled', - 'success' - ) - } catch (error) { - console.error('Failed to update Granola config:', error) - toast('Failed to update Granola sync settings', 'error') - } finally { - setIsLoading(false) - } - }, []) - - return { enabled, isLoading, updateConfig } -} - export function SidebarContentPanel({ tree, selectedPath, @@ -476,7 +425,7 @@ function Tree({ ) } -// Agents Section with Connected Accounts +// Agents Section function AgentsSection() { return ( <> @@ -509,135 +458,7 @@ function AgentsSection() { - - {/* Connectors (Connected Accounts) */} - - - {/* Data Sources */} - ) } -// Data Sources Section (Granola sync, etc.) -function DataSourcesSection() { - const { enabled: granolaEnabled, isLoading: granolaLoading, updateConfig: updateGranolaConfig } = useGranolaConfig() - - return ( - - Data Sources - - - -
- - -
- Granola Sync - - Sync notes from Granola - -
- {granolaLoading && ( - - )} -
-
-
-
-
- ) -} - -// Connectors Section (formerly Connected Accounts) -function ConnectorsSection() { - const { providers, isLoading: providersLoading } = useAvailableProviders() - - return ( - - Connectors - - - {providersLoading ? ( - - - - Loading... - - - ) : providers.length === 0 ? ( - - - No connectors available - - - ) : ( - providers.map((provider) => ( - - )) - )} - - - - ) -} - -function ProviderItem({ provider }: { provider: string }) { - const { isConnected, isLoading, isConnecting, connect, disconnect } = useOAuth(provider) - const providerDisplayName = provider.charAt(0).toUpperCase() + provider.slice(1) - - return ( - -
-
- - {providerDisplayName} - {isLoading ? ( - - ) : ( - - {isConnected ? "Connected" : "Not Connected"} - - )} -
-
- {isConnected ? ( - - ) : ( - - )} -
-
-
- ) -} diff --git a/apps/x/apps/renderer/src/components/sidebar-icon.tsx b/apps/x/apps/renderer/src/components/sidebar-icon.tsx index 36ab6111..cb076358 100644 --- a/apps/x/apps/renderer/src/components/sidebar-icon.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-icon.tsx @@ -5,6 +5,7 @@ import { Bot, Brain, HelpCircle, + Plug, Settings, Ship, Trash2, @@ -17,6 +18,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" import { type ActiveSection, useSidebarSection } from "@/contexts/sidebar-context" +import { ConnectorsPopover } from "@/components/connectors-popover" type NavItem = { id: ActiveSection @@ -37,7 +39,6 @@ const navItems: NavItem[] = [ ] const secondaryItems: SecondaryItem[] = [ - { id: "settings", title: "Settings", icon: Settings }, { id: "trash", title: "Trash", icon: Trash2 }, { id: "help", title: "Help", icon: HelpCircle }, ] @@ -78,6 +79,29 @@ export function SidebarIcon() { {/* Secondary navigation (bottom) */}