mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-28 01:46:23 +02:00
add connector view
This commit is contained in:
parent
665fb67ac9
commit
9fbb7d3033
3 changed files with 367 additions and 182 deletions
340
apps/x/apps/renderer/src/components/connectors-popover.tsx
Normal file
340
apps/x/apps/renderer/src/components/connectors-popover.tsx
Normal file
|
|
@ -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<string[]>([])
|
||||
const [providersLoading, setProvidersLoading] = useState(true)
|
||||
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
|
||||
|
||||
// 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<string, ProviderState> = {}
|
||||
|
||||
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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{children}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-80 p-0"
|
||||
>
|
||||
<div className="p-4 border-b">
|
||||
<h4 className="font-semibold text-sm">Connectors</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Connect accounts to sync data
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
{/* Data Sources Section */}
|
||||
<div className="px-2 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Data Sources</span>
|
||||
</div>
|
||||
|
||||
{/* Granola */}
|
||||
<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">
|
||||
<Database className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Granola</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Sync meeting notes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{granolaLoading && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
<Switch
|
||||
checked={granolaEnabled}
|
||||
onCheckedChange={handleGranolaToggle}
|
||||
disabled={granolaLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
{/* OAuth Connectors Section */}
|
||||
<div className="px-2 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Accounts</span>
|
||||
</div>
|
||||
|
||||
{providersLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="text-center py-4 text-xs text-muted-foreground">
|
||||
No account connectors available
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{providers.map((provider) => {
|
||||
const state = providerStates[provider] || {
|
||||
isConnected: false,
|
||||
isLoading: true,
|
||||
isConnecting: false,
|
||||
}
|
||||
const displayName = provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
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">
|
||||
<Plug className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
{state.isLoading ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Checking...
|
||||
</span>
|
||||
) : (
|
||||
<Badge
|
||||
variant={state.isConnected ? "default" : "outline"}
|
||||
className="w-fit text-xs mt-0.5"
|
||||
>
|
||||
{state.isConnected ? "Connected" : "Not Connected"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{state.isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDisconnect(provider)}
|
||||
disabled={state.isLoading}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{state.isLoading ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Disconnect"
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleConnect(provider)}
|
||||
disabled={state.isConnecting || state.isLoading}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{state.isConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<boolean>(false)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(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() {
|
|||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Connectors (Connected Accounts) */}
|
||||
<ConnectorsSection />
|
||||
|
||||
{/* Data Sources */}
|
||||
<DataSourcesSection />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Data Sources Section (Granola sync, etc.)
|
||||
function DataSourcesSection() {
|
||||
const { enabled: granolaEnabled, isLoading: granolaLoading, updateConfig: updateGranolaConfig } = useGranolaConfig()
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Data Sources</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 w-full">
|
||||
<Switch
|
||||
checked={granolaEnabled}
|
||||
onCheckedChange={updateGranolaConfig}
|
||||
disabled={granolaLoading}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<Database className="size-4 shrink-0" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="truncate text-sm">Granola Sync</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Sync notes from Granola
|
||||
</span>
|
||||
</div>
|
||||
{granolaLoading && (
|
||||
<Loader2 className="size-3 animate-spin shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
// Connectors Section (formerly Connected Accounts)
|
||||
function ConnectorsSection() {
|
||||
const { providers, isLoading: providersLoading } = useAvailableProviders()
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Connectors</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{providersLoading ? (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton disabled>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
<span>Loading...</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
) : providers.length === 0 ? (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton disabled>
|
||||
<span className="text-muted-foreground">No connectors available</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
) : (
|
||||
providers.map((provider) => (
|
||||
<ProviderItem key={provider} provider={provider} />
|
||||
))
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderItem({ provider }: { provider: string }) {
|
||||
const { isConnected, isLoading, isConnecting, connect, disconnect } = useOAuth(provider)
|
||||
const providerDisplayName = provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<div className="flex items-center justify-between w-full gap-2 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Plug className="size-4 shrink-0" />
|
||||
<span className="truncate text-sm">{providerDisplayName}</span>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-3 animate-spin shrink-0" />
|
||||
) : (
|
||||
<Badge
|
||||
variant={isConnected ? "default" : "outline"}
|
||||
className="shrink-0 text-xs"
|
||||
>
|
||||
{isConnected ? "Connected" : "Not Connected"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={disconnect}
|
||||
disabled={isLoading}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={connect}
|
||||
disabled={isConnecting || isLoading}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
</>
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
<nav className="flex flex-col items-center gap-1">
|
||||
{/* Connectors */}
|
||||
<ConnectorsPopover>
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
>
|
||||
<Plug className="size-5" />
|
||||
</button>
|
||||
</ConnectorsPopover>
|
||||
|
||||
{/* Settings */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
>
|
||||
<Settings className="size-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Settings
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{secondaryItems.map((item) => (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue