Merge branch 'dev' of github.com:rowboatlabs/rowboat into dev

This commit is contained in:
tusharmagar 2026-02-17 13:53:38 +05:30
commit 728857da1d
15 changed files with 1292 additions and 206 deletions

View file

@ -43,6 +43,7 @@
"motion": "^12.23.26",
"nanoid": "^5.1.6",
"posthog-js": "^1.332.0",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"sonner": "^2.0.7",

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback } from "react"
import { Loader2, Mic, Mail, MessageSquare } from "lucide-react"
import { AlertTriangle, Loader2, Mic, Mail, MessageSquare } from "lucide-react"
import {
Popover,
@ -28,17 +28,28 @@ interface ProviderState {
isConnecting: boolean
}
interface ProviderStatus {
error?: string
}
interface ConnectorsPopoverProps {
children: React.ReactNode
tooltip?: string
open?: boolean
onOpenChange?: (open: boolean) => void
}
export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) {
const [open, setOpen] = useState(false)
export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenChange }: ConnectorsPopoverProps) {
const [openInternal, setOpenInternal] = useState(false)
const isControlled = typeof openProp === "boolean"
const open = isControlled ? openProp : openInternal
const setOpen = onOpenChange ?? setOpenInternal
const [providers, setProviders] = useState<string[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
const [providerStatus, setProviderStatus] = useState<Record<string, ProviderStatus>>({})
const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false)
const [googleClientIdDescription, setGoogleClientIdDescription] = useState<string | undefined>(undefined)
// Granola state
const [granolaEnabled, setGranolaEnabled] = useState(false)
@ -184,25 +195,35 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
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,
}
try {
const result = await window.ipc.invoke('oauth:getState', null)
const config = result.config || {}
const statusMap: Record<string, ProviderStatus> = {}
for (const provider of providers) {
const providerConfig = config[provider]
newStates[provider] = {
isConnected: providerConfig?.connected ?? false,
isLoading: false,
isConnecting: false,
}
})
)
if (providerConfig?.error) {
statusMap[provider] = { error: providerConfig.error }
}
}
setProviderStatus(statusMap)
} catch (error) {
console.error('Failed to check connection statuses:', error)
for (const provider of providers) {
newStates[provider] = {
isConnected: false,
isLoading: false,
isConnecting: false,
}
}
setProviderStatus({})
}
setProviderStates(newStates)
}, [providers, refreshGranolaConfig, refreshSlackStatus])
@ -302,6 +323,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
// Connect to a provider
const handleConnect = useCallback(async (provider: string) => {
if (provider === 'google') {
setGoogleClientIdDescription(undefined)
const existingClientId = getGoogleClientId()
if (!existingClientId) {
setGoogleClientIdOpen(true)
@ -317,6 +339,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
setGoogleClientId(clientId)
setGoogleClientIdOpen(false)
setGoogleClientIdDescription(undefined)
startConnect('google', clientId)
}, [startConnect])
@ -361,6 +384,10 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
}
}, [])
const hasProviderError = Object.values(providerStatus).some(
(status) => Boolean(status?.error)
)
// Helper to render an OAuth provider row
const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => {
const state = providerStates[provider] || {
@ -368,6 +395,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
isLoading: true,
isConnecting: false,
}
const needsReconnect = Boolean(providerStatus[provider]?.error)
return (
<div
@ -382,6 +410,8 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
<span className="text-sm font-medium truncate">{displayName}</span>
{state.isLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : needsReconnect ? (
<span className="text-xs text-amber-600">Needs reconnect</span>
) : (
<span className="text-xs text-muted-foreground truncate">{description}</span>
)}
@ -390,6 +420,24 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
<div className="shrink-0">
{state.isLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : needsReconnect ? (
<Button
variant="default"
size="sm"
onClick={() => {
if (provider === 'google') {
setGoogleClientIdDescription(
"To keep your Google account connected, please re-enter your client ID. You only need to do this once."
)
setGoogleClientIdOpen(true)
return
}
startConnect(provider)
}}
className="h-7 px-2 text-xs"
>
Reconnect
</Button>
) : state.isConnected ? (
<Button
variant="outline"
@ -423,9 +471,15 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
<>
<GoogleClientIdModal
open={googleClientIdOpen}
onOpenChange={setGoogleClientIdOpen}
onOpenChange={(nextOpen) => {
setGoogleClientIdOpen(nextOpen)
if (!nextOpen) {
setGoogleClientIdDescription(undefined)
}
}}
onSubmit={handleGoogleClientIdSubmit}
isSubmitting={providerStates.google?.isConnecting ?? false}
description={googleClientIdDescription}
/>
<Popover open={open} onOpenChange={setOpen}>
{tooltip ? (
@ -451,7 +505,12 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
className="w-80 p-0"
>
<div className="p-4 border-b">
<h4 className="font-semibold text-sm">Connectors</h4>
<h4 className="font-semibold text-sm flex items-center gap-1.5">
Connected accounts
{hasProviderError && (
<AlertTriangle className="size-3 text-amber-500/80 animate-pulse" />
)}
</h4>
<p className="text-xs text-muted-foreground mt-1">
Connect accounts to sync data
</p>

View file

@ -19,6 +19,7 @@ interface GoogleClientIdModalProps {
onOpenChange: (open: boolean) => void
onSubmit: (clientId: string) => void
isSubmitting?: boolean
description?: string
}
export function GoogleClientIdModal({
@ -26,6 +27,7 @@ export function GoogleClientIdModal({
onOpenChange,
onSubmit,
isSubmitting = false,
description,
}: GoogleClientIdModalProps) {
const [clientId, setClientId] = useState("")
@ -49,7 +51,7 @@ export function GoogleClientIdModal({
<DialogHeader>
<DialogTitle>Enter Google Client ID</DialogTitle>
<DialogDescription>
This app does not store the client ID. You will be prompted each session.
{description ?? "Enter the client ID for your Google OAuth app to continue."}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">

View file

@ -325,25 +325,26 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
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,
}
try {
const result = await window.ipc.invoke('oauth:getState', null)
const config = result.config || {}
for (const provider of providers) {
newStates[provider] = {
isConnected: config[provider]?.connected ?? false,
isLoading: false,
isConnecting: false,
}
})
)
}
} catch (error) {
console.error('Failed to check connection status for providers:', error)
for (const provider of providers) {
newStates[provider] = {
isConnected: false,
isLoading: false,
isConnecting: false,
}
}
}
setProviderStates(newStates)
}, [providers, refreshGranolaConfig, refreshSlackStatus])

View file

@ -10,6 +10,7 @@ import {
Copy,
FilePlus,
FolderPlus,
AlertTriangle,
HelpCircle,
Mic,
Network,
@ -34,6 +35,17 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
Sidebar,
@ -379,6 +391,45 @@ export function SidebarContentPanel({
...props
}: SidebarContentPanelProps) {
const { activeSection, setActiveSection } = useSidebarSection()
const [hasOauthError, setHasOauthError] = useState(false)
const [showOauthAlert, setShowOauthAlert] = useState(true)
const [connectorsOpen, setConnectorsOpen] = useState(false)
const [openConnectorsAfterClose, setOpenConnectorsAfterClose] = useState(false)
const connectorsButtonRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
let mounted = true
const refreshOauthError = async () => {
try {
const result = await window.ipc.invoke('oauth:getState', null)
const config = result.config || {}
const hasError = Object.values(config).some((entry) => Boolean(entry?.error))
if (mounted) {
setHasOauthError(hasError)
if (!hasError) {
setShowOauthAlert(true)
}
}
} catch (error) {
console.error('Failed to fetch OAuth state:', error)
if (mounted) {
setHasOauthError(false)
setShowOauthAlert(true)
}
}
}
refreshOauthError()
const cleanup = window.ipc.on('oauth:didConnect', () => {
refreshOauthError()
})
return () => {
mounted = false
cleanup()
}
}, [])
return (
<Sidebar className="border-r-0" {...props}>
@ -430,12 +481,69 @@ export function SidebarContentPanel({
{/* Bottom actions */}
<div className="border-t border-sidebar-border px-2 py-2">
<div className="flex flex-col gap-1">
<ConnectorsPopover>
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors">
<Plug className="size-4" />
<span>Connectors</span>
</button>
</ConnectorsPopover>
<div className="flex items-center gap-2">
<ConnectorsPopover open={connectorsOpen} onOpenChange={setConnectorsOpen}>
<button
ref={connectorsButtonRef}
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
<Plug className="size-4" />
<span>Connected accounts</span>
</button>
</ConnectorsPopover>
{hasOauthError && (
<AlertDialog
open={showOauthAlert}
onOpenChange={setShowOauthAlert}
>
<AlertDialogTrigger asChild>
<button
type="button"
className="inline-flex items-center"
aria-label="OAuth connection issues"
>
<AlertTriangle className="size-3 text-amber-500/90 animate-pulse" />
</button>
</AlertDialogTrigger>
<AlertDialogContent
onCloseAutoFocus={(event) => {
event.preventDefault()
if (openConnectorsAfterClose) {
setOpenConnectorsAfterClose(false)
setConnectorsOpen(true)
}
connectorsButtonRef.current?.focus()
}}
>
<AlertDialogHeader>
<AlertDialogTitle>Reconnect your accounts</AlertDialogTitle>
<AlertDialogDescription>
One or more connected accounts need attention. Open Connected accounts
to review the status and reconnect if needed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setOpenConnectorsAfterClose(false)
setShowOauthAlert(false)
}}
>
Dismiss
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setOpenConnectorsAfterClose(true)
setShowOauthAlert(false)
}}
>
View connected accounts
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
<SettingsDialog>
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors">
<Settings className="size-4" />

View file

@ -0,0 +1,194 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View file

@ -1,6 +1,6 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
@ -22,9 +22,11 @@ const buttonVariants = cva(
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
@ -46,7 +48,7 @@ function Button({
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot.Root : "button"
return (
<Comp

View file

@ -12,8 +12,9 @@ export function useOAuth(provider: string) {
const checkConnection = useCallback(async () => {
try {
setIsLoading(true);
const result = await window.ipc.invoke('oauth:is-connected', { provider });
setIsConnected(result.isConnected);
const result = await window.ipc.invoke('oauth:getState', null);
const config = result.config || {};
setIsConnected(config[provider]?.connected ?? false);
} catch (error) {
console.error('Failed to check connection status:', error);
setIsConnected(false);
@ -107,8 +108,12 @@ export function useConnectedProviders() {
const refresh = useCallback(async () => {
try {
setIsLoading(true);
const result = await window.ipc.invoke('oauth:get-connected-providers', null);
setProviders(result.providers);
const result = await window.ipc.invoke('oauth:getState', null);
const config = result.config || {};
const connected = Object.entries(config)
.filter(([, value]) => value?.connected)
.map(([key]) => key);
setProviders(connected);
} catch (error) {
console.error('Failed to get connected providers:', error);
setProviders([]);
@ -149,4 +154,3 @@ export function useAvailableProviders() {
return { providers, isLoading };
}