From d2bb11f104a2874a5f1b29a72099080e39c480b2 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:39:48 +0530 Subject: [PATCH 01/61] support managed composio --- apps/x/apps/main/src/composio-handler.ts | 20 ++- apps/x/packages/core/src/composio/client.ts | 181 +++++++------------- apps/x/packages/core/src/composio/types.ts | 9 +- apps/x/packages/shared/src/ipc.ts | 4 +- 4 files changed, 78 insertions(+), 136 deletions(-) diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index e5b25d1a..731492a0 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -2,7 +2,8 @@ import { shell, BrowserWindow } from 'electron'; import { createAuthServer } from './auth-server.js'; import * as composioClient from '@x/core/dist/composio/client.js'; import { composioAccountsRepo } from '@x/core/dist/composio/repo.js'; -import type { LocalConnectedAccount } from '@x/core/dist/composio/types.js'; +import type { LocalConnectedAccount, ZExecuteActionResponse } from '@x/core/dist/composio/types.js'; +import { z } from 'zod'; const REDIRECT_URI = 'http://localhost:8081/oauth/callback'; @@ -28,8 +29,8 @@ export function emitComposioEvent(event: { toolkitSlug: string; success: boolean /** * Check if Composio is configured with an API key */ -export function isConfigured(): { configured: boolean } { - return { configured: composioClient.isConfigured() }; +export async function isConfigured(): Promise<{ configured: boolean }> { + return { configured: await composioClient.isConfigured() }; } /** @@ -272,23 +273,28 @@ export async function executeAction( actionSlug: string, toolkitSlug: string, input: Record -): Promise<{ success: boolean; data: unknown; error?: string }> { +): Promise> { try { const account = composioAccountsRepo.getAccount(toolkitSlug); if (!account || account.status !== 'ACTIVE') { return { - success: false, data: null, + successful: false, error: `Toolkit ${toolkitSlug} is not connected`, }; } - const result = await composioClient.executeAction(actionSlug, account.id, input); + const result = await composioClient.executeAction(actionSlug, { + connected_account_id: account.id, + user_id: 'rowboat-user', + version: 'latest', + arguments: input, + }); return result; } catch (error) { console.error('[Composio] Action execution failed:', error); return { - success: false, + successful: false, data: null, error: error instanceof Error ? error.message : 'Unknown error', }; diff --git a/apps/x/packages/core/src/composio/client.ts b/apps/x/packages/core/src/composio/client.ts index 070b4642..452041db 100644 --- a/apps/x/packages/core/src/composio/client.ts +++ b/apps/x/packages/core/src/composio/client.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import fs from "fs"; import path from "path"; -import { Composio } from "@composio/core"; import { WorkDir } from "../config/config.js"; import { ZAuthConfig, @@ -12,33 +11,36 @@ import { ZCreateConnectedAccountResponse, ZDeleteOperationResponse, ZErrorResponse, + ZExecuteActionRequest, ZExecuteActionResponse, ZListResponse, + ZTool, ZToolkit, } from "./types.js"; +import { isSignedIn } from "../account/account.js"; +import { getAccessToken } from "../auth/tokens.js"; +import { API_URL } from "../config/env.js"; -const BASE_URL = 'https://backend.composio.dev/api/v3'; +const COMPOSIO_BASE_URL = 'https://backend.composio.dev/api/v3'; const CONFIG_FILE = path.join(WorkDir, 'config', 'composio.json'); -// Composio SDK client (lazily initialized) -let composioClient: Composio | null = null; - -function getComposioClient(): Composio { - if (composioClient) { - return composioClient; +async function getBaseUrl(): Promise { + if (await isSignedIn()) { + return `${API_URL}/v1/composio`; } + return COMPOSIO_BASE_URL; +} +async function getAuthHeaders(): Promise> { + if (await isSignedIn()) { + const token = await getAccessToken(); + return { 'Authorization': `Bearer ${token}` }; + } const apiKey = getApiKey(); if (!apiKey) { throw new Error('Composio API key not configured'); } - - composioClient = new Composio({ apiKey }); - return composioClient; -} - -function resetComposioClient(): void { - composioClient = null; + return { 'x-api-key': apiKey }; } /** @@ -91,13 +93,13 @@ export function setApiKey(apiKey: string): void { const config = loadConfig(); config.apiKey = apiKey; saveConfig(config); - resetComposioClient(); } /** * Check if Composio is configured */ -export function isConfigured(): boolean { +export async function isConfigured(): Promise { + if (await isSignedIn()) return true; return !!getApiKey(); } @@ -106,23 +108,25 @@ export function isConfigured(): boolean { */ export async function composioApiCall( schema: T, - url: string, + path: string, + params: Record = {}, options: RequestInit = {}, ): Promise> { - const apiKey = getApiKey(); - if (!apiKey) { - throw new Error('Composio API key not configured'); - } + const authHeaders = await getAuthHeaders(); + const baseURL = await getBaseUrl(); + const url = new URL(path, baseURL); console.log(`[Composio] ${options.method || 'GET'} ${url}`); const startTime = Date.now(); try { + Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value)); + const response = await fetch(url, { ...options, headers: { ...options.headers, - "x-api-key": apiKey, + ...authHeaders, ...(options.method === 'POST' ? { "Content-Type": "application/json" } : {}), }, }); @@ -174,47 +178,20 @@ export async function composioApiCall( * List available toolkits */ export async function listToolkits(cursor: string | null = null): Promise>>> { - const url = new URL(`${BASE_URL}/toolkits`); - url.searchParams.set("sort_by", "usage"); + const params: Record = { + sort_by: "usage", + }; if (cursor) { - url.searchParams.set("cursor", cursor); + params.cursor = cursor; } - return composioApiCall(ZListResponse(ZToolkit), url.toString()); + return composioApiCall(ZListResponse(ZToolkit), "/toolkits", params); } /** * Get a specific toolkit */ export async function getToolkit(toolkitSlug: string): Promise> { - const apiKey = getApiKey(); - if (!apiKey) { - throw new Error('Composio API key not configured'); - } - - const url = `${BASE_URL}/toolkits/${toolkitSlug}`; - console.log(`[Composio] GET ${url}`); - - const response = await fetch(url, { - headers: { "x-api-key": apiKey }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch toolkit: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - - const no_auth = data.composio_managed_auth_schemes?.includes('NO_AUTH') || - data.auth_config_details?.some((config: { mode: string }) => config.mode === 'NO_AUTH') || - false; - - return ZToolkit.parse({ - ...data, - no_auth, - meta: data.meta || { description: '', logo: '', tools_count: 0, triggers_count: 0 }, - auth_schemes: data.auth_schemes || [], - composio_managed_auth_schemes: data.composio_managed_auth_schemes || [], - }); + return composioApiCall(ZToolkit, `/toolkits/${toolkitSlug}`); } /** @@ -225,15 +202,16 @@ export async function listAuthConfigs( cursor: string | null = null, managedOnly: boolean = false ): Promise>>> { - const url = new URL(`${BASE_URL}/auth_configs`); - url.searchParams.set("toolkit_slug", toolkitSlug); + const params: Record = { + toolkit_slug: toolkitSlug, + }; if (cursor) { - url.searchParams.set("cursor", cursor); + params.cursor = cursor; } if (managedOnly) { - url.searchParams.set("is_composio_managed", "true"); + params.is_composio_managed = "true"; } - return composioApiCall(ZListResponse(ZAuthConfig), url.toString()); + return composioApiCall(ZListResponse(ZAuthConfig), "/auth_configs", params); } /** @@ -242,8 +220,7 @@ export async function listAuthConfigs( export async function createAuthConfig( request: z.infer ): Promise> { - const url = new URL(`${BASE_URL}/auth_configs`); - return composioApiCall(ZCreateAuthConfigResponse, url.toString(), { + return composioApiCall(ZCreateAuthConfigResponse, "/auth_configs", {}, { method: 'POST', body: JSON.stringify(request), }); @@ -253,8 +230,7 @@ export async function createAuthConfig( * Delete an auth config */ export async function deleteAuthConfig(authConfigId: string): Promise> { - const url = new URL(`${BASE_URL}/auth_configs/${authConfigId}`); - return composioApiCall(ZDeleteOperationResponse, url.toString(), { + return composioApiCall(ZDeleteOperationResponse, `/auth_configs/${authConfigId}`, {}, { method: 'DELETE', }); } @@ -265,8 +241,7 @@ export async function deleteAuthConfig(authConfigId: string): Promise ): Promise> { - const url = new URL(`${BASE_URL}/connected_accounts`); - return composioApiCall(ZCreateConnectedAccountResponse, url.toString(), { + return composioApiCall(ZCreateConnectedAccountResponse, "/connected_accounts", {}, { method: 'POST', body: JSON.stringify(request), }); @@ -276,16 +251,14 @@ export async function createConnectedAccount( * Get a connected account */ export async function getConnectedAccount(connectedAccountId: string): Promise> { - const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`); - return composioApiCall(ZConnectedAccount, url.toString()); + return composioApiCall(ZConnectedAccount, `/connected_accounts/${connectedAccountId}`); } /** * Delete a connected account */ export async function deleteConnectedAccount(connectedAccountId: string): Promise> { - const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`); - return composioApiCall(ZDeleteOperationResponse, url.toString(), { + return composioApiCall(ZDeleteOperationResponse, `/connected_accounts/${connectedAccountId}`, {}, { method: 'DELETE', }); } @@ -296,64 +269,26 @@ export async function deleteConnectedAccount(connectedAccountId: string): Promis export async function listToolkitTools( toolkitSlug: string, searchQuery: string | null = null, -): Promise<{ items: Array<{ slug: string; name: string; description: string }> }> { - const apiKey = getApiKey(); - if (!apiKey) { - throw new Error('Composio API key not configured'); - } - - const url = new URL(`${BASE_URL}/tools`); - url.searchParams.set('toolkit_slug', toolkitSlug); - url.searchParams.set('limit', '200'); - if (searchQuery) { - url.searchParams.set('search', searchQuery); - } - - console.log(`[Composio] Listing tools for toolkit: ${toolkitSlug}`); - - const response = await fetch(url.toString(), { - headers: { "x-api-key": apiKey }, - }); - - if (!response.ok) { - throw new Error(`Failed to list tools: ${response.status} ${response.statusText}`); - } - - const data = await response.json() as { items?: Array> }; - - return { - items: (data.items || []).map((item) => ({ - slug: String(item.slug ?? ''), - name: String(item.name ?? ''), - description: String(item.description ?? ''), - })), +): Promise>>> { + const params: Record = { + toolkit_slug: toolkitSlug, + limit: '200', }; + if (searchQuery) { + params.search = searchQuery; + } + return composioApiCall(ZListResponse(ZTool), "/tools", params); } /** - * Execute a tool action using Composio SDK + * Execute a tool action */ export async function executeAction( actionSlug: string, - connectedAccountId: string, - input: Record + request: z.infer ): Promise> { - console.log(`[Composio] Executing action: ${actionSlug} (account: ${connectedAccountId})`); - - try { - const client = getComposioClient(); - const result = await client.tools.execute(actionSlug, { - userId: 'rowboat-user', - arguments: input, - connectedAccountId, - dangerouslySkipVersionCheck: true, - }); - - console.log(`[Composio] Action completed successfully`); - return { success: true, data: result.data }; - } catch (error) { - console.error(`[Composio] Action execution failed:`, JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}), 2)); - const message = error instanceof Error ? error.message : (typeof error === 'object' ? JSON.stringify(error) : 'Unknown error'); - return { success: false, data: null, error: message }; - } + return composioApiCall(ZExecuteActionResponse, `/tools/execute/${actionSlug}`, {}, { + method: 'POST', + body: JSON.stringify(request), + }); } diff --git a/apps/x/packages/core/src/composio/types.ts b/apps/x/packages/core/src/composio/types.ts index e2cbaf56..f79961d2 100644 --- a/apps/x/packages/core/src/composio/types.ts +++ b/apps/x/packages/core/src/composio/types.ts @@ -200,18 +200,19 @@ export const ZListResponse = (schema: T) => z.object({ * Execute action request */ export const ZExecuteActionRequest = z.object({ - action: z.string(), connected_account_id: z.string(), - input: z.record(z.string(), z.unknown()), + user_id: z.string(), + version: z.string(), + arguments: z.any().optional(), }); /** * Execute action response */ export const ZExecuteActionResponse = z.object({ - success: z.boolean(), data: z.unknown(), - error: z.string().optional(), + successful: z.boolean(), + error: z.string().nullable(), }); /** diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 4d7e6492..f6f5c0ee 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -375,9 +375,9 @@ const ipcSchemas = { input: z.record(z.string(), z.unknown()), }), res: z.object({ - success: z.boolean(), data: z.unknown(), - error: z.string().optional(), + successful: z.boolean(), + error: z.string().nullable(), }), }, 'composio:didConnect': { From 17bb625ab9dfd07684ad157b8dfe3afe54a51620 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:52:23 +0530 Subject: [PATCH 02/61] use composio for google optionally --- apps/x/apps/main/src/composio-handler.ts | 11 + apps/x/apps/main/src/ipc.ts | 3 + .../src/components/connectors-popover.tsx | 191 ++++++++- .../src/components/onboarding-modal.tsx | 178 ++++++++- apps/x/packages/core/src/composio/client.ts | 9 + .../packages/core/src/knowledge/sync_gmail.ts | 362 +++++++++++++++++- apps/x/packages/shared/src/ipc.ts | 6 + 7 files changed, 740 insertions(+), 20 deletions(-) diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index 731492a0..b0537cd8 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -4,6 +4,7 @@ import * as composioClient from '@x/core/dist/composio/client.js'; import { composioAccountsRepo } from '@x/core/dist/composio/repo.js'; import type { LocalConnectedAccount, ZExecuteActionResponse } from '@x/core/dist/composio/types.js'; import { z } from 'zod'; +import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; const REDIRECT_URI = 'http://localhost:8081/oauth/callback'; @@ -152,6 +153,9 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ if (accountStatus.status === 'ACTIVE') { emitComposioEvent({ toolkitSlug, success: true }); + if (toolkitSlug === 'gmail') { + triggerGmailSync(); + } } else { emitComposioEvent({ toolkitSlug, @@ -266,6 +270,13 @@ export function listConnected(): { toolkits: string[] } { return { toolkits: composioAccountsRepo.getConnectedToolkits() }; } +/** + * Check if Composio should be used for Google services (Gmail, etc.) + */ +export function useComposioForGoogle(): { enabled: boolean } { + return { enabled: composioClient.useComposioForGoogle() }; +} + /** * Execute a Composio action */ diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 84e2b3e4..35856e08 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -543,6 +543,9 @@ export function setupIpcHandlers() { 'composio:execute-action': async (_event, args) => { return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input); }, + 'composio:use-composio-for-google': async () => { + return composioHandler.useComposioForGoogle(); + }, // Agent schedule handlers 'agent-schedule:getConfig': async () => { const repo = container.resolve('agentScheduleRepo'); diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index fe1d58ae..268fcfe7 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -20,6 +20,7 @@ import { Separator } from "@/components/ui/separator" import { GoogleClientIdModal } from "@/components/google-client-id-modal" import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store" import { toast } from "sonner" +import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" interface ProviderState { isConnected: boolean @@ -54,6 +55,10 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha const [granolaEnabled, setGranolaEnabled] = useState(false) const [granolaLoading, setGranolaLoading] = useState(true) + // Composio API key state + const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) + const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') + // Slack state (agent-slack CLI) const [slackEnabled, setSlackEnabled] = useState(false) const [slackLoading, setSlackLoading] = useState(true) @@ -64,7 +69,13 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha const [slackDiscovering, setSlackDiscovering] = useState(false) const [slackDiscoverError, setSlackDiscoverError] = useState(null) - // Load available providers on mount + // Composio/Gmail state + const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) + const [gmailConnected, setGmailConnected] = useState(false) + const [gmailLoading, setGmailLoading] = useState(true) + const [gmailConnecting, setGmailConnecting] = useState(false) + + // Load available providers and composio-for-google flag on mount useEffect(() => { async function loadProviders() { try { @@ -78,7 +89,16 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha setProvidersLoading(false) } } + async function loadComposioForGoogleFlag() { + try { + const result = await window.ipc.invoke('composio:use-composio-for-google', null) + setUseComposioForGoogle(result.enabled) + } catch (error) { + console.error('Failed to check composio-for-google flag:', error) + } + } loadProviders() + loadComposioForGoogleFlag() }, []) // Load Granola config @@ -150,6 +170,80 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha } }, []) + // Load Gmail connection status + const refreshGmailStatus = useCallback(async () => { + try { + setGmailLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' }) + setGmailConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Gmail status:', error) + setGmailConnected(false) + } finally { + setGmailLoading(false) + } + }, []) + + // Connect to Gmail via Composio + const startGmailConnect = useCallback(async () => { + try { + setGmailConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Gmail') + setGmailConnecting(false) + } + // Success will be handled by composio:didConnect event + } catch (error) { + console.error('Failed to connect to Gmail:', error) + toast.error('Failed to connect to Gmail') + setGmailConnecting(false) + } + }, []) + + // Handle Gmail connect button click + const handleConnectGmail = useCallback(async () => { + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyTarget('gmail') + setComposioApiKeyOpen(true) + return + } + await startGmailConnect() + }, [startGmailConnect]) + + // Disconnect from Gmail + const handleDisconnectGmail = useCallback(async () => { + try { + setGmailLoading(true) + const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'gmail' }) + if (result.success) { + setGmailConnected(false) + toast.success('Disconnected from Gmail') + } else { + toast.error('Failed to disconnect from Gmail') + } + } catch (error) { + console.error('Failed to disconnect from Gmail:', error) + toast.error('Failed to disconnect from Gmail') + } finally { + setGmailLoading(false) + } + }, []) + + // 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') + await startGmailConnect() + } catch (error) { + console.error('Failed to save Composio API key:', error) + toast.error('Failed to save API key') + } + }, [startGmailConnect]) + // Save selected Slack workspaces const handleSlackSaveWorkspaces = useCallback(async () => { const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url)) @@ -193,6 +287,11 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha // Refresh Slack config refreshSlackConfig() + // Refresh Gmail Composio status if enabled + if (useComposioForGoogle) { + refreshGmailStatus() + } + // Refresh OAuth providers if (providers.length === 0) return @@ -229,7 +328,7 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha } setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshSlackConfig]) + }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle]) // Refresh statuses when popover opens or providers list changes useEffect(() => { @@ -273,6 +372,30 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha return cleanup }, [refreshAllStatuses]) + // Listen for Composio connection events (Gmail) + useEffect(() => { + const cleanup = window.ipc.on('composio:didConnect', (event) => { + const { toolkitSlug, success, error } = event + + if (toolkitSlug === 'gmail') { + setGmailConnected(success) + setGmailConnecting(false) + + if (success) { + toast.success('Connected to Gmail', { + description: 'Syncing your emails in the background. This may take a few minutes before changes appear.', + duration: 8000, + }) + } else { + toast.error(error || 'Failed to connect to Gmail') + } + } + }) + + return cleanup + }, []) + + const startConnect = useCallback(async (provider: string, clientId?: string) => { setProviderStates(prev => ({ ...prev, @@ -516,13 +639,63 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha )} - {/* Email & Calendar Section - Google */} - {providers.includes('google') && ( + {/* Email & Calendar Section */} + {(useComposioForGoogle || providers.includes('google')) && ( <>
- Email & Calendar + + {useComposioForGoogle ? 'Email' : 'Email & Calendar'} +
- {renderOAuthProvider('google', 'Google', , 'Sync emails and calendar')} + {useComposioForGoogle ? ( +
+
+
+ +
+
+ Gmail + {gmailLoading ? ( + Checking... + ) : ( + + Sync emails + + )} +
+
+
+ {gmailLoading ? ( + + ) : gmailConnected ? ( + + ) : ( + + )} +
+
+ ) : ( + renderOAuthProvider('google', 'Google', , 'Sync emails and calendar') + )} )} @@ -652,6 +825,12 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha + ) } diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 3e663b91..9060a669 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -25,6 +25,7 @@ import { cn } from "@/lib/utils" import { GoogleClientIdModal } from "@/components/google-client-id-modal" import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store" import { toast } from "sonner" +import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" interface ProviderState { isConnected: boolean @@ -78,6 +79,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [granolaLoading, setGranolaLoading] = useState(true) const [showMoreProviders, setShowMoreProviders] = useState(false) + // Composio API key state + const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) + const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') + // Slack state (agent-slack CLI) const [slackEnabled, setSlackEnabled] = useState(false) const [slackLoading, setSlackLoading] = useState(true) @@ -88,6 +93,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [slackDiscovering, setSlackDiscovering] = useState(false) const [slackDiscoverError, setSlackDiscoverError] = useState(null) + // Composio/Gmail state + const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) + const [gmailConnected, setGmailConnected] = useState(false) + const [gmailLoading, setGmailLoading] = useState(true) + const [gmailConnecting, setGmailConnecting] = useState(false) + const updateProviderConfig = useCallback( (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => { setProviderConfigs(prev => ({ @@ -115,7 +126,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { .filter(([, state]) => state.isConnected) .map(([provider]) => provider) - // Load available providers on mount + // Load available providers and composio-for-google flag on mount useEffect(() => { if (!open) return @@ -131,7 +142,16 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { setProvidersLoading(false) } } + async function loadComposioForGoogleFlag() { + try { + const result = await window.ipc.invoke('composio:use-composio-for-google', null) + setUseComposioForGoogle(result.enabled) + } catch (error) { + console.error('Failed to check composio-for-google flag:', error) + } + } loadProviders() + loadComposioForGoogleFlag() }, [open]) // Load LLM models catalog on open @@ -254,6 +274,60 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, []) + // Load Gmail connection status + const refreshGmailStatus = useCallback(async () => { + try { + setGmailLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' }) + setGmailConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Gmail status:', error) + setGmailConnected(false) + } finally { + setGmailLoading(false) + } + }, []) + + // Connect to Gmail via Composio + const startGmailConnect = useCallback(async () => { + try { + setGmailConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Gmail') + setGmailConnecting(false) + } + } catch (error) { + console.error('Failed to connect to Gmail:', error) + toast.error('Failed to connect to Gmail') + setGmailConnecting(false) + } + }, []) + + // Handle Gmail connect button click + const handleConnectGmail = useCallback(async () => { + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyTarget('gmail') + setComposioApiKeyOpen(true) + return + } + await startGmailConnect() + }, [startGmailConnect]) + + // 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') + await startGmailConnect() + } catch (error) { + console.error('Failed to save Composio API key:', error) + toast.error('Failed to save API key') + } + }, [startGmailConnect]) + // Save selected Slack workspaces const handleSlackSaveWorkspaces = useCallback(async () => { const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url)) @@ -341,6 +415,11 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Refresh Slack config refreshSlackConfig() + // Refresh Gmail Composio status if enabled + if (useComposioForGoogle) { + refreshGmailStatus() + } + // Refresh OAuth providers if (providers.length === 0) return @@ -368,7 +447,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshSlackConfig]) + }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle]) // Refresh statuses when modal opens or providers list changes useEffect(() => { @@ -402,6 +481,30 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { return cleanup }, []) + // Listen for Composio connection events (Gmail) + useEffect(() => { + const cleanup = window.ipc.on('composio:didConnect', (event) => { + const { toolkitSlug, success, error } = event + + if (toolkitSlug === 'gmail') { + setGmailConnected(success) + setGmailConnecting(false) + + if (success) { + toast.success('Connected to Gmail', { + description: 'Syncing your emails in the background. This may take a few minutes before changes appear.', + duration: 8000, + }) + } else { + toast.error(error || 'Failed to connect to Gmail') + } + } + }) + + return cleanup + }, []) + + const startConnect = useCallback(async (provider: string, clientId?: string) => { setProviderStates(prev => ({ ...prev, @@ -544,6 +647,50 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { ) + // Render Gmail Composio row + const renderGmailRow = () => ( +
+
+
+ +
+
+ Gmail + {gmailLoading ? ( + Checking... + ) : ( + + Sync emails + + )} +
+
+
+ {gmailLoading ? ( + + ) : gmailConnected ? ( +
+ + Connected +
+ ) : ( + + )} +
+
+ ) + // Render Slack row const renderSlackRow = () => (
@@ -835,13 +982,18 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) : ( <> - {/* Email & Calendar Section */} - {providers.includes('google') && ( + {/* Email / Email & Calendar Section */} + {(useComposioForGoogle || providers.includes('google')) && (
- Email & Calendar + + {useComposioForGoogle ? 'Email' : 'Email & Calendar'} +
- {renderOAuthProvider('google', 'Google', , 'Sync emails and calendar events')} + {useComposioForGoogle + ? renderGmailRow() + : renderOAuthProvider('google', 'Google', , 'Sync emails and calendar events') + }
)} @@ -878,7 +1030,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Step 2: Completion const renderCompletionStep = () => { - const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled + const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected return (
@@ -901,6 +1053,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {

Connected accounts:

+ {gmailConnected && ( +
+ + Gmail (Email) +
+ )} {connectedProviders.includes('google') && (
@@ -945,6 +1103,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { onSubmit={handleGoogleClientIdSubmit} isSubmitting={providerStates.google?.isConnecting ?? false} /> + {}}> > { */ const ZComposioConfig = z.object({ apiKey: z.string().optional(), + use_composio_for_google: z.boolean().optional(), }); type ComposioConfig = z.infer; @@ -103,6 +104,14 @@ export async function isConfigured(): Promise { return !!getApiKey(); } +/** + * Check if Composio should be used for Google services (Gmail, etc.) + */ +export function useComposioForGoogle(): boolean { + const config = loadConfig(); + return config.use_composio_for_google === true; +} + /** * Make an API call to Composio */ diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index de73c016..ab613efe 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -7,6 +7,8 @@ import { WorkDir } from '../config/config.js'; import { GoogleClientFactory } from './google-client-factory.js'; import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { limitEventItems } from './limit_event_items.js'; +import { executeAction, useComposioForGoogle } from '../composio/client.js'; +import { composioAccountsRepo } from '../composio/repo.js'; // Configuration const SYNC_DIR = path.join(WorkDir, 'gmail_sync'); @@ -440,20 +442,366 @@ async function performSync() { } } +// --- Composio-based Sync --- + +const COMPOSIO_LOOKBACK_DAYS = 7; + +interface ComposioSyncState { + last_sync: string; // ISO string +} + +function loadComposioState(stateFile: string): ComposioSyncState | null { + if (fs.existsSync(stateFile)) { + try { + const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + if (data.last_sync) { + return { last_sync: data.last_sync }; + } + } catch (e) { + console.error('[Gmail] Failed to load composio state:', e); + } + } + return null; +} + +function saveComposioState(stateFile: string, lastSync: string): void { + fs.writeFileSync(stateFile, JSON.stringify({ last_sync: lastSync }, null, 2)); +} + +function tryParseDate(dateStr: string): Date | null { + const d = new Date(dateStr); + return isNaN(d.getTime()) ? null : d; +} + +interface ParsedMessage { + from: string; + date: string; + subject: string; + body: string; +} + +function parseMessageData(messageData: Record): ParsedMessage { + const headers = messageData.payload && typeof messageData.payload === 'object' + ? (messageData.payload as Record).headers as Array<{ name: string; value: string }> | undefined + : undefined; + + const from = headers?.find(h => h.name === 'From')?.value || String(messageData.from || messageData.sender || 'Unknown'); + const date = headers?.find(h => h.name === 'Date')?.value || String(messageData.date || messageData.internalDate || 'Unknown'); + const subject = headers?.find(h => h.name === 'Subject')?.value || String(messageData.subject || '(No Subject)'); + + let body = ''; + + if (messageData.payload && typeof messageData.payload === 'object') { + body = extractBodyFromPayload(messageData.payload as Record); + } + + if (!body) { + if (typeof messageData.body === 'string') { + body = messageData.body; + } else if (typeof messageData.snippet === 'string') { + body = messageData.snippet; + } else if (typeof messageData.text === 'string') { + body = messageData.text; + } + } + + if (body && (body.includes(' !line.trim().startsWith('>')).join('\n'); + } + + return { from, date, subject, body }; +} + +function extractBodyFromPayload(payload: Record): string { + const parts = payload.parts as Array> | undefined; + + if (parts) { + for (const part of parts) { + const mimeType = part.mimeType as string | undefined; + const bodyData = part.body && typeof part.body === 'object' + ? (part.body as Record).data as string | undefined + : undefined; + + if ((mimeType === 'text/plain' || mimeType === 'text/html') && bodyData) { + const decoded = Buffer.from(bodyData, 'base64').toString('utf-8'); + if (mimeType === 'text/html') { + return nhm.translate(decoded); + } + return decoded; + } + + if (part.parts) { + const result = extractBodyFromPayload(part as Record); + if (result) return result; + } + } + } + + const bodyData = payload.body && typeof payload.body === 'object' + ? (payload.body as Record).data as string | undefined + : undefined; + + if (bodyData) { + const decoded = Buffer.from(bodyData, 'base64').toString('utf-8'); + const mimeType = payload.mimeType as string | undefined; + if (mimeType === 'text/html') { + return nhm.translate(decoded); + } + return decoded; + } + + return ''; +} + +async function processThreadComposio(connectedAccountId: string, threadId: string, syncDir: string): Promise { + let threadResult; + try { + threadResult = await executeAction( + 'GMAIL_FETCH_MESSAGE_BY_THREAD_ID', + connectedAccountId, + { thread_id: threadId, user_id: 'me' } + ); + } catch (error) { + console.warn(`[Gmail] Skipping thread ${threadId} (fetch failed):`, error instanceof Error ? error.message : error); + return null; + } + + if (!threadResult.success || !threadResult.data) { + console.error(`[Gmail] Failed to fetch thread ${threadId}:`, threadResult.error); + return null; + } + + const data = threadResult.data as Record; + const messages = data.messages as Array> | undefined; + + let newestDate: Date | null = null; + + if (!messages || messages.length === 0) { + const parsed = parseMessageData(data); + const mdContent = `# ${parsed.subject}\n\n` + + `**Thread ID:** ${threadId}\n` + + `**Message Count:** 1\n\n---\n\n` + + `### From: ${parsed.from}\n` + + `**Date:** ${parsed.date}\n\n` + + `${parsed.body}\n\n---\n\n`; + + fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent); + console.log(`[Gmail] Synced Thread: ${parsed.subject} (${threadId})`); + newestDate = tryParseDate(parsed.date); + } else { + const firstParsed = parseMessageData(messages[0]); + let mdContent = `# ${firstParsed.subject}\n\n`; + mdContent += `**Thread ID:** ${threadId}\n`; + mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`; + + for (const msg of messages) { + const parsed = parseMessageData(msg); + mdContent += `### From: ${parsed.from}\n`; + mdContent += `**Date:** ${parsed.date}\n\n`; + mdContent += `${parsed.body}\n\n`; + mdContent += `---\n\n`; + + const msgDate = tryParseDate(parsed.date); + if (msgDate && (!newestDate || msgDate > newestDate)) { + newestDate = msgDate; + } + } + + fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent); + console.log(`[Gmail] Synced Thread: ${firstParsed.subject} (${threadId})`); + } + + if (!newestDate) return null; + return new Date(newestDate.getTime() + 1000).toISOString(); +} + +async function performSyncComposio() { + const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments'); + const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json'); + + if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true }); + if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true }); + + const account = composioAccountsRepo.getAccount('gmail'); + if (!account || account.status !== 'ACTIVE') { + console.log('[Gmail] Gmail not connected via Composio. Skipping sync.'); + return; + } + + const connectedAccountId = account.id; + + const state = loadComposioState(STATE_FILE); + let afterEpochSeconds: number; + + if (state) { + afterEpochSeconds = Math.floor(new Date(state.last_sync).getTime() / 1000); + console.log(`[Gmail] Syncing messages since ${state.last_sync}...`); + } else { + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - COMPOSIO_LOOKBACK_DAYS); + afterEpochSeconds = Math.floor(pastDate.getTime() / 1000); + console.log(`[Gmail] First sync - fetching last ${COMPOSIO_LOOKBACK_DAYS} days...`); + } + + let run: ServiceRunContext | null = null; + const ensureRun = async () => { + if (!run) { + run = await serviceLogger.startRun({ + service: 'gmail', + message: 'Syncing Gmail (Composio)', + trigger: 'timer', + }); + } + }; + + try { + const allThreadIds: string[] = []; + let pageToken: string | undefined; + + do { + const params: Record = { + query: `after:${afterEpochSeconds}`, + max_results: 20, + user_id: 'me', + }; + if (pageToken) { + params.page_token = pageToken; + } + + const result = await executeAction( + 'GMAIL_LIST_THREADS', + connectedAccountId, + params + ); + + if (!result.success || !result.data) { + console.error('[Gmail] Failed to list threads:', result.error); + return; + } + + const data = result.data as Record; + const threads = data.threads as Array> | undefined; + + if (threads && threads.length > 0) { + for (const thread of threads) { + const threadId = thread.id as string | undefined; + if (threadId) { + allThreadIds.push(threadId); + } + } + } + + pageToken = data.nextPageToken as string | undefined; + } while (pageToken); + + if (allThreadIds.length === 0) { + console.log('[Gmail] No new threads.'); + return; + } + + console.log(`[Gmail] Found ${allThreadIds.length} threads to sync.`); + + await ensureRun(); + const limitedThreads = limitEventItems(allThreadIds); + await serviceLogger.log({ + type: 'changes_identified', + service: run!.service, + runId: run!.runId, + level: 'info', + message: `Found ${allThreadIds.length} thread${allThreadIds.length === 1 ? '' : 's'} to sync`, + counts: { threads: allThreadIds.length }, + items: limitedThreads.items, + truncated: limitedThreads.truncated, + }); + + // Process oldest first so high-water mark advances chronologically + allThreadIds.reverse(); + + let highWaterMark: string | null = state?.last_sync ?? null; + let processedCount = 0; + for (const threadId of allThreadIds) { + try { + const newestInThread = await processThreadComposio(connectedAccountId, threadId, SYNC_DIR); + processedCount++; + + if (newestInThread) { + if (!highWaterMark || new Date(newestInThread) > new Date(highWaterMark)) { + highWaterMark = newestInThread; + } + saveComposioState(STATE_FILE, highWaterMark); + } + } catch (error) { + console.error(`[Gmail] Error processing thread ${threadId}, skipping:`, error); + } + } + + await serviceLogger.log({ + type: 'run_complete', + service: run!.service, + runId: run!.runId, + level: 'info', + message: `Gmail sync complete: ${processedCount}/${allThreadIds.length} thread${allThreadIds.length === 1 ? '' : 's'}`, + durationMs: Date.now() - run!.startedAt, + outcome: 'ok', + summary: { threads: processedCount }, + }); + + console.log(`[Gmail] Sync completed. Processed ${processedCount}/${allThreadIds.length} threads.`); + } catch (error) { + console.error('[Gmail] Error during sync:', error); + await ensureRun(); + await serviceLogger.log({ + type: 'error', + service: run!.service, + runId: run!.runId, + level: 'error', + message: 'Gmail sync error', + error: error instanceof Error ? error.message : String(error), + }); + await serviceLogger.log({ + type: 'run_complete', + service: run!.service, + runId: run!.runId, + level: 'error', + message: 'Gmail sync failed', + durationMs: Date.now() - run!.startedAt, + outcome: 'error', + }); + } +} + export async function init() { console.log("Starting Gmail Sync (TS)..."); console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); + const composioMode = useComposioForGoogle(); + if (composioMode) { + console.log('[Gmail] Using Composio backend for Gmail sync.'); + } + while (true) { try { - // Check if credentials are available with required scopes - const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE); - - if (!hasCredentials) { - console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping..."); + if (composioMode) { + const isConnected = composioAccountsRepo.isConnected('gmail'); + if (!isConnected) { + console.log('[Gmail] Gmail not connected via Composio. Sleeping...'); + } else { + await performSyncComposio(); + } } else { - // Perform one sync - await performSync(); + // Check if credentials are available with required scopes + const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE); + + if (!hasCredentials) { + console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping..."); + } else { + // Perform one sync + await performSync(); + } } } catch (error) { console.error("Error in main loop:", error); diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index f6f5c0ee..557df845 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -380,6 +380,12 @@ const ipcSchemas = { error: z.string().nullable(), }), }, + 'composio:use-composio-for-google': { + req: z.null(), + res: z.object({ + enabled: z.boolean(), + }), + }, 'composio:didConnect': { req: z.object({ toolkitSlug: z.string(), From dd361cb6bfdce335a3da7932558f7a450a1c1886 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:57:55 +0530 Subject: [PATCH 03/61] fix composio API integration: URL construction, schema validation, and executeAction calls - Fix URL construction in composioApiCall to preserve base path (/api/v3) - Make ZToolkit and ZTool schema fields optional to match actual API responses - Fix error detection to not trigger on successful responses with null error - Fix executeAction calls in sync_gmail to use correct request object shape - Use .successful instead of .success to match ZExecuteActionResponse schema Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/x/apps/main/src/composio-handler.ts | 2 +- apps/x/packages/core/src/composio/client.ts | 4 ++-- apps/x/packages/core/src/composio/types.ts | 8 ++++---- .../packages/core/src/knowledge/sync_gmail.ts | 20 +++++++++++++------ 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index b0537cd8..b2175819 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -70,7 +70,7 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ const toolkit = await composioClient.getToolkit(toolkitSlug); // Check for managed OAuth2 - if (!toolkit.composio_managed_auth_schemes.includes('OAUTH2')) { + if (!toolkit.composio_managed_auth_schemes?.includes('OAUTH2')) { return { success: false, error: `Toolkit ${toolkitSlug} does not support managed OAuth2`, diff --git a/apps/x/packages/core/src/composio/client.ts b/apps/x/packages/core/src/composio/client.ts index 8adca19c..05b3918a 100644 --- a/apps/x/packages/core/src/composio/client.ts +++ b/apps/x/packages/core/src/composio/client.ts @@ -123,7 +123,7 @@ export async function composioApiCall( ): Promise> { const authHeaders = await getAuthHeaders(); const baseURL = await getBaseUrl(); - const url = new URL(path, baseURL); + const url = new URL(`${baseURL}${path}`); console.log(`[Composio] ${options.method || 'GET'} ${url}`); const startTime = Date.now(); @@ -171,7 +171,7 @@ export async function composioApiCall( throw new Error(`Failed to parse response: ${message}`); } - if (typeof data === 'object' && data !== null && 'error' in data) { + if (typeof data === 'object' && data !== null && 'error' in data && data.error !== null && typeof data.error === 'object') { const parsedError = ZErrorResponse.parse(data); throw new Error(`Composio error (${parsedError.error.error_code}): ${parsedError.error.message}`); } diff --git a/apps/x/packages/core/src/composio/types.ts b/apps/x/packages/core/src/composio/types.ts index f79961d2..5f6faa34 100644 --- a/apps/x/packages/core/src/composio/types.ts +++ b/apps/x/packages/core/src/composio/types.ts @@ -45,9 +45,9 @@ export const ZToolkit = z.object({ slug: z.string(), name: z.string(), meta: ZToolkitMeta, - no_auth: z.boolean(), - auth_schemes: z.array(ZAuthScheme), - composio_managed_auth_schemes: z.array(ZAuthScheme), + no_auth: z.boolean().optional(), + auth_schemes: z.array(ZAuthScheme).optional(), + composio_managed_auth_schemes: z.array(ZAuthScheme).optional(), }); /** @@ -68,7 +68,7 @@ export const ZTool = z.object({ required: z.array(z.string()).optional(), additionalProperties: z.boolean().optional(), }), - no_auth: z.boolean(), + no_auth: z.boolean().optional(), }); /** diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index ab613efe..38afc0ee 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -562,15 +562,19 @@ async function processThreadComposio(connectedAccountId: string, threadId: strin try { threadResult = await executeAction( 'GMAIL_FETCH_MESSAGE_BY_THREAD_ID', - connectedAccountId, - { thread_id: threadId, user_id: 'me' } + { + connected_account_id: connectedAccountId, + user_id: 'rowboat-user', + version: 'latest', + arguments: { thread_id: threadId, user_id: 'me' }, + } ); } catch (error) { console.warn(`[Gmail] Skipping thread ${threadId} (fetch failed):`, error instanceof Error ? error.message : error); return null; } - if (!threadResult.success || !threadResult.data) { + if (!threadResult.successful || !threadResult.data) { console.error(`[Gmail] Failed to fetch thread ${threadId}:`, threadResult.error); return null; } @@ -674,11 +678,15 @@ async function performSyncComposio() { const result = await executeAction( 'GMAIL_LIST_THREADS', - connectedAccountId, - params + { + connected_account_id: connectedAccountId, + user_id: 'rowboat-user', + version: 'latest', + arguments: params, + } ); - if (!result.success || !result.data) { + if (!result.successful || !result.data) { console.error('[Gmail] Failed to list threads:', result.error); return; } From 16b8975b000960238d9403b9113a1249580daa6c Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:01:22 +0530 Subject: [PATCH 04/61] auto-enable composio gmail sync when signed in --- apps/x/apps/main/src/composio-handler.ts | 4 ++-- apps/x/packages/core/src/composio/client.ts | 3 ++- apps/x/packages/core/src/knowledge/sync_gmail.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index b2175819..cace8c74 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -273,8 +273,8 @@ export function listConnected(): { toolkits: string[] } { /** * Check if Composio should be used for Google services (Gmail, etc.) */ -export function useComposioForGoogle(): { enabled: boolean } { - return { enabled: composioClient.useComposioForGoogle() }; +export async function useComposioForGoogle(): Promise<{ enabled: boolean }> { + return { enabled: await composioClient.useComposioForGoogle() }; } /** diff --git a/apps/x/packages/core/src/composio/client.ts b/apps/x/packages/core/src/composio/client.ts index 05b3918a..f1aeee7e 100644 --- a/apps/x/packages/core/src/composio/client.ts +++ b/apps/x/packages/core/src/composio/client.ts @@ -107,7 +107,8 @@ export async function isConfigured(): Promise { /** * Check if Composio should be used for Google services (Gmail, etc.) */ -export function useComposioForGoogle(): boolean { +export async function useComposioForGoogle(): Promise { + if (await isSignedIn()) return true; const config = loadConfig(); return config.use_composio_for_google === true; } diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 38afc0ee..c74de77a 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -786,7 +786,7 @@ export async function init() { console.log("Starting Gmail Sync (TS)..."); console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); - const composioMode = useComposioForGoogle(); + const composioMode = await useComposioForGoogle(); if (composioMode) { console.log('[Gmail] Using Composio backend for Gmail sync.'); } From 7e15c1231daec6053a9c7bb3d27c89ad6c2a8b38 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:20:08 +0530 Subject: [PATCH 05/61] meetings and knowledge improvements - Limit Granola sync to 30-day lookback period - Move Granola and Fireflies sync dirs under knowledge/Meetings/ - Note creation agent links to source meeting notes in activity entries - Note creation agent links to Gmail threads via web URL - Add Meetings to note type definitions with recursive tag scanning - Tagging agent extracts meeting metadata (date, source, attendees, title, topic) - Clicking Knowledge with no tab open auto-opens Bases view Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/x/apps/renderer/src/App.tsx | 6 +++- .../core/src/knowledge/build_graph.ts | 8 +++-- .../core/src/knowledge/granola/sync.ts | 33 +++++++++++++++++-- .../core/src/knowledge/note_creation.ts | 29 ++++++++++++++-- .../core/src/knowledge/note_system.ts | 7 ++++ .../core/src/knowledge/note_tagging_agent.ts | 18 +++++++--- .../core/src/knowledge/sync_fireflies.ts | 14 ++++++-- .../packages/core/src/knowledge/tag_notes.ts | 24 +++++++++----- 8 files changed, 114 insertions(+), 25 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 45044f5f..ff6d8dc1 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -3514,7 +3514,11 @@ function App() { return ( - + { + if (section === 'knowledge' && !selectedPath && !isGraphOpen) { + void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) + } + }}>
{/* Content sidebar with SidebarProvider for collapse functionality */} / @@ -192,7 +192,9 @@ async function createNotesFromBatch( // Add each file's content message += `# Source Files to Process\n\n`; files.forEach((file, idx) => { - message += `## Source File ${idx + 1}: ${path.basename(file.path)}\n\n`; + // Pass workspace-relative path so the agent can link back to meeting notes + const relativePath = path.relative(WorkDir, file.path); + message += `## Source File ${idx + 1}: ${relativePath}\n\n`; message += file.content; message += `\n\n---\n\n`; }); diff --git a/apps/x/packages/core/src/knowledge/granola/sync.ts b/apps/x/packages/core/src/knowledge/granola/sync.ts index f03a8e06..2c647c9a 100644 --- a/apps/x/packages/core/src/knowledge/granola/sync.ts +++ b/apps/x/packages/core/src/knowledge/granola/sync.ts @@ -17,13 +17,14 @@ import { const GRANOLA_CLIENT_VERSION = '6.462.1'; const GRANOLA_API_BASE = 'https://api.granola.ai'; const GRANOLA_CONFIG_PATH = path.join(homedir(), 'Library', 'Application Support', 'Granola', 'supabase.json'); -const SYNC_DIR = path.join(WorkDir, 'granola_notes'); +const SYNC_DIR = path.join(WorkDir, 'knowledge', 'Meetings', 'granola'); const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json'); const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const API_DELAY_MS = 1000; // 1 second delay between API calls const RATE_LIMIT_RETRY_DELAY_MS = 60 * 1000; // Wait 1 minute on rate limit const MAX_RETRIES = 3; // Maximum retries for rate-limited requests const MAX_BATCH_SIZE = 10; // Process max 10 documents per folder per sync +const LOOKBACK_DAYS = 30; // Only sync documents from the last 30 days // --- Wake Signal for Immediate Sync Trigger --- let wakeResolve: (() => void) | null = null; @@ -370,6 +371,10 @@ async function syncNotes(): Promise { let hasMore = true; const changedTitles: string[] = []; + // Calculate lookback cutoff date + const lookbackCutoff = new Date(); + lookbackCutoff.setDate(lookbackCutoff.getDate() - LOOKBACK_DAYS); + // Fetch documents with pagination while (hasMore) { // Delay before API call (except first) @@ -390,7 +395,16 @@ async function syncNotes(): Promise { } // Process each document + let foundOldDoc = false; for (const doc of docsResponse.docs) { + // Skip documents outside the lookback period + const docDate = new Date(doc.created_at); + if (docDate < lookbackCutoff) { + console.log(`[Granola] Document "${doc.title}" is older than ${LOOKBACK_DAYS} days, stopping pagination`); + foundOldDoc = true; + break; + } + const docUpdatedAt = doc.updated_at || doc.created_at; const lastSyncedAt = state.syncedDocs[doc.id]; @@ -407,8 +421,15 @@ async function syncNotes(): Promise { // Convert to markdown and save const markdown = documentToMarkdown(doc); - const filename = `${doc.id}_${cleanFilename(docTitle)}.md`; - const filePath = path.join(SYNC_DIR, filename); + const dateDir = path.join( + SYNC_DIR, + String(docDate.getFullYear()), + String(docDate.getMonth() + 1).padStart(2, '0'), + String(docDate.getDate()).padStart(2, '0') + ); + ensureDir(dateDir); + const filename = `${cleanFilename(docTitle)}.md`; + const filePath = path.join(dateDir, filename); fs.writeFileSync(filePath, markdown); @@ -424,6 +445,12 @@ async function syncNotes(): Promise { state.syncedDocs[doc.id] = docUpdatedAt; } + // Stop if we hit a document outside the lookback period + if (foundOldDoc) { + hasMore = false; + break; + } + // Move to next page offset += docsResponse.docs.length; diff --git a/apps/x/packages/core/src/knowledge/note_creation.ts b/apps/x/packages/core/src/knowledge/note_creation.ts index 2d7a6b58..bec0dd21 100644 --- a/apps/x/packages/core/src/knowledge/note_creation.ts +++ b/apps/x/packages/core/src/knowledge/note_creation.ts @@ -157,6 +157,7 @@ workspace-readFile({ path: "{source_file}" }) - Has \`Attendees:\` field - Has \`Meeting:\` title - Transcript format with speaker labels +- Source file path is under \`knowledge/Meetings/\` (e.g. \`knowledge/Meetings/granola/...\` or \`knowledge/Meetings/fireflies/...\`) **Email indicators:** - Has \`From:\` and \`To:\` fields @@ -680,6 +681,16 @@ One line summarizing this source's relevance to the entity: **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[links]]} \`\`\` +**For meetings:** Include a link to the source meeting note. Derive the wiki-link path from the source file path (strip the \`.md\` extension): +\`\`\` +**2025-01-15** (meeting): Discussed [[Projects/Acme Integration]] timeline with [[People/David Kim]]. See [[Meetings/granola/abc123_Weekly Sync]] +\`\`\` + +**For emails:** Include a Gmail web link to the thread. Extract the thread ID from the \`**Thread ID:**\` field in the email source file, then construct the URL as \`https://mail.google.com/mail/#inbox/{threadId}\`: +\`\`\` +**2025-01-15** (email): [[People/Sarah Chen]] sent pricing proposal for [[Projects/Acme Integration]]. [View thread](https://mail.google.com/mail/#inbox/18d5a3b2c1e4f567) +\`\`\` + **For voice memos:** Include a link to the voice memo file using the Path field: \`\`\` **2025-01-15** (voice memo): Discussed [[Projects/Acme Integration]] timeline. See [[Voice Memos/2025-01-15/voice-memo-2025-01-15T10-30-00-000Z]] @@ -687,11 +698,13 @@ One line summarizing this source's relevance to the entity: **Important:** Use canonical names with absolute paths from resolution map in all summaries: \`\`\` -# Correct (uses absolute paths): -**2025-01-15** (meeting): [[People/Sarah Chen]] confirmed timeline with [[People/David Kim]]. Blocked on [[Topics/Security Compliance]]. +# Correct (uses absolute paths and source links): +**2025-01-15** (meeting): [[People/Sarah Chen]] confirmed timeline with [[People/David Kim]]. Blocked on [[Topics/Security Compliance]]. See [[Meetings/fireflies/abc_Team Sync]] +**2025-01-15** (email): [[People/Sarah Chen]] shared the contract draft. [View thread](https://mail.google.com/mail/#inbox/18d5a3b2c1e4f567) -# Incorrect (uses variants or relative links): +# Incorrect (uses variants or relative links, missing source links): **2025-01-15** (meeting): Sarah confirmed timeline with David. Blocked on SOC 2. +**2025-01-15** (email): Sarah shared the contract draft. \`\`\` --- @@ -888,6 +901,16 @@ ${renderNoteTypesBlock()} | Email (has create label) | Yes | Yes | Yes | | Email (only skip labels) | No (SKIP) | No | No | +**Meeting activity format:** Always include a link to the source meeting note: +\`\`\` +**2025-01-15** (meeting): Discussed project timeline with [[People/Sarah Chen]]. See [[Meetings/granola/abc123_Weekly Sync]] +\`\`\` + +**Email activity format:** Always include a Gmail web link using the Thread ID from the source: +\`\`\` +**2025-01-15** (email): [[People/Sarah Chen]] sent pricing proposal. [View thread](https://mail.google.com/mail/#inbox/18d5a3b2c1e4f567) +\`\`\` + **Voice memo activity format:** Always include a link to the source voice memo: \`\`\` **2025-01-15** (voice memo): Discussed project timeline with [[People/Sarah Chen]]. See [[Voice Memos/2025-01-15/voice-memo-...]] diff --git a/apps/x/packages/core/src/knowledge/note_system.ts b/apps/x/packages/core/src/knowledge/note_system.ts index 210d3501..39cf2695 100644 --- a/apps/x/packages/core/src/knowledge/note_system.ts +++ b/apps/x/packages/core/src/knowledge/note_system.ts @@ -153,6 +153,13 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [ extractionGuide: "Look for: topic name, keywords, related people/orgs/projects, decisions, key facts", }, + { + type: "Meetings", + folder: "Meetings", + template: "", + extractionGuide: + "Look for: meeting title, date, attendees, source (granola or fireflies), duration, topics discussed", + }, ]; // ── Disk-backed config with mtime caching ────────────────────────────────── diff --git a/apps/x/packages/core/src/knowledge/note_tagging_agent.ts b/apps/x/packages/core/src/knowledge/note_tagging_agent.ts index 94cd5016..8238e40a 100644 --- a/apps/x/packages/core/src/knowledge/note_tagging_agent.ts +++ b/apps/x/packages/core/src/knowledge/note_tagging_agent.ts @@ -16,14 +16,14 @@ tools: --- # Task -You are a note tagging agent. Given a batch of knowledge notes (People, Organizations, Projects, Topics), you will classify each note and prepend YAML frontmatter with categorized tags and Info attributes. +You are a note tagging agent. Given a batch of knowledge notes (People, Organizations, Projects, Topics, Meetings), you will classify each note and prepend YAML frontmatter with categorized tags and Info/metadata attributes. # Instructions 1. For each note file provided in the message, read its content carefully. -2. Determine the note type from its folder path (People/, Organizations/, Projects/, Topics/). +2. Determine the note type from its folder path (People/, Organizations/, Projects/, Topics/, Meetings/). 3. Classify the note using the Rowboat Tag System (Note Tags section) appended below. -4. Extract attributes from the note's \`## Info\` section (or \`## About\` for Topics). +4. Extract attributes from the note's \`## Info\` section (or \`## About\` for Topics). For Meetings, extract metadata from the note content and file path (see Meeting extraction rules below). 5. Use \`workspace-edit\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Title\` heading), and the newString should be the frontmatter followed by that same first line. 6. If the note already has frontmatter (starts with \`---\`), skip it. @@ -97,6 +97,12 @@ Extract all \`**Key:** value\` fields from the \`## Info\` (or \`## About\`) sec - **Organizations**: type, industry, relationship, domain, aliases, first_met, last_seen - **Projects**: type, status, started, last_activity - **Topics** (from \`## About\`): keywords, aliases, first_mentioned, last_mentioned +- **Meetings**: Extract from the note content and file path: + - \`date\`: meeting date (from the file path \`Meetings/{source}/YYYY/MM/DD/\` or from \`created_at\`/\`Date:\` in content) + - \`source\`: \`granola\` or \`fireflies\` (from the file path) + - \`attendees\`: list of attendee names (from \`Attendees:\` field or participant list) + - \`title\`: meeting title + - \`topic\`: relevant topic tags based on meeting content Note: For Organizations, the Info \`**Relationship:**\` field is separate from the \`relationship\` tag category. Include both — the Info field as \`info_relationship\` and the tag as \`relationship\`. @@ -122,7 +128,11 @@ Note: For Organizations, the Info \`**Relationship:**\` field is separate from t 7. **For Topic notes**, include: - The relevant topic tag - Source tags -8. **Only use tags from the Rowboat Tag System** — do not invent new tags. +8. **For Meeting notes**, include: + - \`source: meeting\` + - Topic tags based on what was discussed + - The \`date\`, \`attendees\`, and \`title\` fields extracted from content +9. **Only use tags from the Rowboat Tag System** — do not invent new tags. 9. Process all files in the batch. Do not skip any unless they already have frontmatter. --- diff --git a/apps/x/packages/core/src/knowledge/sync_fireflies.ts b/apps/x/packages/core/src/knowledge/sync_fireflies.ts index 5e0cca07..1244b2dd 100644 --- a/apps/x/packages/core/src/knowledge/sync_fireflies.ts +++ b/apps/x/packages/core/src/knowledge/sync_fireflies.ts @@ -6,7 +6,7 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge import { limitEventItems } from './limit_event_items.js'; // Configuration -const SYNC_DIR = path.join(WorkDir, 'fireflies_transcripts'); +const SYNC_DIR = path.join(WorkDir, 'knowledge', 'Meetings', 'fireflies'); const SYNC_INTERVAL_MS = 30 * 60 * 1000; // Check every 30 minutes (reduced from 1 minute) const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json'); const LOOKBACK_DAYS = 30; // Last 1 month @@ -569,8 +569,16 @@ async function syncMeetings() { // Convert to markdown and save const markdown = meetingToMarkdown(meetingData); - const filename = `${meetingId}_${cleanFilename(meetingData.title || 'untitled')}.md`; - const filePath = path.join(SYNC_DIR, filename); + const meetingDate = new Date(meetingData.dateString || meetingData.date || Date.now()); + const dateDir = path.join( + SYNC_DIR, + String(meetingDate.getFullYear()), + String(meetingDate.getMonth() + 1).padStart(2, '0'), + String(meetingDate.getDate()).padStart(2, '0') + ); + fs.mkdirSync(dateDir, { recursive: true }); + const filename = `${cleanFilename(meetingData.title || 'untitled')}.md`; + const filePath = path.join(dateDir, filename); fs.writeFileSync(filePath, markdown); console.log(`[Fireflies] Saved: ${filename}`); diff --git a/apps/x/packages/core/src/knowledge/tag_notes.ts b/apps/x/packages/core/src/knowledge/tag_notes.ts index 95934b03..086a3bb5 100644 --- a/apps/x/packages/core/src/knowledge/tag_notes.ts +++ b/apps/x/packages/core/src/knowledge/tag_notes.ts @@ -30,17 +30,17 @@ function getUntaggedNotes(state: NoteTaggingState): string[] { const untagged: string[] = []; const noteFolders = getNoteTypeDefinitions().map(d => d.folder); - for (const folder of noteFolders) { - const folderPath = path.join(KNOWLEDGE_DIR, folder); - if (!fs.existsSync(folderPath)) { - continue; - } - - const entries = fs.readdirSync(folderPath); + function scanDir(dir: string) { + const entries = fs.readdirSync(dir); for (const entry of entries) { - const fullPath = path.join(folderPath, entry); + const fullPath = path.join(dir, entry); const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + scanDir(fullPath); + continue; + } + if (!stat.isFile() || !entry.endsWith('.md')) { continue; } @@ -64,6 +64,14 @@ function getUntaggedNotes(state: NoteTaggingState): string[] { } } + for (const folder of noteFolders) { + const folderPath = path.join(KNOWLEDGE_DIR, folder); + if (!fs.existsSync(folderPath)) { + continue; + } + scanDir(folderPath); + } + return untagged; } From 970bb75faa3b2f974134caa38f1a0adff75ee17a Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:02:17 +0530 Subject: [PATCH 06/61] better meeting organization and sidebar display - Meeting notes saved as YYYY/MM/DD/{Title}.md under each source folder - Sidebar flattens date hierarchy to show "2026-03-15 Title.md" (newest first) - Empty source folders (granola/fireflies) and empty Meetings folder hidden from sidebar Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/x/apps/renderer/src/App.tsx | 45 +++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index ff6d8dc1..f82a4899 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -271,6 +271,49 @@ function sortNodes(nodes: TreeNode[]): TreeNode[] { }) } +/** + * Flatten date-based folder hierarchy under Meetings/ source folders. + * Turns Meetings/granola/2026/03/15/Title.md into a flat list under + * Meetings/granola/ with display name "2026-03-15 Title". + */ +function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] { + return nodes.flatMap(node => { + if (node.kind !== 'dir' || node.name !== 'Meetings') return [node] + + const flattenedSourceChildren = (node.children ?? []).flatMap(sourceNode => { + if (sourceNode.kind !== 'dir') return [sourceNode] + + // Collect all files recursively from the date hierarchy + const files: TreeNode[] = [] + function collectFiles(n: TreeNode, dateParts: string[]) { + for (const child of n.children ?? []) { + if (child.kind === 'file') { + const dateStr = dateParts.join('-') + const displayName = dateStr ? `${dateStr} ${child.name}` : child.name + files.push({ ...child, name: displayName }) + } else if (child.kind === 'dir') { + collectFiles(child, [...dateParts, child.name]) + } + } + } + collectFiles(sourceNode, []) + + // Hide empty source folders + if (files.length === 0) return [] + + // Sort files reverse chronologically (newest first) + files.sort((a, b) => b.name.localeCompare(a.name)) + + return [{ ...sourceNode, children: files }] + }) + + // Hide Meetings folder entirely if no source folders have files + if (flattenedSourceChildren.length === 0) return [] + + return [{ ...node, children: flattenedSourceChildren }] + }) +} + // Build tree structure from flat entries function buildTree(entries: DirEntry[]): TreeNode[] { const treeMap = new Map() @@ -928,7 +971,7 @@ function App() { opts: { recursive: false, includeHidden: false, includeStats: true } }).catch(() => [] as DirEntry[]), ]) - const knowledgeTree = buildTree(knowledgeResult) + const knowledgeTree = flattenMeetingsTree(buildTree(knowledgeResult)) const basesChildren: TreeNode[] = (basesResult as DirEntry[]) .filter((e) => e.name.endsWith('.base')) .map((e) => ({ ...e, kind: 'file' as const })) From 09395a72c776a763c8f7a2ec3bac93d96bbc0cfd Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:02:24 +0530 Subject: [PATCH 07/61] fix voice memos and move metadata to properties - Fix duplicate tab bug via onNoteCreatedRef (stale closure in recorder.onstop) - Fix transcription not showing: read file from disk and update editor directly - Move voice memo type/recorded/path from body to YAML frontmatter (Properties) - Update note creation agent to detect voice memos via frontmatter Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/x/apps/renderer/src/App.tsx | 33 +++++++++++- .../src/components/sidebar-content.tsx | 51 +++++++++++-------- .../core/src/knowledge/note_creation.ts | 4 +- 3 files changed, 62 insertions(+), 26 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index f82a4899..a1a63811 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -3263,9 +3263,38 @@ function App() { return newSet }) - // Select the file to show it in the editor + // If tab already exists for this path (e.g. second call after transcription), + // force a content reload instead of creating a duplicate tab. + const existingTab = fileTabs.find(tab => tab.path === notePath) + if (existingTab) { + setActiveFileTabId(existingTab.id) + // Read fresh content from disk and update the editor + try { + const result = await window.ipc.invoke('workspace:readFile', { path: notePath, encoding: 'utf8' }) + const { raw: fm, body } = splitFrontmatter(result.data) + frontmatterByPathRef.current.set(notePath, fm) + setFileContent(body) + setEditorContent(body) + editorContentRef.current = body + editorPathRef.current = notePath + initialContentRef.current = body + initialContentByPathRef.current.set(notePath, body) + setEditorContentByPath(prev => ({ ...prev, [notePath]: body })) + editorContentByPathRef.current.set(notePath, body) + // Bump editor session to force TipTap to pick up the new content + setEditorSessionByTabId(prev => ({ + ...prev, + [existingTab.id]: (prev[existingTab.id] ?? 0) + 1, + })) + } catch { + // File read failed — ignore + } + return + } + + // First call — open the file in a tab navigateToFile(notePath) - }, [loadDirectory, navigateToFile]) + }, [loadDirectory, navigateToFile, fileTabs]) const ensureWikiFile = useCallback(async (wikiPath: string) => { const resolvedPath = toKnowledgePath(wikiPath) diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 2ae699a9..40353183 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -608,6 +608,9 @@ function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) => const notePathRef = React.useRef(null) const timestampRef = React.useRef(null) const relativePathRef = React.useRef(null) + // Keep a ref to always call the latest onNoteCreated (avoids stale closure in recorder.onstop) + const onNoteCreatedRef = React.useRef(onNoteCreated) + React.useEffect(() => { onNoteCreatedRef.current = onNoteCreated }, [onNoteCreated]) React.useEffect(() => { window.ipc.invoke('workspace:readFile', { @@ -642,11 +645,12 @@ function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) => recursive: true, }) - const initialContent = `# Voice Memo - -**Type:** voice memo -**Recorded:** ${now.toLocaleString()} -**Path:** ${relativePath} + const initialContent = `--- +type: voice memo +recorded: "${now.toISOString()}" +path: ${relativePath} +--- +# Voice Memo ## Transcript @@ -659,7 +663,7 @@ function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) => }) // Select the note so the user can see it - onNoteCreated?.(notePath) + onNoteCreatedRef.current?.(notePath) // Start actual recording const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) @@ -707,11 +711,12 @@ function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) => const currentNotePath = notePathRef.current const currentRelativePath = relativePathRef.current if (currentNotePath && currentRelativePath) { - const transcribingContent = `# Voice Memo - -**Type:** voice memo -**Recorded:** ${new Date().toLocaleString()} -**Path:** ${currentRelativePath} + const transcribingContent = `--- +type: voice memo +recorded: "${new Date().toISOString()}" +path: ${currentRelativePath} +--- +# Voice Memo ## Transcript @@ -728,21 +733,23 @@ function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) => const transcript = await transcribeWithDeepgram(blob) if (currentNotePath && currentRelativePath) { const finalContent = transcript - ? `# Voice Memo - -**Type:** voice memo -**Recorded:** ${new Date().toLocaleString()} -**Path:** ${currentRelativePath} + ? `--- +type: voice memo +recorded: "${new Date().toISOString()}" +path: ${currentRelativePath} +--- +# Voice Memo ## Transcript ${transcript} ` - : `# Voice Memo - -**Type:** voice memo -**Recorded:** ${new Date().toLocaleString()} -**Path:** ${currentRelativePath} + : `--- +type: voice memo +recorded: "${new Date().toISOString()}" +path: ${currentRelativePath} +--- +# Voice Memo ## Transcript @@ -755,7 +762,7 @@ ${transcript} }) // Re-select to trigger refresh - onNoteCreated?.(currentNotePath) + onNoteCreatedRef.current?.(currentNotePath) if (transcript) { toast('Voice note transcribed', 'success') diff --git a/apps/x/packages/core/src/knowledge/note_creation.ts b/apps/x/packages/core/src/knowledge/note_creation.ts index bec0dd21..adc1eafc 100644 --- a/apps/x/packages/core/src/knowledge/note_creation.ts +++ b/apps/x/packages/core/src/knowledge/note_creation.ts @@ -165,8 +165,8 @@ workspace-readFile({ path: "{source_file}" }) - Email signature **Voice memo indicators:** -- Has \`**Type:** voice memo\` field -- Has \`**Path:**\` field with path like \`Voice Memos/YYYY-MM-DD/...\` +- Has YAML frontmatter with \`type: voice memo\` +- Has frontmatter \`path:\` field like \`Voice Memos/YYYY-MM-DD/...\` - Has \`## Transcript\` section **Set processing mode:** From 65e2b3b868032d1cc13566f45d0d8bec95a48890 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:02:29 +0530 Subject: [PATCH 08/61] add Notes folder and move sync state out of knowledge - Add knowledge/Notes/ folder created on startup, pinned to top of sidebar - New Note/New Folder default to creating inside Notes/ - Move Granola sync state to ~/.rowboat/granola_sync_state.json - Move Fireflies sync state to ~/.rowboat/fireflies_sync_state.json Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/x/apps/renderer/src/App.tsx | 18 ++++++++++++++---- .../core/src/knowledge/granola/sync.ts | 2 +- .../core/src/knowledge/sync_fireflies.ts | 2 +- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a1a63811..fa929b8c 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -258,10 +258,18 @@ const normalizeUsage = (usage?: Partial | null): LanguageMod } } -// Sort nodes (dirs first, then alphabetically) +// Pinned folders appear first in the sidebar (in this order) +const PINNED_FOLDERS = ['Notes'] + +// Sort nodes (dirs first, pinned folders at top, then alphabetically) function sortNodes(nodes: TreeNode[]): TreeNode[] { return nodes.sort((a, b) => { if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1 + const aPinned = PINNED_FOLDERS.indexOf(a.name) + const bPinned = PINNED_FOLDERS.indexOf(b.name) + if (aPinned !== -1 && bPinned !== -1) return aPinned - bPinned + if (aPinned !== -1) return -1 + if (bPinned !== -1) return 1 return a.name.localeCompare(b.name) }).map(node => { if (node.children) { @@ -991,10 +999,12 @@ function App() { } }, []) - // Ensure bases/ directory exists on startup + // Ensure bases/ and knowledge/Notes/ directories exist on startup useEffect(() => { window.ipc.invoke('workspace:mkdir', { path: 'bases', recursive: true }) .catch((err: unknown) => console.error('Failed to ensure bases directory:', err)) + window.ipc.invoke('workspace:mkdir', { path: 'knowledge/Notes', recursive: true }) + .catch((err: unknown) => console.error('Failed to ensure Notes directory:', err)) }, []) // Load initial tree @@ -3113,7 +3123,7 @@ function App() { }, []) const knowledgeActions = React.useMemo(() => ({ - createNote: async (parentPath: string = 'knowledge') => { + createNote: async (parentPath: string = 'knowledge/Notes') => { try { let index = 0 let name = untitledBaseName @@ -3136,7 +3146,7 @@ function App() { throw err } }, - createFolder: async (parentPath: string = 'knowledge') => { + createFolder: async (parentPath: string = 'knowledge/Notes') => { try { await window.ipc.invoke('workspace:mkdir', { path: `${parentPath}/new-folder-${Date.now()}`, diff --git a/apps/x/packages/core/src/knowledge/granola/sync.ts b/apps/x/packages/core/src/knowledge/granola/sync.ts index 2c647c9a..2c1e82c4 100644 --- a/apps/x/packages/core/src/knowledge/granola/sync.ts +++ b/apps/x/packages/core/src/knowledge/granola/sync.ts @@ -18,7 +18,7 @@ const GRANOLA_CLIENT_VERSION = '6.462.1'; const GRANOLA_API_BASE = 'https://api.granola.ai'; const GRANOLA_CONFIG_PATH = path.join(homedir(), 'Library', 'Application Support', 'Granola', 'supabase.json'); const SYNC_DIR = path.join(WorkDir, 'knowledge', 'Meetings', 'granola'); -const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json'); +const STATE_FILE = path.join(WorkDir, 'granola_sync_state.json'); const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const API_DELAY_MS = 1000; // 1 second delay between API calls const RATE_LIMIT_RETRY_DELAY_MS = 60 * 1000; // Wait 1 minute on rate limit diff --git a/apps/x/packages/core/src/knowledge/sync_fireflies.ts b/apps/x/packages/core/src/knowledge/sync_fireflies.ts index 1244b2dd..faa885e2 100644 --- a/apps/x/packages/core/src/knowledge/sync_fireflies.ts +++ b/apps/x/packages/core/src/knowledge/sync_fireflies.ts @@ -8,7 +8,7 @@ import { limitEventItems } from './limit_event_items.js'; // Configuration const SYNC_DIR = path.join(WorkDir, 'knowledge', 'Meetings', 'fireflies'); const SYNC_INTERVAL_MS = 30 * 60 * 1000; // Check every 30 minutes (reduced from 1 minute) -const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json'); +const STATE_FILE = path.join(WorkDir, 'fireflies_sync_state.json'); const LOOKBACK_DAYS = 30; // Last 1 month const API_DELAY_MS = 2000; // 2 second delay between API calls const RATE_LIMIT_RETRY_DELAY_MS = 60 * 1000; // Wait 1 minute on rate limit From 86818e7d217836646149162f5330380f1cd4d2aa Mon Sep 17 00:00:00 2001 From: Tushar <47842976+tusharmagar@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:04:57 +0530 Subject: [PATCH 09/61] Onboarding rebased (#426) * Enhance onboarding modal to support multiple paths (Rowboat and BYOK). v1 * new onboarding flow * Resolve stash merge conflicts: keep both inline-task and billing features Co-Authored-By: Claude Opus 4.6 * Refactor billing information structure and API integration * onboarding ui refactor * Update import path for getAccessToken in billing.ts to reflect new directory structure * Implement Gmail integration with Composio, enhancing onboarding flow to support Gmail connection status and API key management. Update ConnectorsPopover and SettingsDialog components to reflect new functionality, including dynamic tab visibility based on Rowboat connection status. * use composio for calendar * Enhance onboarding flow to support Google Calendar integration with Composio. Add state management for Google Calendar connection status, loading states, and connection handling. Update UI components to reflect Google Calendar connectivity in onboarding steps. * Integrate Google Calendar sync functionality with Composio, enhancing the connection handling in composio-handler and oauth-handler. Update onboarding modal and connectors-popover to manage connection states and provide user feedback during the sync process. Implement Composio-based event syncing in sync_calendar.ts. * Maximize window on ready-to-show event in main.ts to improve user experience by preventing blank screen on launch. * Enhance WelcomeStep component in onboarding flow with new feature highlights and animations. Introduce icons for memory, connectivity, and privacy features. Update logo display with ambient glow effect and improve user feedback during connection states. * Refactor voice availability check in App component to trigger on OAuth state changes. Update SidebarContentPanel to enhance billing display with improved styling and clearer upgrade call-to-action. * Enhance OAuth event handling by notifying the renderer on provider disconnection. Update ConnectorsPopover to listen for OAuth state changes, and refine WelcomeStep component by removing feature highlights and adjusting layout for improved user experience. * Implement Rowboat model settings in the settings dialog, including loading and saving model configurations based on Rowboat connection status. Enhance chat input component to manage Rowboat connection state and update model loading logic accordingly. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Arjun <6592213+arkml@users.noreply.github.com> --- apps/x/.claude/launch.json | 11 + apps/x/apps/main/src/composio-handler.ts | 15 + apps/x/apps/main/src/ipc.ts | 8 + apps/x/apps/main/src/main.ts | 1 + apps/x/apps/main/src/oauth-handler.ts | 6 + apps/x/apps/renderer/src/App.css | 62 ++ apps/x/apps/renderer/src/App.tsx | 14 +- .../components/chat-input-with-mentions.tsx | 163 ++-- .../src/components/connectors-popover.tsx | 189 ++++- .../src/components/google-client-id-modal.tsx | 65 +- .../src/components/onboarding-modal.tsx | 374 +++++++-- .../src/components/onboarding/index.tsx | 83 ++ .../components/onboarding/provider-icons.tsx | 107 +++ .../components/onboarding/step-indicator.tsx | 68 ++ .../onboarding/steps/completion-step.tsx | 154 ++++ .../steps/connect-accounts-step.tsx | 294 +++++++ .../onboarding/steps/llm-setup-step.tsx | 300 ++++++++ .../onboarding/steps/welcome-step.tsx | 124 +++ .../onboarding/use-onboarding-state.ts | 720 ++++++++++++++++++ .../src/components/settings-dialog.tsx | 146 +++- .../src/components/sidebar-content.tsx | 18 + apps/x/apps/renderer/src/hooks/useBilling.ts | 36 + apps/x/packages/core/src/billing/billing.ts | 39 + apps/x/packages/core/src/composio/client.ts | 10 + .../core/src/knowledge/sync_calendar.ts | 253 +++++- .../packages/core/src/knowledge/sync_gmail.ts | 6 +- apps/x/packages/shared/src/ipc.ts | 16 + 27 files changed, 3114 insertions(+), 168 deletions(-) create mode 100644 apps/x/.claude/launch.json create mode 100644 apps/x/apps/renderer/src/components/onboarding/index.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts create mode 100644 apps/x/apps/renderer/src/hooks/useBilling.ts create mode 100644 apps/x/packages/core/src/billing/billing.ts diff --git a/apps/x/.claude/launch.json b/apps/x/.claude/launch.json new file mode 100644 index 00000000..3ba43066 --- /dev/null +++ b/apps/x/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "renderer-dev", + "runtimeExecutable": "/Users/tusharmagar/Rowboat/rowboat-V2/apps/x/apps/renderer/node_modules/.bin/vite", + "runtimeArgs": ["--port", "5173"], + "port": 5173 + } + ] +} diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index cace8c74..36470d3e 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -5,6 +5,7 @@ import { composioAccountsRepo } from '@x/core/dist/composio/repo.js'; import type { LocalConnectedAccount, ZExecuteActionResponse } from '@x/core/dist/composio/types.js'; import { z } from 'zod'; import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; +import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js'; const REDIRECT_URI = 'http://localhost:8081/oauth/callback'; @@ -145,7 +146,11 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ // Set up callback server let cleanupTimeout: NodeJS.Timeout; + let callbackHandled = false; const { server } = await createAuthServer(8081, async (_code, _state) => { + // Guard against duplicate callbacks (browser may send multiple requests) + if (callbackHandled) return; + callbackHandled = true; // OAuth callback received - sync the account status try { const accountStatus = await composioClient.getConnectedAccount(connectedAccountId); @@ -156,6 +161,9 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ if (toolkitSlug === 'gmail') { triggerGmailSync(); } + if (toolkitSlug === 'googlecalendar') { + triggerCalendarSync(); + } } else { emitComposioEvent({ toolkitSlug, @@ -277,6 +285,13 @@ export async function useComposioForGoogle(): Promise<{ enabled: boolean }> { return { enabled: await composioClient.useComposioForGoogle() }; } +/** + * Check if Composio should be used for Google Calendar + */ +export async function useComposioForGoogleCalendar(): Promise<{ enabled: boolean }> { + return { enabled: await composioClient.useComposioForGoogleCalendar() }; +} + /** * Execute a Composio action */ diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 35856e08..2b43983f 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -40,6 +40,7 @@ import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedu import { search } from '@x/core/dist/search/search.js'; import { versionHistory, voice } from '@x/core'; import { classifySchedule } from '@x/core/dist/knowledge/inline_tasks.js'; +import { getBillingInfo } from '@x/core/dist/billing/billing.js'; /** * Convert markdown to a styled HTML document for PDF/DOCX export. @@ -546,6 +547,9 @@ export function setupIpcHandlers() { 'composio:use-composio-for-google': async () => { return composioHandler.useComposioForGoogle(); }, + 'composio:use-composio-for-google-calendar': async () => { + return composioHandler.useComposioForGoogleCalendar(); + }, // Agent schedule handlers 'agent-schedule:getConfig': async () => { const repo = container.resolve('agentScheduleRepo'); @@ -710,5 +714,9 @@ export function setupIpcHandlers() { 'voice:getDeepgramToken': async () => { return voice.getDeepgramToken(); }, + // Billing handler + 'billing:getInfo': async () => { + return await getBillingInfo(); + }, }); } diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 4dbe6117..579fdbfa 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -103,6 +103,7 @@ function createWindow() { // Show window when content is ready to prevent blank screen win.once("ready-to-show", () => { + win.maximize(); win.show(); }); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 2efc77c2..b38a42d2 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -186,7 +186,11 @@ export async function connectProvider(provider: string, clientId?: string): Prom }); // Create callback server + let callbackHandled = false; const { server } = await createAuthServer(8080, async (code, receivedState) => { + // Guard against duplicate callbacks (browser may send multiple requests) + if (callbackHandled) return; + callbackHandled = true; // Validate state if (receivedState !== state) { throw new Error('Invalid state parameter - possible CSRF attack'); @@ -282,6 +286,8 @@ export async function disconnectProvider(provider: string): Promise<{ success: b try { const oauthRepo = getOAuthRepo(); await oauthRepo.delete(provider); + // Notify renderer so sidebar, voice, and billing re-check state + emitOAuthEvent({ provider, success: false }); return { success: true }; } catch (error) { console.error('OAuth disconnect failed:', error); diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 991236ea..5c1eabb2 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -49,6 +49,15 @@ color: #888; } +/* Onboarding dot grid background */ +.onboarding-dot-grid { + background-image: radial-gradient(circle, oklch(0.5 0 0 / 0.08) 1px, transparent 1px); + background-size: 24px 24px; +} +.dark .onboarding-dot-grid { + background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px); +} + @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); @@ -293,3 +302,56 @@ pointer-events: none; user-select: none; } + +/* Upgrade button: grainy gradient sweep on hover */ +.upgrade-btn { + position: relative; + overflow: hidden; + isolation: isolate; +} + +.upgrade-btn::before { + content: ''; + position: absolute; + inset: 0; + background: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.25'/%3E%3C/svg%3E"), + linear-gradient( + 90deg, + transparent 0%, + rgba(168, 85, 247, 0.35) 20%, + rgba(236, 72, 153, 0.4) 40%, + rgba(251, 146, 60, 0.35) 60%, + rgba(168, 85, 247, 0.3) 80%, + transparent 100% + ); + background-size: 100px 100px, 100% 100%; + transform: translateX(-120%); + opacity: 0; + z-index: 1; + pointer-events: none; + border-radius: inherit; +} + +.upgrade-btn:hover::before { + animation: grain-sweep 2.4s ease-in-out infinite; +} + +@keyframes grain-sweep { + 0% { + opacity: 1; + transform: translateX(-120%); + } + 45% { + opacity: 1; + transform: translateX(120%); + } + 55% { + opacity: 1; + transform: translateX(120%); + } + 100% { + opacity: 1; + transform: translateX(-120%); + } +} diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index fa929b8c..ae8d60c9 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -49,7 +49,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' -import { OnboardingModal } from '@/components/onboarding-modal' +import { OnboardingModal } from '@/components/onboarding' import { SearchDialog } from '@/components/search-dialog' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { VersionHistoryPanel } from '@/components/version-history-panel' @@ -619,8 +619,8 @@ function App() { const voiceRef = useRef(voice) voiceRef.current = voice - // Check if voice is available on mount - useEffect(() => { + // Check if voice is available on mount and when OAuth state changes + const refreshVoiceAvailability = useCallback(() => { Promise.all([ window.ipc.invoke('voice:getConfig', null), window.ipc.invoke('oauth:getState', null), @@ -634,6 +634,14 @@ function App() { }) }, []) + useEffect(() => { + refreshVoiceAvailability() + const cleanup = window.ipc.on('oauth:didConnect', () => { + refreshVoiceAvailability() + }) + return cleanup + }, [refreshVoiceAvailability]) + const handleStartRecording = useCallback(() => { setIsRecording(true) isRecordingRef.current = true diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 057550b2..74a0b2ed 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -66,6 +66,7 @@ const providerDisplayNames: Record = { openrouter: 'OpenRouter', aigateway: 'AI Gateway', 'openai-compatible': 'OpenAI-Compatible', + rowboat: 'Rowboat', } interface ConfiguredModel { @@ -156,51 +157,103 @@ function ChatInputInner({ const [activeModelKey, setActiveModelKey] = useState('') const [searchEnabled, setSearchEnabled] = useState(false) const [searchAvailable, setSearchAvailable] = useState(false) + const [isRowboatConnected, setIsRowboatConnected] = useState(false) - // Load model config from disk (on mount and whenever tab becomes active) + // Check Rowboat sign-in state + useEffect(() => { + window.ipc.invoke('oauth:getState', null).then((result) => { + setIsRowboatConnected(result.config?.rowboat?.connected ?? false) + }).catch(() => setIsRowboatConnected(false)) + }, [isActive]) + + // Update sign-in state when OAuth events fire + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', () => { + window.ipc.invoke('oauth:getState', null).then((result) => { + setIsRowboatConnected(result.config?.rowboat?.connected ?? false) + }).catch(() => setIsRowboatConnected(false)) + }) + return cleanup + }, []) + + // Load model config (gateway when signed in, local config when BYOK) const loadModelConfig = useCallback(async () => { try { - const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) - const parsed = JSON.parse(result.data) - const models: ConfiguredModel[] = [] - if (parsed?.providers) { - for (const [flavor, entry] of Object.entries(parsed.providers)) { - const e = entry as Record - const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : [] - const singleModel = typeof e.model === 'string' ? e.model : '' - const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : [] - for (const model of allModels) { - if (model) { - models.push({ - flavor, - model, - apiKey: (e.apiKey as string) || undefined, - baseURL: (e.baseURL as string) || undefined, - headers: (e.headers as Record) || undefined, - knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined, - }) + if (isRowboatConnected) { + // Fetch gateway models + const listResult = await window.ipc.invoke('models:list', null) + const rowboatProvider = listResult.providers?.find( + (p: { id: string }) => p.id === 'rowboat' + ) + const models: ConfiguredModel[] = (rowboatProvider?.models || []).map( + (m: { id: string }) => ({ flavor: 'rowboat', model: m.id }) + ) + + // Read current default from config + let defaultModel = '' + try { + const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) + const parsed = JSON.parse(result.data) + defaultModel = parsed?.model || '' + } catch { /* no config yet */ } + + if (defaultModel) { + models.sort((a, b) => { + if (a.model === defaultModel) return -1 + if (b.model === defaultModel) return 1 + return 0 + }) + } + + setConfiguredModels(models) + const activeKey = defaultModel + ? `rowboat/${defaultModel}` + : models[0] ? `rowboat/${models[0].model}` : '' + if (activeKey) setActiveModelKey(activeKey) + } else { + // BYOK: read from local models.json + const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) + const parsed = JSON.parse(result.data) + const models: ConfiguredModel[] = [] + if (parsed?.providers) { + for (const [flavor, entry] of Object.entries(parsed.providers)) { + const e = entry as Record + const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : [] + const singleModel = typeof e.model === 'string' ? e.model : '' + const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : [] + for (const model of allModels) { + if (model) { + models.push({ + flavor, + model, + apiKey: (e.apiKey as string) || undefined, + baseURL: (e.baseURL as string) || undefined, + headers: (e.headers as Record) || undefined, + knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined, + }) + } } } } - } - const defaultKey = parsed?.provider?.flavor && parsed?.model - ? `${parsed.provider.flavor}/${parsed.model}` - : '' - models.sort((a, b) => { - const aKey = `${a.flavor}/${a.model}` - const bKey = `${b.flavor}/${b.model}` - if (aKey === defaultKey) return -1 - if (bKey === defaultKey) return 1 - return 0 - }) - setConfiguredModels(models) - if (defaultKey) { - setActiveModelKey(defaultKey) + const defaultKey = parsed?.provider?.flavor && parsed?.model + ? `${parsed.provider.flavor}/${parsed.model}` + : '' + models.sort((a, b) => { + const aKey = `${a.flavor}/${a.model}` + const bKey = `${b.flavor}/${b.model}` + if (aKey === defaultKey) return -1 + if (bKey === defaultKey) return 1 + return 0 + }) + setConfiguredModels(models) + if (defaultKey) { + setActiveModelKey(defaultKey) + } } } catch { // No config yet } - }, []) + }, [isRowboatConnected]) useEffect(() => { loadModelConfig() @@ -238,22 +291,32 @@ function ChatInputInner({ const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key) if (!entry) return setActiveModelKey(key) - // Collect all models for this provider so the full list is preserved - const providerModels = configuredModels - .filter((m) => m.flavor === entry.flavor) - .map((m) => m.model) + try { - await window.ipc.invoke('models:saveConfig', { - provider: { - flavor: entry.flavor, - apiKey: entry.apiKey, - baseURL: entry.baseURL, - headers: entry.headers, - }, - model: entry.model, - models: providerModels, - knowledgeGraphModel: entry.knowledgeGraphModel, - }) + if (entry.flavor === 'rowboat') { + // Gateway model — save with valid Zod flavor, no credentials + await window.ipc.invoke('models:saveConfig', { + provider: { flavor: 'openrouter' as const }, + model: entry.model, + knowledgeGraphModel: entry.knowledgeGraphModel, + }) + } else { + // BYOK — preserve full provider config + const providerModels = configuredModels + .filter((m) => m.flavor === entry.flavor) + .map((m) => m.model) + await window.ipc.invoke('models:saveConfig', { + provider: { + flavor: entry.flavor, + apiKey: entry.apiKey, + baseURL: entry.baseURL, + headers: entry.headers, + }, + model: entry.model, + models: providerModels, + knowledgeGraphModel: entry.knowledgeGraphModel, + }) + } } catch { toast.error('Failed to switch model') } diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index 268fcfe7..254a4331 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { AlertTriangle, Loader2, Mic, Mail, MessageSquare, User } from "lucide-react" +import { AlertTriangle, Loader2, Mic, Mail, Calendar, MessageSquare, User } from "lucide-react" import { Popover, @@ -75,7 +75,13 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha const [gmailLoading, setGmailLoading] = useState(true) const [gmailConnecting, setGmailConnecting] = useState(false) - // Load available providers and composio-for-google flag on mount + // Composio/Google Calendar state + const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) + const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) + const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) + + // Load available providers on mount useEffect(() => { async function loadProviders() { try { @@ -89,6 +95,12 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha setProvidersLoading(false) } } + loadProviders() + }, []) + + // Re-check composio-for-google flag every time the popover opens + useEffect(() => { + if (!open) return async function loadComposioForGoogleFlag() { try { const result = await window.ipc.invoke('composio:use-composio-for-google', null) @@ -97,9 +109,17 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha console.error('Failed to check composio-for-google flag:', error) } } - loadProviders() + async function loadComposioForGoogleCalendarFlag() { + try { + const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) + setUseComposioForGoogleCalendar(result.enabled) + } catch (error) { + console.error('Failed to check composio-for-google-calendar flag:', error) + } + } loadComposioForGoogleFlag() - }, []) + loadComposioForGoogleCalendarFlag() + }, [open]) // Load Granola config const refreshGranolaConfig = useCallback(async () => { @@ -184,6 +204,20 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha } }, []) + // Load Google Calendar connection status + const refreshGoogleCalendarStatus = useCallback(async () => { + try { + setGoogleCalendarLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' }) + setGoogleCalendarConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Google Calendar status:', error) + setGoogleCalendarConnected(false) + } finally { + setGoogleCalendarLoading(false) + } + }, []) + // Connect to Gmail via Composio const startGmailConnect = useCallback(async () => { try { @@ -212,6 +246,52 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha await startGmailConnect() }, [startGmailConnect]) + // Connect to Google Calendar via Composio + const startGoogleCalendarConnect = useCallback(async () => { + try { + setGoogleCalendarConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'googlecalendar' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Google Calendar') + setGoogleCalendarConnecting(false) + } + } catch (error) { + console.error('Failed to connect to Google Calendar:', error) + toast.error('Failed to connect to Google Calendar') + setGoogleCalendarConnecting(false) + } + }, []) + + // Handle Google Calendar connect button click + const handleConnectGoogleCalendar = useCallback(async () => { + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyTarget('gmail') + setComposioApiKeyOpen(true) + return + } + await startGoogleCalendarConnect() + }, [startGoogleCalendarConnect]) + + // Disconnect from Google Calendar + const handleDisconnectGoogleCalendar = useCallback(async () => { + try { + setGoogleCalendarLoading(true) + const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'googlecalendar' }) + if (result.success) { + setGoogleCalendarConnected(false) + toast.success('Disconnected from Google Calendar') + } else { + toast.error('Failed to disconnect from Google Calendar') + } + } catch (error) { + console.error('Failed to disconnect from Google Calendar:', error) + toast.error('Failed to disconnect from Google Calendar') + } finally { + setGoogleCalendarLoading(false) + } + }, []) + // Disconnect from Gmail const handleDisconnectGmail = useCallback(async () => { try { @@ -292,6 +372,11 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha refreshGmailStatus() } + // Refresh Google Calendar Composio status if enabled + if (useComposioForGoogleCalendar) { + refreshGoogleCalendarStatus() + } + // Refresh OAuth providers if (providers.length === 0) return @@ -328,7 +413,7 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha } setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle]) + }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar]) // Refresh statuses when popover opens or providers list changes useEffect(() => { @@ -337,11 +422,11 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha } }, [open, providers, refreshAllStatuses]) - // Listen for OAuth completion events + // Listen for OAuth state change events (connect + disconnect) useEffect(() => { - const cleanup = window.ipc.on('oauth:didConnect', (event) => { - const { provider, success, error } = event - + const cleanup = window.ipc.on('oauth:didConnect', async (event) => { + const { provider, success } = event + setProviderStates(prev => ({ ...prev, [provider]: { @@ -362,17 +447,32 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha } else { toast.success(`Connected to ${displayName}`) } + + // When Rowboat account connects, re-check composio flags so Gmail/Calendar use the right flow + if (provider === 'rowboat') { + try { + const [googleResult, calendarResult] = await Promise.all([ + window.ipc.invoke('composio:use-composio-for-google', null), + window.ipc.invoke('composio:use-composio-for-google-calendar', null), + ]) + setUseComposioForGoogle(googleResult.enabled) + setUseComposioForGoogleCalendar(calendarResult.enabled) + } catch (err) { + console.error('Failed to re-check composio flags:', err) + } + } + // Refresh status to ensure consistency refreshAllStatuses() - } else { - toast.error(error || `Failed to connect to ${provider}`) } + // Note: error toasts for failed connections are handled by startConnect/handleConnect. + // Disconnect events (success: false) are handled by handleDisconnect which shows its own toast. }) return cleanup }, [refreshAllStatuses]) - // Listen for Composio connection events (Gmail) + // Listen for Composio connection events (Gmail, Google Calendar) useEffect(() => { const cleanup = window.ipc.on('composio:didConnect', (event) => { const { toolkitSlug, success, error } = event @@ -390,6 +490,20 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha toast.error(error || 'Failed to connect to Gmail') } } + + if (toolkitSlug === 'googlecalendar') { + setGoogleCalendarConnected(success) + setGoogleCalendarConnecting(false) + + if (success) { + toast.success('Connected to Google Calendar', { + description: 'Syncing your calendar in the background. This may take a few minutes before changes appear.', + duration: 8000, + }) + } else { + toast.error(error || 'Failed to connect to Google Calendar') + } + } }) return cleanup @@ -640,11 +754,11 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha )} {/* Email & Calendar Section */} - {(useComposioForGoogle || providers.includes('google')) && ( + {(useComposioForGoogle || useComposioForGoogleCalendar || providers.includes('google')) && ( <>
- {useComposioForGoogle ? 'Email' : 'Email & Calendar'} + Email & Calendar
{useComposioForGoogle ? ( @@ -696,6 +810,53 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha ) : ( renderOAuthProvider('google', 'Google', , 'Sync emails and calendar') )} + {useComposioForGoogleCalendar && ( +
+
+
+ +
+
+ Google Calendar + {googleCalendarLoading ? ( + Checking... + ) : ( + + Sync calendar events + + )} +
+
+
+ {googleCalendarLoading ? ( + + ) : googleCalendarConnected ? ( + + ) : ( + + )} +
+
+ )} )} diff --git a/apps/x/apps/renderer/src/components/google-client-id-modal.tsx b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx index c4df07a2..3ef536d9 100644 --- a/apps/x/apps/renderer/src/components/google-client-id-modal.tsx +++ b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx @@ -47,19 +47,37 @@ export function GoogleClientIdModal({ return ( - - - Enter Google Client ID - - {description ?? "Enter the client ID for your Google OAuth app to continue."} - - -
- -
- Need help setting this up?{" "} + +
+ + Google Client ID + + {description ?? "Enter the client ID for your Google OAuth app to connect."} + + +
+
+
+ + setClientId(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + handleSubmit() + } + }} + className="font-mono text-xs" + autoFocus + /> +
+

+ Need help?{" "} Read the setup guide - . -

- setClientId(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault() - handleSubmit() - } - }} - autoFocus - /> +

-
+
-
diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 9060a669..d687d81a 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { Loader2, Mic, Mail, CheckCircle2, MessageSquare } from "lucide-react" +import { Loader2, Mic, Mail, Calendar, CheckCircle2, ArrowLeft, MessageSquare } from "lucide-react" import { Dialog, @@ -38,7 +38,9 @@ interface OnboardingModalProps { onComplete: () => void } -type Step = 0 | 1 | 2 +type Step = 0 | 1 | 2 | 3 | 4 + +type OnboardingPath = 'rowboat' | 'byok' | null type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" @@ -50,6 +52,7 @@ interface LlmModelOption { export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [currentStep, setCurrentStep] = useState(0) + const [onboardingPath, setOnboardingPath] = useState(null) // LLM setup state const [llmProvider, setLlmProvider] = useState("openai") @@ -99,6 +102,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [gmailLoading, setGmailLoading] = useState(true) const [gmailConnecting, setGmailConnecting] = useState(false) + // Composio/Google Calendar state + const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) + const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) + const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) + const updateProviderConfig = useCallback( (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => { setProviderConfigs(prev => ({ @@ -150,8 +159,17 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { console.error('Failed to check composio-for-google flag:', error) } } + async function loadComposioForGoogleCalendarFlag() { + try { + const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) + setUseComposioForGoogleCalendar(result.enabled) + } catch (error) { + console.error('Failed to check composio-for-google-calendar flag:', error) + } + } loadProviders() loadComposioForGoogleFlag() + loadComposioForGoogleCalendarFlag() }, [open]) // Load LLM models catalog on open @@ -288,6 +306,20 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, []) + // Load Google Calendar connection status + const refreshGoogleCalendarStatus = useCallback(async () => { + try { + setGoogleCalendarLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' }) + setGoogleCalendarConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Google Calendar status:', error) + setGoogleCalendarConnected(false) + } finally { + setGoogleCalendarLoading(false) + } + }, []) + // Connect to Gmail via Composio const startGmailConnect = useCallback(async () => { try { @@ -315,6 +347,33 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { await startGmailConnect() }, [startGmailConnect]) + // Connect to Google Calendar via Composio + const startGoogleCalendarConnect = useCallback(async () => { + try { + setGoogleCalendarConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'googlecalendar' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Google Calendar') + setGoogleCalendarConnecting(false) + } + } catch (error) { + console.error('Failed to connect to Google Calendar:', error) + toast.error('Failed to connect to Google Calendar') + setGoogleCalendarConnecting(false) + } + }, []) + + // Handle Google Calendar connect button click + const handleConnectGoogleCalendar = useCallback(async () => { + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyTarget('gmail') + setComposioApiKeyOpen(true) + return + } + await startGoogleCalendarConnect() + }, [startGoogleCalendarConnect]) + // Handle Composio API key submission const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => { try { @@ -364,11 +423,29 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { }, []) const handleNext = () => { - if (currentStep < 2) { + if (currentStep < 4) { setCurrentStep((prev) => (prev + 1) as Step) } } + const handleBack = () => { + if (currentStep === 1) { + // BYOK upsell → back to sign-in page + setOnboardingPath(null) + setCurrentStep(0 as Step) + } else if (currentStep === 2) { + // LLM setup → back to BYOK upsell + setCurrentStep(1 as Step) + } else if (currentStep === 3) { + // Connect accounts → back depends on path + if (onboardingPath === 'rowboat') { + setCurrentStep(0 as Step) + } else { + setCurrentStep(2 as Step) + } + } + } + const handleComplete = () => { onComplete() } @@ -420,6 +497,11 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { refreshGmailStatus() } + // Refresh Google Calendar Composio status if enabled + if (useComposioForGoogleCalendar) { + refreshGoogleCalendarStatus() + } + // Refresh OAuth providers if (providers.length === 0) return @@ -447,7 +529,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle]) + }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar]) // Refresh statuses when modal opens or providers list changes useEffect(() => { @@ -456,10 +538,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, [open, providers, refreshAllStatuses]) - // Listen for OAuth completion events + // Listen for OAuth completion events (state updates only — toasts handled by ConnectorsPopover) useEffect(() => { const cleanup = window.ipc.on('oauth:didConnect', (event) => { - const { provider, success, error } = event + const { provider, success } = event setProviderStates(prev => ({ ...prev, @@ -469,35 +551,37 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { isConnecting: false, } })) - - if (success) { - const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) - toast.success(`Connected to ${displayName}`) - } else { - toast.error(error || `Failed to connect to ${provider}`) - } }) return cleanup }, []) - // Listen for Composio connection events (Gmail) + // Auto-advance from Rowboat sign-in step when OAuth completes + useEffect(() => { + if (onboardingPath !== 'rowboat' || currentStep !== 0) return + + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + if (event.provider === 'rowboat' && event.success) { + setCurrentStep(3 as Step) + } + }) + + return cleanup + }, [onboardingPath, currentStep]) + + // Listen for Composio connection events (state updates only — toasts handled by ConnectorsPopover) useEffect(() => { const cleanup = window.ipc.on('composio:didConnect', (event) => { - const { toolkitSlug, success, error } = event + const { toolkitSlug, success } = event if (toolkitSlug === 'gmail') { setGmailConnected(success) setGmailConnecting(false) + } - if (success) { - toast.success('Connected to Gmail', { - description: 'Syncing your emails in the background. This may take a few minutes before changes appear.', - duration: 8000, - }) - } else { - toast.error(error || 'Failed to connect to Gmail') - } + if (toolkitSlug === 'googlecalendar') { + setGoogleCalendarConnected(success) + setGoogleCalendarConnecting(false) } }) @@ -552,20 +636,30 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { startConnect('google', clientId) }, [startConnect]) - // Step indicator - const renderStepIndicator = () => ( -
- {[0, 1, 2].map((step) => ( -
= step ? "bg-primary" : "bg-muted" - )} - /> - ))} -
- ) + // Step indicator - dynamic based on path + const renderStepIndicator = () => { + // Rowboat path: Sign In (0), Connect (3), Done (4) = 3 dots + // BYOK path: Sign In (0), Upsell (1), Model (2), Connect (3), Done (4) = 5 dots + // Before path is chosen: show 3 dots (minimal) + const rowboatSteps = [0, 3, 4] + const byokSteps = [0, 1, 2, 3, 4] + const steps = onboardingPath === 'byok' ? byokSteps : rowboatSteps + const currentIndex = steps.indexOf(currentStep) + + return ( +
+ {steps.map((_, i) => ( +
= i ? "bg-primary" : "bg-muted" + )} + /> + ))} +
+ ) + } // Helper to render an OAuth provider row const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => { @@ -691,6 +785,50 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) + // Render Google Calendar Composio row + const renderGoogleCalendarRow = () => ( +
+
+
+ +
+
+ Google Calendar + {googleCalendarLoading ? ( + Checking... + ) : ( + + Sync calendar events + + )} +
+
+
+ {googleCalendarLoading ? ( + + ) : googleCalendarConnected ? ( +
+ + Connected +
+ ) : ( + + )} +
+
+ ) + // Render Slack row const renderSlackRow = () => (
@@ -772,7 +910,123 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) - // Step 0: LLM Setup + // Step 0: Sign in to Rowboat (with BYOK option) + const renderSignInStep = () => { + const rowboatState = providerStates['rowboat'] || { isConnected: false, isLoading: false, isConnecting: false } + + return ( +
+
+ Your AI coworker, with memory +
+ + Sign in to Rowboat + + Connect your Rowboat account for instant access to all models through our gateway — no API keys needed. + + + + {rowboatState.isConnected ? ( +
+
+ + Connected to Rowboat +
+ +
+ ) : ( +
+ + {rowboatState.isConnecting && ( +

+ Complete sign in in your browser, then return here. +

+ )} +
+ )} + +
+ +
+
+ ) + } + + // Step 1: BYOK upsell — explain benefits of Rowboat before continuing with BYOK + const renderByokUpsellStep = () => ( +
+ + Before you continue + + With a Rowboat account, you get: + + + +
+
+ +
+
Instant access to all models
+
GPT, Claude, Gemini, and more — no separate API keys needed
+
+
+
+ +
+
Simplified billing
+
One account for everything — no juggling multiple provider subscriptions
+
+
+
+ +
+
Automatic updates
+
New models are available as soon as they launch, with no configuration changes
+
+
+
+ +

+ By continuing, you'll set up your own API keys instead of using Rowboat's managed gateway. +

+ +
+ + +
+
+ ) + + // Step 2 (BYOK path): LLM Setup const renderLlmSetupStep = () => { const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [ { id: "openai", name: "OpenAI", description: "Use your OpenAI API key" }, @@ -948,10 +1202,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)} -
+
+ - +
+ + +
) - // Step 2: Completion + // Step 4: Completion const renderCompletionStep = () => { - const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected + const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected || googleCalendarConnected return (
@@ -1059,6 +1323,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { Gmail (Email)
)} + {googleCalendarConnected && ( +
+ + Google Calendar +
+ )} {connectedProviders.includes('google') && (
@@ -1117,9 +1387,11 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { onEscapeKeyDown={(e) => e.preventDefault()} > {renderStepIndicator()} - {currentStep === 0 && renderLlmSetupStep()} - {currentStep === 1 && renderAccountConnectionStep()} - {currentStep === 2 && renderCompletionStep()} + {currentStep === 0 && renderSignInStep()} + {currentStep === 1 && renderByokUpsellStep()} + {currentStep === 2 && renderLlmSetupStep()} + {currentStep === 3 && renderAccountConnectionStep()} + {currentStep === 4 && renderCompletionStep()}
diff --git a/apps/x/apps/renderer/src/components/onboarding/index.tsx b/apps/x/apps/renderer/src/components/onboarding/index.tsx new file mode 100644 index 00000000..d37cdf0f --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/index.tsx @@ -0,0 +1,83 @@ +"use client" + +import * as React from "react" +import { AnimatePresence, motion } from "motion/react" + +import { + Dialog, + DialogContent, +} from "@/components/ui/dialog" +import { GoogleClientIdModal } from "@/components/google-client-id-modal" +import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" +import { useOnboardingState } from "./use-onboarding-state" +import { StepIndicator } from "./step-indicator" +import { WelcomeStep } from "./steps/welcome-step" +import { LlmSetupStep } from "./steps/llm-setup-step" +import { ConnectAccountsStep } from "./steps/connect-accounts-step" +import { CompletionStep } from "./steps/completion-step" + +interface OnboardingModalProps { + open: boolean + onComplete: () => void +} + +export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { + const state = useOnboardingState(open, onComplete) + + const stepContent = React.useMemo(() => { + switch (state.currentStep) { + case 0: + return + case 1: + return + case 2: + return + case 3: + return + } + }, [state.currentStep, state]) + + return ( + <> + + + {}}> + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > +
+ + + + {stepContent} + + +
+
+
+ + ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx b/apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx new file mode 100644 index 00000000..c58c7bf0 --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx @@ -0,0 +1,107 @@ +import { cn } from "@/lib/utils" + +interface IconProps { + className?: string +} + +export function OpenAIIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function AnthropicIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function GoogleIcon({ className }: IconProps) { + return ( + + + + + + + ) +} + +export function OllamaIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function OpenRouterIcon({ className }: IconProps) { + return ( + + + + + ) +} + +export function VercelIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function GmailIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function SlackIcon({ className }: IconProps) { + return ( + + + + + + + ) +} + +export function FirefliesIcon({ className }: IconProps) { + return ( + + + + + + + + + + + ) +} + +export function GranolaIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function GenericApiIcon({ className }: IconProps) { + return ( + + + + ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx b/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx new file mode 100644 index 00000000..6fae6dbb --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx @@ -0,0 +1,68 @@ +import * as React from "react" +import { CheckCircle2 } from "lucide-react" +import { cn } from "@/lib/utils" +import type { Step, OnboardingPath } from "./use-onboarding-state" + +const ROWBOAT_STEPS = [ + { step: 0 as Step, label: "Welcome" }, + { step: 2 as Step, label: "Connect" }, + { step: 3 as Step, label: "Done" }, +] + +const BYOK_STEPS = [ + { step: 0 as Step, label: "Welcome" }, + { step: 1 as Step, label: "Model" }, + { step: 2 as Step, label: "Connect" }, + { step: 3 as Step, label: "Done" }, +] + +interface StepIndicatorProps { + currentStep: Step + path: OnboardingPath +} + +export function StepIndicator({ currentStep, path }: StepIndicatorProps) { + const steps = path === 'byok' ? BYOK_STEPS : ROWBOAT_STEPS + const currentIndex = steps.findIndex(s => s.step === currentStep) + + return ( +
+ {steps.map((s, i) => ( + + {i > 0 && ( +
+ )} +
+
currentIndex && "bg-muted text-muted-foreground" + )} + > + {i < currentIndex ? ( + + ) : ( + i + 1 + )} +
+ + {s.label} + +
+ + ))} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx new file mode 100644 index 00000000..c01e42ea --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx @@ -0,0 +1,154 @@ +import { CheckCircle2 } from "lucide-react" +import { motion } from "motion/react" +import { Button } from "@/components/ui/button" +import type { OnboardingState } from "../use-onboarding-state" + +interface CompletionStepProps { + state: OnboardingState +} + +export function CompletionStep({ state }: CompletionStepProps) { + const { connectedProviders, granolaEnabled, slackEnabled, gmailConnected, googleCalendarConnected, handleComplete } = state + const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected || googleCalendarConnected + + return ( +
+ {/* Animated checkmark */} + + {/* Pulsing ring */} + +
+ +
+
+ + {/* Title */} + + You're All Set! + + + + {hasConnections ? ( + <>Give me 30 minutes to build your context graph. I can still help with other things on your computer. + ) : ( + <>You can connect your accounts anytime from the sidebar to start syncing data. + )} + + + {/* Connected accounts summary */} + {hasConnections && ( + +

Connected

+
+ {gmailConnected && ( + + + Gmail (Email) + + )} + {googleCalendarConnected && ( + + + Google Calendar + + )} + {connectedProviders.includes('google') && ( + + + Google (Email & Calendar) + + )} + {connectedProviders.includes('fireflies-ai') && ( + + + Fireflies (Meeting transcripts) + + )} + {granolaEnabled && ( + + + Granola (Local meeting notes) + + )} + {slackEnabled && ( + + + Slack (Team communication) + + )} +
+
+ )} + + {/* CTA */} + + + +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx new file mode 100644 index 00000000..b152d567 --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx @@ -0,0 +1,294 @@ +import { Loader2, CheckCircle2, ArrowLeft, Calendar } from "lucide-react" +import { motion } from "motion/react" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" +import { GmailIcon, SlackIcon, FirefliesIcon, GranolaIcon } from "../provider-icons" +import type { OnboardingState, ProviderState } from "../use-onboarding-state" + +interface ConnectAccountsStepProps { + state: OnboardingState +} + +function ProviderCard({ + name, + description, + icon, + iconBg, + iconColor, + providerState, + onConnect, + rightSlot, + index, +}: { + name: string + description: string + icon: React.ReactNode + iconBg: string + iconColor: string + providerState?: ProviderState + onConnect?: () => void + rightSlot?: React.ReactNode + index: number +}) { + const isConnected = providerState?.isConnected ?? false + + return ( + +
+
+ {icon} +
+
+
{name}
+
{description}
+
+
+
+ {rightSlot ?? ( + providerState?.isLoading ? ( + + ) : isConnected ? ( +
+ + Connected +
+ ) : ( + + ) + )} +
+
+ ) +} + +export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) { + const { + providers, providersLoading, providerStates, handleConnect, + granolaEnabled, granolaLoading, handleGranolaToggle, + slackEnabled, slackLoading, slackWorkspaces, slackAvailableWorkspaces, + slackSelectedUrls, setSlackSelectedUrls, slackPickerOpen, + slackDiscovering, slackDiscoverError, + handleSlackEnable, handleSlackSaveWorkspaces, handleSlackDisable, + useComposioForGoogle, gmailConnected, gmailLoading, gmailConnecting, handleConnectGmail, + useComposioForGoogleCalendar, googleCalendarConnected, googleCalendarLoading, googleCalendarConnecting, handleConnectGoogleCalendar, + handleNext, handleBack, + } = state + + let cardIndex = 0 + + return ( +
+ {/* Title */} +

+ Connect Your Accounts +

+

+ Connect your accounts to give Rowboat context about your work. You can always add more later. +

+ + {providersLoading ? ( +
+ +
+ ) : ( +
+ {/* Email & Calendar */} + {(useComposioForGoogle || useComposioForGoogleCalendar || providers.includes('google')) && ( +
+ + Email & Calendar + + {useComposioForGoogle ? ( + } + iconBg="bg-red-500/10" + iconColor="text-red-500" + providerState={{ isConnected: gmailConnected, isLoading: gmailLoading, isConnecting: gmailConnecting }} + onConnect={handleConnectGmail} + index={cardIndex++} + /> + ) : ( + } + iconBg="bg-red-500/10" + iconColor="text-red-500" + providerState={providerStates['google']} + onConnect={() => handleConnect('google')} + index={cardIndex++} + /> + )} + {useComposioForGoogleCalendar && ( + } + iconBg="bg-blue-500/10" + iconColor="text-blue-500" + providerState={{ isConnected: googleCalendarConnected, isLoading: googleCalendarLoading, isConnecting: googleCalendarConnecting }} + onConnect={handleConnectGoogleCalendar} + index={cardIndex++} + /> + )} +
+ )} + + {/* Meeting Notes */} +
+ + Meeting Notes + + } + iconBg="bg-purple-500/10" + iconColor="text-purple-500" + providerState={{ isConnected: granolaEnabled, isLoading: false, isConnecting: false }} + rightSlot={ +
+ {granolaLoading && } + +
+ } + index={cardIndex++} + /> + {providers.includes('fireflies-ai') && ( + } + iconBg="bg-amber-500/10" + iconColor="text-amber-500" + providerState={providerStates['fireflies-ai']} + onConnect={() => handleConnect('fireflies-ai')} + index={cardIndex++} + /> + )} +
+ + {/* Team Communication */} +
+ + Team Communication + +
+ 0 + ? slackWorkspaces.map(w => w.name).join(', ') + : "Enable Rowboat to understand your team conversations and provide relevant context" + } + icon={} + iconBg="bg-emerald-500/10" + iconColor="text-emerald-500" + providerState={{ isConnected: slackEnabled, isLoading: false, isConnecting: false }} + rightSlot={ +
+ {(slackLoading || slackDiscovering) && } + {slackEnabled ? ( + handleSlackDisable()} + disabled={slackLoading} + /> + ) : ( + + )} +
+ } + index={cardIndex++} + /> + {slackPickerOpen && ( +
+ {slackDiscoverError ? ( +

{slackDiscoverError}

+ ) : ( + <> + {slackAvailableWorkspaces.map(w => ( + + ))} + + + )} +
+ )} +
+
+
+ )} + + {/* Footer */} +
+ +
+ + +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx new file mode 100644 index 00000000..31a7308a --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx @@ -0,0 +1,300 @@ +import { Loader2, CheckCircle2, ArrowLeft, X, Lightbulb } from "lucide-react" +import { motion } from "motion/react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from "@/lib/utils" +import { + OpenAIIcon, + AnthropicIcon, + GoogleIcon, + OllamaIcon, + OpenRouterIcon, + VercelIcon, + GenericApiIcon, +} from "../provider-icons" +import type { OnboardingState, LlmProviderFlavor } from "../use-onboarding-state" + +interface LlmSetupStepProps { + state: OnboardingState +} + +const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [ + { id: "openai", name: "OpenAI", description: "GPT models", color: "bg-green-500/10 text-green-600 dark:text-green-400", icon: }, + { id: "anthropic", name: "Anthropic", description: "Claude models", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400", icon: }, + { id: "google", name: "Gemini", description: "Google AI Studio", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400", icon: }, + { id: "ollama", name: "Ollama", description: "Local models", color: "bg-purple-500/10 text-purple-600 dark:text-purple-400", icon: }, +] + +const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [ + { id: "openrouter", name: "OpenRouter", description: "Multiple models, one key", color: "bg-pink-500/10 text-pink-600 dark:text-pink-400", icon: }, + { id: "aigateway", name: "AI Gateway", description: "Vercel AI Gateway", color: "bg-sky-500/10 text-sky-600 dark:text-sky-400", icon: }, + { id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom endpoint", color: "bg-gray-500/10 text-gray-600 dark:text-gray-400", icon: }, +] + +export function LlmSetupStep({ state }: LlmSetupStepProps) { + const { + llmProvider, setLlmProvider, modelsCatalog, modelsLoading, modelsError, + activeConfig, testState, setTestState, showApiKey, requiresApiKey, + showBaseURL, isLocalProvider, canTest, showMoreProviders, setShowMoreProviders, + updateProviderConfig, handleTestAndSaveLlmConfig, handleBack, + upsellDismissed, setUpsellDismissed, handleSwitchToRowboat, + } = state + + const isMoreProvider = moreProviders.some(p => p.id === llmProvider) + const modelsForProvider = modelsCatalog[llmProvider] || [] + const showModelInput = isLocalProvider || modelsForProvider.length === 0 + + const renderProviderCard = (provider: typeof primaryProviders[0], index: number) => { + const isSelected = llmProvider === provider.id + return ( + { + setLlmProvider(provider.id) + setTestState({ status: "idle" }) + }} + className={cn( + "rounded-xl border-2 p-4 text-left transition-all", + isSelected + ? "border-primary bg-primary/5 shadow-sm" + : "border-transparent bg-muted/50 hover:bg-muted" + )} + > +
+
+ {provider.icon} +
+
+
{provider.name}
+
{provider.description}
+
+
+
+ ) + } + + return ( +
+ {/* Title */} +

+ Choose your model +

+

+ Select a provider and configure your API key +

+ + {/* Inline Rowboat upsell callout */} + {!upsellDismissed && ( + + +
+

+ Tip: Sign in with Rowboat for instant access to all models — no API keys needed. +

+ +
+ +
+ )} + + {/* Provider selection */} +
+ Provider +
+ {primaryProviders.map((p, i) => renderProviderCard(p, i))} +
+ {(showMoreProviders || isMoreProvider) ? ( +
+ {moreProviders.map((p, i) => renderProviderCard(p, i + 4))} +
+ ) : ( + + )} +
+ + {/* Separator */} +
+ + {/* Model configuration */} +
+

Model Configuration

+ +
+
+ + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { model: e.target.value })} + placeholder="Enter model" + /> + ) : ( + + )} + {modelsError && ( +
{modelsError}
+ )} +
+ +
+ + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + + )} +
+
+ + {showApiKey && ( +
+ + updateProviderConfig(llmProvider, { apiKey: e.target.value })} + placeholder="Paste your API key" + className="font-mono" + /> +
+ )} + + {showBaseURL && ( +
+ + updateProviderConfig(llmProvider, { baseURL: e.target.value })} + placeholder={ + llmProvider === "ollama" + ? "http://localhost:11434" + : llmProvider === "openai-compatible" + ? "http://localhost:1234/v1" + : "https://ai-gateway.vercel.sh/v1" + } + className="font-mono" + /> +
+ )} +
+ + {/* Footer */} +
+ + +
+ {testState.status === "success" && ( + + + Connected + + )} + {testState.status === "error" && ( + + {testState.error} + + )} + +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx new file mode 100644 index 00000000..9a660507 --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx @@ -0,0 +1,124 @@ +import { Loader2, CheckCircle2 } from "lucide-react" +import { motion } from "motion/react" +import { Button } from "@/components/ui/button" +import type { OnboardingState } from "../use-onboarding-state" + +interface WelcomeStepProps { + state: OnboardingState +} + +export function WelcomeStep({ state }: WelcomeStepProps) { + const rowboatState = state.providerStates['rowboat'] || { isConnected: false, isLoading: false, isConnecting: false } + + return ( +
+ {/* Logo with ambient glow */} + +
+ Rowboat + + + {/* Tagline badge */} + + + Your AI coworker, with memory + + + {/* Main heading */} + + Welcome to Rowboat + + + Rowboat connects to your work, builds a knowledge graph, and uses that context to help you get things done. Private and on your machine. + + + {/* Sign in / connected state */} + + {rowboatState.isConnected ? ( +
+
+ + Connected to Rowboat +
+ +
+ ) : ( +
+ + {rowboatState.isConnecting && ( +

+ Complete sign in in your browser, then return here. +

+ )} +
+ )} +
+ + {/* BYOK link */} + + + +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts new file mode 100644 index 00000000..7cc50a90 --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -0,0 +1,720 @@ +import { useState, useEffect, useCallback } from "react" +import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store" +import { toast } from "sonner" + +export interface ProviderState { + isConnected: boolean + isLoading: boolean + isConnecting: boolean +} + +export type Step = 0 | 1 | 2 | 3 + +export type OnboardingPath = 'rowboat' | 'byok' | null + +export type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" + +export interface LlmModelOption { + id: string + name?: string + release_date?: string +} + +export function useOnboardingState(open: boolean, onComplete: () => void) { + const [currentStep, setCurrentStep] = useState(0) + const [onboardingPath, setOnboardingPath] = useState(null) + + // LLM setup state + const [llmProvider, setLlmProvider] = useState("openai") + const [modelsCatalog, setModelsCatalog] = useState>({}) + const [modelsLoading, setModelsLoading] = useState(false) + const [modelsError, setModelsError] = useState(null) + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" }, + }) + const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ + status: "idle", + }) + const [showMoreProviders, setShowMoreProviders] = useState(false) + + // OAuth provider states + const [providers, setProviders] = useState([]) + const [providersLoading, setProvidersLoading] = useState(true) + const [providerStates, setProviderStates] = useState>({}) + const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false) + + // Granola state + const [granolaEnabled, setGranolaEnabled] = useState(false) + const [granolaLoading, setGranolaLoading] = useState(true) + + // Slack state (agent-slack CLI) + const [slackEnabled, setSlackEnabled] = useState(false) + const [slackLoading, setSlackLoading] = useState(true) + const [slackWorkspaces, setSlackWorkspaces] = useState>([]) + const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState>([]) + const [slackSelectedUrls, setSlackSelectedUrls] = useState>(new Set()) + const [slackPickerOpen, setSlackPickerOpen] = useState(false) + const [slackDiscovering, setSlackDiscovering] = useState(false) + const [slackDiscoverError, setSlackDiscoverError] = useState(null) + + // Inline upsell callout dismissed + const [upsellDismissed, setUpsellDismissed] = useState(false) + + // Composio/Gmail state (used when signed in with Rowboat account) + const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) + const [gmailConnected, setGmailConnected] = useState(false) + const [gmailLoading, setGmailLoading] = useState(true) + const [gmailConnecting, setGmailConnecting] = useState(false) + const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) + const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') + + // Composio/Google Calendar state + const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) + const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) + const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) + + const updateProviderConfig = useCallback( + (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => { + setProviderConfigs(prev => ({ + ...prev, + [provider]: { ...prev[provider], ...updates }, + })) + setTestState({ status: "idle" }) + }, + [] + ) + + const activeConfig = providerConfigs[llmProvider] + const showApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway" || llmProvider === "openai-compatible" + const requiresApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway" + const requiresBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" + const showBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" || llmProvider === "aigateway" + const isLocalProvider = llmProvider === "ollama" || llmProvider === "openai-compatible" + const canTest = + activeConfig.model.trim().length > 0 && + (!requiresApiKey || activeConfig.apiKey.trim().length > 0) && + (!requiresBaseURL || activeConfig.baseURL.trim().length > 0) + + // Track connected providers for the completion step + const connectedProviders = Object.entries(providerStates) + .filter(([, state]) => state.isConnected) + .map(([provider]) => provider) + + // Load available providers and composio-for-google flag on mount + useEffect(() => { + if (!open) return + + 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) + } + } + async function loadComposioForGoogleFlag() { + try { + const result = await window.ipc.invoke('composio:use-composio-for-google', null) + setUseComposioForGoogle(result.enabled) + } catch (error) { + console.error('Failed to check composio-for-google flag:', error) + } + } + async function loadComposioForGoogleCalendarFlag() { + try { + const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) + setUseComposioForGoogleCalendar(result.enabled) + } catch (error) { + console.error('Failed to check composio-for-google-calendar flag:', error) + } + } + loadProviders() + loadComposioForGoogleFlag() + loadComposioForGoogleCalendarFlag() + }, [open]) + + // Load LLM models catalog on open + useEffect(() => { + if (!open) return + + async function loadModels() { + try { + setModelsLoading(true) + setModelsError(null) + const result = await window.ipc.invoke("models:list", null) + const catalog: Record = {} + for (const provider of result.providers || []) { + catalog[provider.id] = provider.models || [] + } + setModelsCatalog(catalog) + } catch (error) { + console.error("Failed to load models catalog:", error) + setModelsError("Failed to load models list") + setModelsCatalog({}) + } finally { + setModelsLoading(false) + } + } + + loadModels() + }, [open]) + + // Preferred default models for each provider + const preferredDefaults: Partial> = { + openai: "gpt-5.2", + anthropic: "claude-opus-4-6-20260202", + } + + // Initialize default models from catalog + useEffect(() => { + if (Object.keys(modelsCatalog).length === 0) return + setProviderConfigs(prev => { + const next = { ...prev } + const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"] + for (const provider of cloudProviders) { + const models = modelsCatalog[provider] + if (models?.length && !next[provider].model) { + const preferredModel = preferredDefaults[provider] + const hasPreferred = preferredModel && models.some(m => m.id === preferredModel) + next[provider] = { ...next[provider], model: hasPreferred ? preferredModel : (models[0]?.id || "") } + } + } + return next + }) + }, [modelsCatalog]) + + // 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.success(enabled ? 'Granola sync enabled' : 'Granola sync disabled') + } catch (error) { + console.error('Failed to update Granola config:', error) + toast.error('Failed to update Granola sync settings') + } finally { + setGranolaLoading(false) + } + }, []) + + // Load Slack config + const refreshSlackConfig = useCallback(async () => { + try { + setSlackLoading(true) + const result = await window.ipc.invoke('slack:getConfig', null) + setSlackEnabled(result.enabled) + setSlackWorkspaces(result.workspaces || []) + } catch (error) { + console.error('Failed to load Slack config:', error) + setSlackEnabled(false) + setSlackWorkspaces([]) + } finally { + setSlackLoading(false) + } + }, []) + + // Enable Slack: discover workspaces + const handleSlackEnable = useCallback(async () => { + setSlackDiscovering(true) + setSlackDiscoverError(null) + try { + const result = await window.ipc.invoke('slack:listWorkspaces', null) + if (result.error || result.workspaces.length === 0) { + setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop') + setSlackAvailableWorkspaces([]) + setSlackPickerOpen(true) + } else { + setSlackAvailableWorkspaces(result.workspaces) + setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url))) + setSlackPickerOpen(true) + } + } catch (error) { + console.error('Failed to discover Slack workspaces:', error) + setSlackDiscoverError('Failed to discover Slack workspaces') + setSlackPickerOpen(true) + } finally { + setSlackDiscovering(false) + } + }, []) + + // Save selected Slack workspaces + const handleSlackSaveWorkspaces = useCallback(async () => { + const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url)) + try { + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected }) + setSlackEnabled(true) + setSlackWorkspaces(selected) + setSlackPickerOpen(false) + toast.success('Slack enabled') + } catch (error) { + console.error('Failed to save Slack config:', error) + toast.error('Failed to save Slack settings') + } finally { + setSlackLoading(false) + } + }, [slackAvailableWorkspaces, slackSelectedUrls]) + + // Disable Slack + const handleSlackDisable = useCallback(async () => { + try { + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] }) + setSlackEnabled(false) + setSlackWorkspaces([]) + setSlackPickerOpen(false) + toast.success('Slack disabled') + } catch (error) { + console.error('Failed to update Slack config:', error) + toast.error('Failed to update Slack settings') + } finally { + setSlackLoading(false) + } + }, []) + + // Load Gmail connection status (Composio) + const refreshGmailStatus = useCallback(async () => { + try { + setGmailLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' }) + setGmailConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Gmail status:', error) + setGmailConnected(false) + } finally { + setGmailLoading(false) + } + }, []) + + // Connect to Gmail via Composio + const startGmailConnect = useCallback(async () => { + try { + setGmailConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Gmail') + setGmailConnecting(false) + } + } catch (error) { + console.error('Failed to connect to Gmail:', error) + toast.error('Failed to connect to Gmail') + setGmailConnecting(false) + } + }, []) + + // Handle Gmail connect button click (checks Composio config first) + const handleConnectGmail = useCallback(async () => { + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyTarget('gmail') + setComposioApiKeyOpen(true) + return + } + await startGmailConnect() + }, [startGmailConnect]) + + // 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') + await startGmailConnect() + } catch (error) { + console.error('Failed to save Composio API key:', error) + toast.error('Failed to save API key') + } + }, [startGmailConnect]) + + // Load Google Calendar connection status (Composio) + const refreshGoogleCalendarStatus = useCallback(async () => { + try { + setGoogleCalendarLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' }) + setGoogleCalendarConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Google Calendar status:', error) + setGoogleCalendarConnected(false) + } finally { + setGoogleCalendarLoading(false) + } + }, []) + + // Connect to Google Calendar via Composio + const startGoogleCalendarConnect = useCallback(async () => { + try { + setGoogleCalendarConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'googlecalendar' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Google Calendar') + setGoogleCalendarConnecting(false) + } + } catch (error) { + console.error('Failed to connect to Google Calendar:', error) + toast.error('Failed to connect to Google Calendar') + setGoogleCalendarConnecting(false) + } + }, []) + + // Handle Google Calendar connect button click + const handleConnectGoogleCalendar = useCallback(async () => { + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyTarget('gmail') + setComposioApiKeyOpen(true) + return + } + await startGoogleCalendarConnect() + }, [startGoogleCalendarConnect]) + + // New step flow: + // Rowboat path: 0 (welcome) → 2 (connect) → 3 (done) + // BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (done) + const handleNext = useCallback(() => { + if (currentStep === 0) { + if (onboardingPath === 'byok') { + setCurrentStep(1) + } else { + setCurrentStep(2) + } + } else if (currentStep === 1) { + setCurrentStep(2) + } else if (currentStep === 2) { + setCurrentStep(3) + } + }, [currentStep, onboardingPath]) + + const handleBack = useCallback(() => { + if (currentStep === 1) { + setCurrentStep(0) + setOnboardingPath(null) + } else if (currentStep === 2) { + if (onboardingPath === 'rowboat') { + setCurrentStep(0) + } else { + setCurrentStep(1) + } + } + }, [currentStep, onboardingPath]) + + const handleComplete = useCallback(() => { + onComplete() + }, [onComplete]) + + const handleTestAndSaveLlmConfig = useCallback(async () => { + if (!canTest) return + setTestState({ status: "testing" }) + try { + const apiKey = activeConfig.apiKey.trim() || undefined + const baseURL = activeConfig.baseURL.trim() || undefined + const model = activeConfig.model.trim() + const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined + const providerConfig = { + provider: { + flavor: llmProvider, + apiKey, + baseURL, + }, + model, + knowledgeGraphModel, + } + const result = await window.ipc.invoke("models:test", providerConfig) + if (result.success) { + setTestState({ status: "success" }) + await window.ipc.invoke("models:saveConfig", providerConfig) + window.dispatchEvent(new Event('models-config-changed')) + handleNext() + } else { + setTestState({ status: "error", error: result.error }) + toast.error(result.error || "Connection test failed") + } + } catch (error) { + console.error("Connection test failed:", error) + setTestState({ status: "error", error: "Connection test failed" }) + toast.error("Connection test failed") + } + }, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, canTest, llmProvider, handleNext]) + + // Check connection status for all providers + const refreshAllStatuses = useCallback(async () => { + refreshGranolaConfig() + refreshSlackConfig() + + // Refresh Gmail Composio status if enabled + if (useComposioForGoogle) { + refreshGmailStatus() + } + + // Refresh Google Calendar Composio status if enabled + if (useComposioForGoogleCalendar) { + refreshGoogleCalendarStatus() + } + + if (providers.length === 0) return + + const newStates: Record = {} + + 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, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar]) + + // Refresh statuses when modal opens or providers list changes + useEffect(() => { + if (open && providers.length > 0) { + refreshAllStatuses() + } + }, [open, providers, refreshAllStatuses]) + + // Listen for OAuth completion events (state updates only — toasts handled by ConnectorsPopover) + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + const { provider, success } = event + + setProviderStates(prev => ({ + ...prev, + [provider]: { + isConnected: success, + isLoading: false, + isConnecting: false, + } + })) + }) + + return cleanup + }, []) + + // Auto-advance from Rowboat sign-in step when OAuth completes + useEffect(() => { + if (onboardingPath !== 'rowboat' || currentStep !== 0) return + + const cleanup = window.ipc.on('oauth:didConnect', async (event) => { + if (event.provider === 'rowboat' && event.success) { + // Re-check composio flags now that the account is connected + try { + const [googleResult, calendarResult] = await Promise.all([ + window.ipc.invoke('composio:use-composio-for-google', null), + window.ipc.invoke('composio:use-composio-for-google-calendar', null), + ]) + setUseComposioForGoogle(googleResult.enabled) + setUseComposioForGoogleCalendar(calendarResult.enabled) + } catch (error) { + console.error('Failed to re-check composio flags:', error) + } + setCurrentStep(2) // Go to Connect Accounts + } + }) + + return cleanup + }, [onboardingPath, currentStep]) + + // Listen for Composio connection events (state updates only — toasts handled by ConnectorsPopover) + useEffect(() => { + const cleanup = window.ipc.on('composio:didConnect', (event) => { + const { toolkitSlug, success } = event + + if (toolkitSlug === 'slack') { + setSlackEnabled(success) + } + + if (toolkitSlug === 'gmail') { + setGmailConnected(success) + setGmailConnecting(false) + } + + if (toolkitSlug === 'googlecalendar') { + setGoogleCalendarConnected(success) + setGoogleCalendarConnecting(false) + } + }) + + return cleanup + }, []) + + const startConnect = useCallback(async (provider: string, clientId?: string) => { + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isConnecting: true } + })) + + try { + const result = await window.ipc.invoke('oauth:connect', { provider, clientId }) + + if (!result.success) { + toast.error(result.error || `Failed to connect to ${provider}`) + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isConnecting: false } + })) + } + } catch (error) { + console.error('Failed to connect:', error) + toast.error(`Failed to connect to ${provider}`) + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isConnecting: false } + })) + } + }, []) + + // Connect to a provider + const handleConnect = useCallback(async (provider: string) => { + if (provider === 'google') { + const existingClientId = getGoogleClientId() + if (!existingClientId) { + setGoogleClientIdOpen(true) + return + } + await startConnect(provider, existingClientId) + return + } + + await startConnect(provider) + }, [startConnect]) + + const handleGoogleClientIdSubmit = useCallback((clientId: string) => { + setGoogleClientId(clientId) + setGoogleClientIdOpen(false) + startConnect('google', clientId) + }, [startConnect]) + + // Switch to rowboat path from BYOK inline callout + const handleSwitchToRowboat = useCallback(() => { + setOnboardingPath('rowboat') + setCurrentStep(0) + }, []) + + return { + // Step state + currentStep, + setCurrentStep, + onboardingPath, + setOnboardingPath, + + // LLM state + llmProvider, + setLlmProvider, + modelsCatalog, + modelsLoading, + modelsError, + providerConfigs, + activeConfig, + testState, + setTestState, + showApiKey, + requiresApiKey, + requiresBaseURL, + showBaseURL, + isLocalProvider, + canTest, + showMoreProviders, + setShowMoreProviders, + updateProviderConfig, + handleTestAndSaveLlmConfig, + + // OAuth state + providers, + providersLoading, + providerStates, + googleClientIdOpen, + setGoogleClientIdOpen, + connectedProviders, + handleConnect, + handleGoogleClientIdSubmit, + startConnect, + + // Granola state + granolaEnabled, + granolaLoading, + handleGranolaToggle, + + // Slack state + slackEnabled, + slackLoading, + slackWorkspaces, + slackAvailableWorkspaces, + slackSelectedUrls, + setSlackSelectedUrls, + slackPickerOpen, + slackDiscovering, + slackDiscoverError, + handleSlackEnable, + handleSlackSaveWorkspaces, + handleSlackDisable, + + // Upsell + upsellDismissed, + setUpsellDismissed, + + // Composio/Gmail state + useComposioForGoogle, + gmailConnected, + gmailLoading, + gmailConnecting, + composioApiKeyOpen, + setComposioApiKeyOpen, + composioApiKeyTarget, + handleConnectGmail, + handleComposioApiKeySubmit, + + // Composio/Google Calendar state + useComposioForGoogleCalendar, + googleCalendarConnected, + googleCalendarLoading, + googleCalendarConnecting, + handleConnectGoogleCalendar, + + // Navigation + handleNext, + handleBack, + handleComplete, + handleSwitchToRowboat, + } +} + +export type OnboardingState = ReturnType diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 6d6dfbfc..65c5b424 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -693,6 +693,126 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { ) } +// --- Rowboat Model Settings (when signed in via Rowboat) --- + +function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) { + const [gatewayModels, setGatewayModels] = useState([]) + const [selectedModel, setSelectedModel] = useState("") + const [selectedKgModel, setSelectedKgModel] = useState("") + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (!dialogOpen) return + + async function load() { + setLoading(true) + try { + // Fetch gateway models + const listResult = await window.ipc.invoke("models:list", null) + const rowboatProvider = listResult.providers?.find((p: { id: string }) => p.id === "rowboat") + const models = rowboatProvider?.models || [] + setGatewayModels(models) + + // Read current selection from config + try { + const configResult = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" }) + const parsed = JSON.parse(configResult.data) + if (parsed?.model) setSelectedModel(parsed.model) + if (parsed?.knowledgeGraphModel) setSelectedKgModel(parsed.knowledgeGraphModel) + } catch { + // No config yet — pick first model as default + if (models.length > 0) setSelectedModel(models[0].id) + } + } catch { + toast.error("Failed to load models") + } finally { + setLoading(false) + } + } + + load() + }, [dialogOpen]) + + const handleSave = useCallback(async () => { + if (!selectedModel) return + setSaving(true) + try { + await window.ipc.invoke("models:saveConfig", { + provider: { flavor: "openrouter" as const }, + model: selectedModel, + knowledgeGraphModel: selectedKgModel || undefined, + }) + window.dispatchEvent(new Event("models-config-changed")) + toast.success("Model configuration saved") + } catch { + toast.error("Failed to save model configuration") + } finally { + setSaving(false) + } + }, [selectedModel, selectedKgModel]) + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+

+ Select the models Rowboat uses. These are provided through your Rowboat account. +

+ + {/* Assistant model */} +
+ + +
+ + {/* Knowledge graph model */} +
+ + +
+ + {/* Save */} + +
+ ) +} + // --- Note Tagging Settings --- interface TagDef { @@ -1112,8 +1232,22 @@ export function SettingsDialog({ children }: SettingsDialogProps) { const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) + const [rowboatConnected, setRowboatConnected] = useState(false) - const activeTabConfig = tabs.find((t) => t.id === activeTab)! + // Check if user is signed in to Rowboat + useEffect(() => { + if (!open) return + window.ipc.invoke('oauth:getState', null).then((result) => { + const connected = result.config?.rowboat?.connected ?? false + setRowboatConnected(connected) + }).catch(() => { + setRowboatConnected(false) + }) + }, [open]) + + const visibleTabs = useMemo(() => tabs, []) + + const activeTabConfig = visibleTabs.find((t) => t.id === activeTab) ?? visibleTabs[0] const isJsonTab = activeTab === "mcp" || activeTab === "security" const formatJson = (jsonString: string): string => { @@ -1202,7 +1336,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {

Settings

+
+ )} {/* Bottom actions */}
diff --git a/apps/x/apps/renderer/src/hooks/useBilling.ts b/apps/x/apps/renderer/src/hooks/useBilling.ts new file mode 100644 index 00000000..49459fdb --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useBilling.ts @@ -0,0 +1,36 @@ +import { useState, useEffect, useCallback } from 'react' + +interface BillingInfo { + subscriptionPlan: string + subscriptionStatus: string + sanctionedCredits: number + availableCredits: number +} + +export function useBilling(isRowboatConnected: boolean) { + const [billing, setBilling] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchBilling = useCallback(async () => { + if (!isRowboatConnected) { + setBilling(null) + return + } + try { + setIsLoading(true) + const result = await window.ipc.invoke('billing:getInfo', null) + setBilling(result) + } catch (error) { + console.error('Failed to fetch billing info:', error) + setBilling(null) + } finally { + setIsLoading(false) + } + }, [isRowboatConnected]) + + useEffect(() => { + fetchBilling() + }, [fetchBilling]) + + return { billing, isLoading, refresh: fetchBilling } +} diff --git a/apps/x/packages/core/src/billing/billing.ts b/apps/x/packages/core/src/billing/billing.ts new file mode 100644 index 00000000..69cf6083 --- /dev/null +++ b/apps/x/packages/core/src/billing/billing.ts @@ -0,0 +1,39 @@ +import { getAccessToken } from '../auth/tokens.js'; +import { API_URL } from '../config/env.js'; + +export interface BillingInfo { + subscriptionPlan: string | null; + subscriptionStatus: string | null; + sanctionedCredits: number; + availableCredits: number; +} + +export async function getBillingInfo(): Promise { + const accessToken = await getAccessToken(); + const response = await fetch(`${API_URL}/v1/me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + throw new Error(`Billing API failed: ${response.status}`); + } + const body = await response.json() as { + user: { + id: string; + email: string; + }; + billing: { + plan: string | null; + status: string | null; + usage: { + sanctionedCredits: number; + availableCredits: number; + }; + }; + }; + return { + subscriptionPlan: body.billing.plan, + subscriptionStatus: body.billing.status, + sanctionedCredits: body.billing.usage.sanctionedCredits, + availableCredits: body.billing.usage.availableCredits, + }; +} diff --git a/apps/x/packages/core/src/composio/client.ts b/apps/x/packages/core/src/composio/client.ts index f1aeee7e..3602bef6 100644 --- a/apps/x/packages/core/src/composio/client.ts +++ b/apps/x/packages/core/src/composio/client.ts @@ -49,6 +49,7 @@ async function getAuthHeaders(): Promise> { const ZComposioConfig = z.object({ apiKey: z.string().optional(), use_composio_for_google: z.boolean().optional(), + use_composio_for_google_calendar: z.boolean().optional(), }); type ComposioConfig = z.infer; @@ -113,6 +114,15 @@ export async function useComposioForGoogle(): Promise { return config.use_composio_for_google === true; } +/** + * Check if Composio should be used for Google Calendar + */ +export async function useComposioForGoogleCalendar(): Promise { + if (await isSignedIn()) return true; + const config = loadConfig(); + return config.use_composio_for_google_calendar === true; +} + /** * Make an API call to Composio */ diff --git a/apps/x/packages/core/src/knowledge/sync_calendar.ts b/apps/x/packages/core/src/knowledge/sync_calendar.ts index cf2f6387..70b1ccce 100644 --- a/apps/x/packages/core/src/knowledge/sync_calendar.ts +++ b/apps/x/packages/core/src/knowledge/sync_calendar.ts @@ -5,13 +5,16 @@ import { OAuth2Client } from 'google-auth-library'; import { NodeHtmlMarkdown } from 'node-html-markdown' import { WorkDir } from '../config/config.js'; import { GoogleClientFactory } from './google-client-factory.js'; -import { serviceLogger } from '../services/service_logger.js'; +import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { limitEventItems } from './limit_event_items.js'; +import { executeAction, useComposioForGoogleCalendar } from '../composio/client.js'; +import { composioAccountsRepo } from '../composio/repo.js'; // Configuration const SYNC_DIR = path.join(WorkDir, 'calendar_sync'); const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const LOOKBACK_DAYS = 14; +const COMPOSIO_LOOKBACK_DAYS = 14; const REQUIRED_SCOPES = [ 'https://www.googleapis.com/auth/calendar.events.readonly', 'https://www.googleapis.com/auth/drive.readonly' @@ -56,7 +59,7 @@ function cleanUpOldFiles(currentEventIds: Set, syncDir: string): string[ const files = fs.readdirSync(syncDir); const deleted: string[] = []; for (const filename of files) { - if (filename === 'sync_state.json') continue; + if (filename === 'sync_state.json' || filename === 'composio_state.json') continue; // We expect files like: // {eventId}.json @@ -133,10 +136,10 @@ async function processAttachments(drive: drive.Drive, event: cal.Schema$Event, s const filename = `${eventId}_doc_${safeTitle}.md`; const filePath = path.join(syncDir, filename); - // Simple cache check: if file exists, skip. + // Simple cache check: if file exists, skip. // Ideally we check modifiedTime, but that requires an extra API call per file. // Given the loop interval, we can just check existence to save quota. - // If user updates notes, they might want them re-synced. + // If user updates notes, they might want them re-synced. // For now, let's just check existence. To be smarter, we'd need a state file or check API. if (fs.existsSync(filePath)) continue; @@ -343,20 +346,248 @@ async function performSync(syncDir: string, lookbackDays: number) { } } +// --- Composio-based Sync --- + +interface ComposioCalendarState { + last_sync: string; // ISO string +} + +function loadComposioState(stateFile: string): ComposioCalendarState | null { + if (fs.existsSync(stateFile)) { + try { + const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + if (data.last_sync) { + return { last_sync: data.last_sync }; + } + } catch (e) { + console.error('[Calendar] Failed to load composio state:', e); + } + } + return null; +} + +function saveComposioState(stateFile: string, lastSync: string): void { + fs.writeFileSync(stateFile, JSON.stringify({ last_sync: lastSync }, null, 2)); +} + +/** + * Save a Composio calendar event as JSON (same format used by Google OAuth path). + * The event data from Composio is already structured similarly to Google Calendar API. + */ +function saveComposioEvent(eventData: Record, syncDir: string): { changed: boolean; isNew: boolean; title: string } { + const eventId = eventData.id as string | undefined; + if (!eventId) return { changed: false, isNew: false, title: 'Unknown' }; + + const filePath = path.join(syncDir, `${eventId}.json`); + const content = JSON.stringify(eventData, null, 2); + const exists = fs.existsSync(filePath); + + try { + if (exists) { + const existing = fs.readFileSync(filePath, 'utf-8'); + if (existing === content) { + return { changed: false, isNew: false, title: (eventData.summary as string) || eventId }; + } + } + + fs.writeFileSync(filePath, content); + return { changed: true, isNew: !exists, title: (eventData.summary as string) || eventId }; + } catch (e) { + console.error(`[Calendar] Error saving event ${eventId}:`, e); + return { changed: false, isNew: false, title: (eventData.summary as string) || eventId }; + } +} + +async function performSyncComposio() { + const STATE_FILE = path.join(SYNC_DIR, 'composio_state.json'); + + if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true }); + + const account = composioAccountsRepo.getAccount('googlecalendar'); + if (!account || account.status !== 'ACTIVE') { + console.log('[Calendar] Google Calendar not connected via Composio. Skipping sync.'); + return; + } + + const connectedAccountId = account.id; + + // Calculate time window: lookback + 14 days forward + const now = new Date(); + const lookbackMs = COMPOSIO_LOOKBACK_DAYS * 24 * 60 * 60 * 1000; + const twoWeeksForwardMs = 14 * 24 * 60 * 60 * 1000; + + const timeMin = new Date(now.getTime() - lookbackMs).toISOString(); + const timeMax = new Date(now.getTime() + twoWeeksForwardMs).toISOString(); + + console.log(`[Calendar] Syncing via Composio from ${timeMin} to ${timeMax} (lookback: ${COMPOSIO_LOOKBACK_DAYS} days)...`); + + let run: ServiceRunContext | null = null; + const ensureRun = async (): Promise => { + if (!run) { + run = await serviceLogger.startRun({ + service: 'calendar', + message: 'Syncing calendar (Composio)', + trigger: 'timer', + }); + } + return run; + }; + + try { + const result = await executeAction( + 'GOOGLECALENDAR_FIND_EVENT', + { + connected_account_id: connectedAccountId, + user_id: 'rowboat-user', + version: 'latest', + arguments: { + calendar_id: 'primary', + time_min: timeMin, + time_max: timeMax, + single_events: true, + order_by: 'startTime', + }, + } + ); + + if (!result.successful || !result.data) { + console.error('[Calendar] Failed to list events via Composio:', result.error); + return; + } + + const data = result.data as Record; + // Composio may return events in different structures + let events: Array> = []; + + if (Array.isArray(data.items)) { + events = data.items as Array>; + } else if (Array.isArray(data.events)) { + events = data.events as Array>; + } else if (Array.isArray(data)) { + events = data as unknown as Array>; + } + + const currentEventIds = new Set(); + let newCount = 0; + let updatedCount = 0; + const changedTitles: string[] = []; + + if (events.length === 0) { + console.log('[Calendar] No events found in this window.'); + } else { + console.log(`[Calendar] Found ${events.length} events.`); + for (const event of events) { + const eventId = event.id as string | undefined; + if (eventId) { + const saveResult = saveComposioEvent(event, SYNC_DIR); + currentEventIds.add(eventId); + + if (saveResult.changed) { + await ensureRun(); + changedTitles.push(saveResult.title); + if (saveResult.isNew) { + newCount++; + } else { + updatedCount++; + } + } + } + } + } + + // Clean up events no longer in the window + const deletedFiles = cleanUpOldFiles(currentEventIds, SYNC_DIR); + let deletedCount = 0; + if (deletedFiles.length > 0) { + await ensureRun(); + deletedCount = deletedFiles.length; + } + + // Log results if any changes were detected (run was started by ensureRun) + if (run) { + const r = run as ServiceRunContext; + const totalChanges = newCount + updatedCount + deletedCount; + const limitedTitles = limitEventItems(changedTitles); + await serviceLogger.log({ + type: 'changes_identified', + service: r.service, + runId: r.runId, + level: 'info', + message: `Calendar updates: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`, + counts: { + newEvents: newCount, + updatedEvents: updatedCount, + deletedFiles: deletedCount, + }, + items: limitedTitles.items, + truncated: limitedTitles.truncated, + }); + await serviceLogger.log({ + type: 'run_complete', + service: r.service, + runId: r.runId, + level: 'info', + message: `Calendar sync complete: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`, + durationMs: Date.now() - r.startedAt, + outcome: 'ok', + summary: { + newEvents: newCount, + updatedEvents: updatedCount, + deletedFiles: deletedCount, + }, + }); + } + + // Save state + saveComposioState(STATE_FILE, new Date().toISOString()); + console.log(`[Calendar] Composio sync completed. ${newCount} new, ${updatedCount} updated, ${deletedCount} deleted.`); + } catch (error) { + console.error('[Calendar] Error during Composio sync:', error); + const errRun = await ensureRun(); + await serviceLogger.log({ + type: 'error', + service: errRun.service, + runId: errRun.runId, + level: 'error', + message: 'Calendar sync error', + error: error instanceof Error ? error.message : String(error), + }); + await serviceLogger.log({ + type: 'run_complete', + service: errRun.service, + runId: errRun.runId, + level: 'error', + message: 'Calendar sync failed', + durationMs: Date.now() - errRun.startedAt, + outcome: 'error', + }); + } +} + export async function init() { console.log("Starting Google Calendar & Notes Sync (TS)..."); console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); while (true) { try { - // Check if credentials are available with required scopes - const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES); - - if (!hasCredentials) { - console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping..."); + const composioMode = await useComposioForGoogleCalendar(); + if (composioMode) { + const isConnected = composioAccountsRepo.isConnected('googlecalendar'); + if (!isConnected) { + console.log('[Calendar] Google Calendar not connected via Composio. Sleeping...'); + } else { + await performSyncComposio(); + } } else { - // Perform one sync - await performSync(SYNC_DIR, LOOKBACK_DAYS); + // Check if credentials are available with required scopes + const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES); + + if (!hasCredentials) { + console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping..."); + } else { + // Perform one sync + await performSync(SYNC_DIR, LOOKBACK_DAYS); + } } } catch (error) { console.error("Error in main loop:", error); diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index c74de77a..9dffdc6d 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -786,13 +786,9 @@ export async function init() { console.log("Starting Gmail Sync (TS)..."); console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); - const composioMode = await useComposioForGoogle(); - if (composioMode) { - console.log('[Gmail] Using Composio backend for Gmail sync.'); - } - while (true) { try { + const composioMode = await useComposioForGoogle(); if (composioMode) { const isConnected = composioAccountsRepo.isConnected('gmail'); if (!isConnected) { diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 557df845..b6abbf29 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -386,6 +386,12 @@ const ipcSchemas = { enabled: z.boolean(), }), }, + 'composio:use-composio-for-google-calendar': { + req: z.null(), + res: z.object({ + enabled: z.boolean(), + }), + }, 'composio:didConnect': { req: z.object({ toolkitSlug: z.string(), @@ -516,6 +522,16 @@ const ipcSchemas = { ]).nullable(), }), }, + // Billing channels + 'billing:getInfo': { + req: z.null(), + res: z.object({ + subscriptionPlan: z.string().nullable(), + subscriptionStatus: z.string().nullable(), + sanctionedCredits: z.number(), + availableCredits: z.number(), + }), + }, } as const; // ============================================================================ From b066aa2b246a6963b1d4ad7695cbb411dfb861a5 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Wed, 18 Mar 2026 10:27:02 +0530 Subject: [PATCH 10/61] Update COMPOSIO_LOOKBACK_DAYS to 30 in sync_gmail.ts for extended sync period --- apps/x/packages/core/src/knowledge/sync_gmail.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 9dffdc6d..3f7c3d16 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -444,7 +444,7 @@ async function performSync() { // --- Composio-based Sync --- -const COMPOSIO_LOOKBACK_DAYS = 7; +const COMPOSIO_LOOKBACK_DAYS = 30; interface ComposioSyncState { last_sync: string; // ISO string From 128f433e5cb602cb519eaa9ed8171ff3c92e75d9 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:29:21 +0530 Subject: [PATCH 11/61] fixed type errors --- apps/x/apps/renderer/src/App.tsx | 4 ++-- apps/x/apps/renderer/src/components/bases-view.tsx | 3 --- .../apps/renderer/src/components/chat-input-with-mentions.tsx | 4 ++-- apps/x/apps/renderer/src/components/connectors-popover.tsx | 2 +- .../x/apps/renderer/src/components/frontmatter-properties.tsx | 2 +- apps/x/apps/renderer/src/components/markdown-editor.tsx | 2 +- apps/x/apps/renderer/src/components/onboarding-modal.tsx | 2 +- .../src/components/onboarding/steps/llm-setup-step.tsx | 2 +- apps/x/apps/renderer/src/components/settings-dialog.tsx | 2 +- apps/x/apps/renderer/src/extensions/task-block.tsx | 4 ++-- apps/x/apps/renderer/src/hooks/useBilling.ts | 4 ++-- 11 files changed, 14 insertions(+), 17 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index ae8d60c9..a92f2d28 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -648,7 +648,7 @@ function App() { voice.start() }, [voice]) - const handlePromptSubmitRef = useRef<((msg: { text: string }) => void) | null>(null) + const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean) => Promise) | null>(null) const pendingVoiceInputRef = useRef(false) const handleSubmitRecording = useCallback(() => { @@ -657,7 +657,7 @@ function App() { isRecordingRef.current = false if (text) { pendingVoiceInputRef.current = true - handlePromptSubmitRef.current?.({ text }) + handlePromptSubmitRef.current?.({ text, files: [] }) } }, [voice]) diff --git a/apps/x/apps/renderer/src/components/bases-view.tsx b/apps/x/apps/renderer/src/components/bases-view.tsx index 7462f5b5..cad6ccbf 100644 --- a/apps/x/apps/renderer/src/components/bases-view.tsx +++ b/apps/x/apps/renderer/src/components/bases-view.tsx @@ -143,9 +143,6 @@ function getSortValue(note: NoteEntry, column: string): string | number { return Array.isArray(v) ? v[0] ?? '' : v } -const isBuiltin = (col: string): col is BuiltinColumn => - (BUILTIN_COLUMNS as readonly string[]).includes(col) - export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave, externalSearch, onExternalSearchConsumed }: BasesViewProps) { // Build notes instantly from tree const notes = useMemo(() => { diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 74a0b2ed..afc29b34 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -70,7 +70,7 @@ const providerDisplayNames: Record = { } interface ConfiguredModel { - flavor: string + flavor: "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" model: string apiKey?: string baseURL?: string @@ -224,7 +224,7 @@ function ChatInputInner({ for (const model of allModels) { if (model) { models.push({ - flavor, + flavor: flavor as ConfiguredModel['flavor'], model, apiKey: (e.apiKey as string) || undefined, baseURL: (e.baseURL as string) || undefined, diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index 254a4331..0671699a 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -57,7 +57,7 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha // Composio API key state const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) - const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') + const [, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') // Slack state (agent-slack CLI) const [slackEnabled, setSlackEnabled] = useState(false) diff --git a/apps/x/apps/renderer/src/components/frontmatter-properties.tsx b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx index 280d45f1..0ceb2c76 100644 --- a/apps/x/apps/renderer/src/components/frontmatter-properties.tsx +++ b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx @@ -59,7 +59,7 @@ export function FrontmatterProperties({ raw, onRawChange, editable = true }: Fro }) }, []) - const commitField = useCallback((index: number) => { + const commitField = useCallback((_index: number) => { setFields(prev => { commit(prev) return prev diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 776b5a42..9f4a2d2a 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -555,7 +555,7 @@ export function MarkdownEditor({ return false }, - handleClickOn: (_view, _pos, node, nodePos, event) => { + handleClickOn: (_view, _pos, node, _nodePos, event) => { if (node.type.name === 'wikiLink') { event.preventDefault() wikiLinks?.onOpen?.(node.attrs.path) diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index d687d81a..82064205 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -84,7 +84,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Composio API key state const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) - const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') + const [, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') // Slack state (agent-slack CLI) const [slackEnabled, setSlackEnabled] = useState(false) diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx index 31a7308a..534a67a8 100644 --- a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx @@ -41,7 +41,7 @@ const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: s export function LlmSetupStep({ state }: LlmSetupStepProps) { const { llmProvider, setLlmProvider, modelsCatalog, modelsLoading, modelsError, - activeConfig, testState, setTestState, showApiKey, requiresApiKey, + activeConfig, testState, setTestState, showApiKey, showBaseURL, isLocalProvider, canTest, showMoreProviders, setShowMoreProviders, updateProviderConfig, handleTestAndSaveLlmConfig, handleBack, upsellDismissed, setUpsellDismissed, handleSwitchToRowboat, diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 65c5b424..d987ee18 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -907,7 +907,7 @@ const DEFAULT_TAGS: TagDef[] = [ function TagGroupTable({ group, - tags, + tags: _tags, collapsed, onToggle, onAdd, diff --git a/apps/x/apps/renderer/src/extensions/task-block.tsx b/apps/x/apps/renderer/src/extensions/task-block.tsx index 9c93231d..b1839745 100644 --- a/apps/x/apps/renderer/src/extensions/task-block.tsx +++ b/apps/x/apps/renderer/src/extensions/task-block.tsx @@ -3,8 +3,8 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' import { CalendarClock, X } from 'lucide-react' import { inlineTask } from '@x/shared' -function TaskBlockView({ node, deleteNode }: { node: { attrs: { data: string } }; deleteNode: () => void }) { - const raw = node.attrs.data +function TaskBlockView({ node, deleteNode }: { node: { attrs: Record }; deleteNode: () => void }) { + const raw = node.attrs.data as string let instruction = '' let scheduleLabel = '' diff --git a/apps/x/apps/renderer/src/hooks/useBilling.ts b/apps/x/apps/renderer/src/hooks/useBilling.ts index 49459fdb..6ab5f04a 100644 --- a/apps/x/apps/renderer/src/hooks/useBilling.ts +++ b/apps/x/apps/renderer/src/hooks/useBilling.ts @@ -1,8 +1,8 @@ import { useState, useEffect, useCallback } from 'react' interface BillingInfo { - subscriptionPlan: string - subscriptionStatus: string + subscriptionPlan: string | null + subscriptionStatus: string | null sanctionedCredits: number availableCredits: number } From 47ecc3198852de5a208673bb5b4c3046c7e36220 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Wed, 18 Mar 2026 16:15:02 +0530 Subject: [PATCH 12/61] Refactor ConnectorsPopover and SettingsDialog components to enhance account management features. Introduce AccountSettings and ConnectedAccountsSettings components for improved user experience in managing Rowboat account connections. Update billing information structure to include user email and ID. Implement dynamic tab visibility based on connection status in SettingsDialog. --- .../src/components/connectors-popover.tsx | 1036 +++++------------ .../src/components/settings-dialog.tsx | 28 +- .../components/settings/account-settings.tsx | 211 ++++ .../settings/connected-accounts-settings.tsx | 364 ++++++ .../src/components/sidebar-content.tsx | 36 +- apps/x/apps/renderer/src/hooks/useBilling.ts | 2 + .../apps/renderer/src/hooks/useConnectors.ts | 618 ++++++++++ apps/x/packages/core/src/billing/billing.ts | 4 + apps/x/packages/shared/src/ipc.ts | 2 + 9 files changed, 1522 insertions(+), 779 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/settings/account-settings.tsx create mode 100644 apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx create mode 100644 apps/x/apps/renderer/src/hooks/useConnectors.ts diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index 254a4331..e28f662e 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { useState, useEffect, useCallback } from "react" +import { useState } from "react" import { AlertTriangle, Loader2, Mic, Mail, Calendar, MessageSquare, User } from "lucide-react" import { @@ -18,604 +18,40 @@ import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { Separator } from "@/components/ui/separator" import { GoogleClientIdModal } from "@/components/google-client-id-modal" -import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store" -import { toast } from "sonner" import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" - -interface ProviderState { - isConnected: boolean - isLoading: boolean - isConnecting: boolean -} - -interface ProviderStatus { - error?: string -} +import { useConnectors } from "@/hooks/useConnectors" interface ConnectorsPopoverProps { children: React.ReactNode tooltip?: string open?: boolean onOpenChange?: (open: boolean) => void + mode?: "all" | "unconnected" } -export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenChange }: ConnectorsPopoverProps) { +export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenChange, mode = "all" }: ConnectorsPopoverProps) { const [openInternal, setOpenInternal] = useState(false) const isControlled = typeof openProp === "boolean" const open = isControlled ? openProp : openInternal const setOpen = onOpenChange ?? setOpenInternal - const [providers, setProviders] = useState([]) - const [providersLoading, setProvidersLoading] = useState(true) - const [providerStates, setProviderStates] = useState>({}) - const [providerStatus, setProviderStatus] = useState>({}) - const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false) - const [googleClientIdDescription, setGoogleClientIdDescription] = useState(undefined) - // Granola state - const [granolaEnabled, setGranolaEnabled] = useState(false) - const [granolaLoading, setGranolaLoading] = useState(true) + const c = useConnectors(open) - // Composio API key state - const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) - const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') - - // Slack state (agent-slack CLI) - const [slackEnabled, setSlackEnabled] = useState(false) - const [slackLoading, setSlackLoading] = useState(true) - const [slackWorkspaces, setSlackWorkspaces] = useState>([]) - const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState>([]) - const [slackSelectedUrls, setSlackSelectedUrls] = useState>(new Set()) - const [slackPickerOpen, setSlackPickerOpen] = useState(false) - const [slackDiscovering, setSlackDiscovering] = useState(false) - const [slackDiscoverError, setSlackDiscoverError] = useState(null) - - // Composio/Gmail state - const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) - const [gmailConnected, setGmailConnected] = useState(false) - const [gmailLoading, setGmailLoading] = useState(true) - const [gmailConnecting, setGmailConnecting] = useState(false) - - // Composio/Google Calendar state - const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) - const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) - const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) - const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) - - // 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() - }, []) - - // Re-check composio-for-google flag every time the popover opens - useEffect(() => { - if (!open) return - async function loadComposioForGoogleFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google', null) - setUseComposioForGoogle(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google flag:', error) - } - } - async function loadComposioForGoogleCalendarFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) - setUseComposioForGoogleCalendar(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google-calendar flag:', error) - } - } - loadComposioForGoogleFlag() - loadComposioForGoogleCalendarFlag() - }, [open]) - - // 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.success(enabled ? 'Granola sync enabled' : 'Granola sync disabled') - } catch (error) { - console.error('Failed to update Granola config:', error) - toast.error('Failed to update Granola sync settings') - } finally { - setGranolaLoading(false) - } - }, []) - - // Load Slack config - const refreshSlackConfig = useCallback(async () => { - try { - setSlackLoading(true) - const result = await window.ipc.invoke('slack:getConfig', null) - setSlackEnabled(result.enabled) - setSlackWorkspaces(result.workspaces || []) - } catch (error) { - console.error('Failed to load Slack config:', error) - setSlackEnabled(false) - setSlackWorkspaces([]) - } finally { - setSlackLoading(false) - } - }, []) - - // Enable Slack: discover workspaces - const handleSlackEnable = useCallback(async () => { - setSlackDiscovering(true) - setSlackDiscoverError(null) - try { - const result = await window.ipc.invoke('slack:listWorkspaces', null) - if (result.error || result.workspaces.length === 0) { - setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop') - setSlackAvailableWorkspaces([]) - setSlackPickerOpen(true) - } else { - setSlackAvailableWorkspaces(result.workspaces) - setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url))) - setSlackPickerOpen(true) - } - } catch (error) { - console.error('Failed to discover Slack workspaces:', error) - setSlackDiscoverError('Failed to discover Slack workspaces') - setSlackPickerOpen(true) - } finally { - setSlackDiscovering(false) - } - }, []) - - // Load Gmail connection status - const refreshGmailStatus = useCallback(async () => { - try { - setGmailLoading(true) - const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' }) - setGmailConnected(result.isConnected) - } catch (error) { - console.error('Failed to load Gmail status:', error) - setGmailConnected(false) - } finally { - setGmailLoading(false) - } - }, []) - - // Load Google Calendar connection status - const refreshGoogleCalendarStatus = useCallback(async () => { - try { - setGoogleCalendarLoading(true) - const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' }) - setGoogleCalendarConnected(result.isConnected) - } catch (error) { - console.error('Failed to load Google Calendar status:', error) - setGoogleCalendarConnected(false) - } finally { - setGoogleCalendarLoading(false) - } - }, []) - - // Connect to Gmail via Composio - const startGmailConnect = useCallback(async () => { - try { - setGmailConnecting(true) - const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' }) - if (!result.success) { - toast.error(result.error || 'Failed to connect to Gmail') - setGmailConnecting(false) - } - // Success will be handled by composio:didConnect event - } catch (error) { - console.error('Failed to connect to Gmail:', error) - toast.error('Failed to connect to Gmail') - setGmailConnecting(false) - } - }, []) - - // Handle Gmail connect button click - const handleConnectGmail = useCallback(async () => { - const configResult = await window.ipc.invoke('composio:is-configured', null) - if (!configResult.configured) { - setComposioApiKeyTarget('gmail') - setComposioApiKeyOpen(true) - return - } - await startGmailConnect() - }, [startGmailConnect]) - - // Connect to Google Calendar via Composio - const startGoogleCalendarConnect = useCallback(async () => { - try { - setGoogleCalendarConnecting(true) - const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'googlecalendar' }) - if (!result.success) { - toast.error(result.error || 'Failed to connect to Google Calendar') - setGoogleCalendarConnecting(false) - } - } catch (error) { - console.error('Failed to connect to Google Calendar:', error) - toast.error('Failed to connect to Google Calendar') - setGoogleCalendarConnecting(false) - } - }, []) - - // Handle Google Calendar connect button click - const handleConnectGoogleCalendar = useCallback(async () => { - const configResult = await window.ipc.invoke('composio:is-configured', null) - if (!configResult.configured) { - setComposioApiKeyTarget('gmail') - setComposioApiKeyOpen(true) - return - } - await startGoogleCalendarConnect() - }, [startGoogleCalendarConnect]) - - // Disconnect from Google Calendar - const handleDisconnectGoogleCalendar = useCallback(async () => { - try { - setGoogleCalendarLoading(true) - const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'googlecalendar' }) - if (result.success) { - setGoogleCalendarConnected(false) - toast.success('Disconnected from Google Calendar') - } else { - toast.error('Failed to disconnect from Google Calendar') - } - } catch (error) { - console.error('Failed to disconnect from Google Calendar:', error) - toast.error('Failed to disconnect from Google Calendar') - } finally { - setGoogleCalendarLoading(false) - } - }, []) - - // Disconnect from Gmail - const handleDisconnectGmail = useCallback(async () => { - try { - setGmailLoading(true) - const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'gmail' }) - if (result.success) { - setGmailConnected(false) - toast.success('Disconnected from Gmail') - } else { - toast.error('Failed to disconnect from Gmail') - } - } catch (error) { - console.error('Failed to disconnect from Gmail:', error) - toast.error('Failed to disconnect from Gmail') - } finally { - setGmailLoading(false) - } - }, []) - - // 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') - await startGmailConnect() - } catch (error) { - console.error('Failed to save Composio API key:', error) - toast.error('Failed to save API key') - } - }, [startGmailConnect]) - - // Save selected Slack workspaces - const handleSlackSaveWorkspaces = useCallback(async () => { - const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url)) - try { - setSlackLoading(true) - await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected }) - setSlackEnabled(true) - setSlackWorkspaces(selected) - setSlackPickerOpen(false) - toast.success('Slack enabled') - } catch (error) { - console.error('Failed to save Slack config:', error) - toast.error('Failed to save Slack settings') - } finally { - setSlackLoading(false) - } - }, [slackAvailableWorkspaces, slackSelectedUrls]) - - // Disable Slack - const handleSlackDisable = useCallback(async () => { - try { - setSlackLoading(true) - await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] }) - setSlackEnabled(false) - setSlackWorkspaces([]) - setSlackPickerOpen(false) - toast.success('Slack disabled') - } catch (error) { - console.error('Failed to update Slack config:', error) - toast.error('Failed to update Slack settings') - } finally { - setSlackLoading(false) - } - }, []) - - // Check connection status for all providers - const refreshAllStatuses = useCallback(async () => { - // Refresh Granola - refreshGranolaConfig() - - // Refresh Slack config - refreshSlackConfig() - - // Refresh Gmail Composio status if enabled - if (useComposioForGoogle) { - refreshGmailStatus() - } - - // Refresh Google Calendar Composio status if enabled - if (useComposioForGoogleCalendar) { - refreshGoogleCalendarStatus() - } - - // Refresh OAuth providers - if (providers.length === 0) return - - const newStates: Record = {} - - try { - const result = await window.ipc.invoke('oauth:getState', null) - const config = result.config || {} - const statusMap: Record = {} - - 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, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar]) - - // Refresh statuses when popover opens or providers list changes - useEffect(() => { - if (open) { - refreshAllStatuses() - } - }, [open, providers, refreshAllStatuses]) - - // Listen for OAuth state change events (connect + disconnect) - useEffect(() => { - const cleanup = window.ipc.on('oauth:didConnect', async (event) => { - const { provider, success } = event - - setProviderStates(prev => ({ - ...prev, - [provider]: { - isConnected: success, - isLoading: false, - isConnecting: false, - } - })) - - if (success) { - const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) - // Show detailed message for Google and Fireflies (includes sync info) - if (provider === 'google' || provider === 'fireflies-ai') { - toast.success(`Connected to ${displayName}`, { - description: 'Syncing your data in the background. This may take a few minutes before changes appear.', - duration: 8000, - }) - } else { - toast.success(`Connected to ${displayName}`) - } - - // When Rowboat account connects, re-check composio flags so Gmail/Calendar use the right flow - if (provider === 'rowboat') { - try { - const [googleResult, calendarResult] = await Promise.all([ - window.ipc.invoke('composio:use-composio-for-google', null), - window.ipc.invoke('composio:use-composio-for-google-calendar', null), - ]) - setUseComposioForGoogle(googleResult.enabled) - setUseComposioForGoogleCalendar(calendarResult.enabled) - } catch (err) { - console.error('Failed to re-check composio flags:', err) - } - } - - // Refresh status to ensure consistency - refreshAllStatuses() - } - // Note: error toasts for failed connections are handled by startConnect/handleConnect. - // Disconnect events (success: false) are handled by handleDisconnect which shows its own toast. - }) - - return cleanup - }, [refreshAllStatuses]) - - // Listen for Composio connection events (Gmail, Google Calendar) - useEffect(() => { - const cleanup = window.ipc.on('composio:didConnect', (event) => { - const { toolkitSlug, success, error } = event - - if (toolkitSlug === 'gmail') { - setGmailConnected(success) - setGmailConnecting(false) - - if (success) { - toast.success('Connected to Gmail', { - description: 'Syncing your emails in the background. This may take a few minutes before changes appear.', - duration: 8000, - }) - } else { - toast.error(error || 'Failed to connect to Gmail') - } - } - - if (toolkitSlug === 'googlecalendar') { - setGoogleCalendarConnected(success) - setGoogleCalendarConnecting(false) - - if (success) { - toast.success('Connected to Google Calendar', { - description: 'Syncing your calendar in the background. This may take a few minutes before changes appear.', - duration: 8000, - }) - } else { - toast.error(error || 'Failed to connect to Google Calendar') - } - } - }) - - return cleanup - }, []) - - - const startConnect = useCallback(async (provider: string, clientId?: string) => { - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isConnecting: true } - })) - - try { - const result = await window.ipc.invoke('oauth:connect', { provider, clientId }) - - if (result.success) { - // OAuth flow started - keep isConnecting state, wait for event - // Event listener will handle the actual completion - } else { - // Immediate failure (e.g., couldn't start flow) - toast.error(result.error || `Failed to connect to ${provider}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isConnecting: false } - })) - } - } catch (error) { - console.error('Failed to connect:', error) - toast.error(`Failed to connect to ${provider}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isConnecting: false } - })) - } - }, []) - - // Connect to a provider - const handleConnect = useCallback(async (provider: string) => { - if (provider === 'google') { - setGoogleClientIdDescription(undefined) - const existingClientId = getGoogleClientId() - if (!existingClientId) { - setGoogleClientIdOpen(true) - return - } - await startConnect(provider, existingClientId) - return - } - - await startConnect(provider) - }, [startConnect]) - - const handleGoogleClientIdSubmit = useCallback((clientId: string) => { - setGoogleClientId(clientId) - setGoogleClientIdOpen(false) - setGoogleClientIdDescription(undefined) - startConnect('google', clientId) - }, [startConnect]) - - // 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) { - if (provider === 'google') { - clearGoogleClientId() - } - const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) - toast.success(`Disconnected from ${displayName}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { - isConnected: false, - isLoading: false, - isConnecting: false, - } - })) - } else { - toast.error(`Failed to disconnect from ${provider}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isLoading: false } - })) - } - } catch (error) { - console.error('Failed to disconnect:', error) - toast.error(`Failed to disconnect from ${provider}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isLoading: false } - })) - } - }, []) - - const hasProviderError = Object.values(providerStatus).some( - (status) => Boolean(status?.error) - ) + const isUnconnectedMode = mode === "unconnected" // Helper to render an OAuth provider row const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => { - const state = providerStates[provider] || { + const state = c.providerStates[provider] || { isConnected: false, isLoading: true, isConnecting: false, } - const needsReconnect = Boolean(providerStatus[provider]?.error) + const needsReconnect = Boolean(c.providerStatus[provider]?.error) + + // In unconnected mode, skip connected providers (unless they need reconnect) + if (isUnconnectedMode && state.isConnected && !needsReconnect && !state.isLoading) { + return null + } return (
{ if (provider === 'google') { - setGoogleClientIdDescription( + c.setGoogleClientIdDescription( "To keep your Google account connected, please re-enter your client ID. You only need to do this once." ) - setGoogleClientIdOpen(true) + c.setGoogleClientIdOpen(true) return } - startConnect(provider) + c.startConnect(provider) }} className="h-7 px-2 text-xs" > @@ -662,23 +98,23 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha ) : ( )} @@ -687,19 +123,57 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha ) } + // Check if Gmail is unconnected (for filtering in unconnected mode) + const isGmailUnconnected = c.useComposioForGoogle ? !c.gmailConnected && !c.gmailLoading : true + const isGoogleCalendarUnconnected = c.useComposioForGoogleCalendar ? !c.googleCalendarConnected && !c.googleCalendarLoading : true + const isGranolaUnconnected = !c.granolaEnabled && !c.granolaLoading + const isSlackUnconnected = !c.slackEnabled && !c.slackLoading + + // For unconnected mode, check if there's anything to show + const hasUnconnectedEmailCalendar = (() => { + if (!isUnconnectedMode) return true + if (c.useComposioForGoogle && isGmailUnconnected) return true + if (c.useComposioForGoogleCalendar && isGoogleCalendarUnconnected) return true + if (!c.useComposioForGoogle && c.providers.includes('google')) { + const googleState = c.providerStates['google'] + if (!googleState?.isConnected || c.providerStatus['google']?.error) return true + } + return false + })() + + const hasUnconnectedMeetingNotes = (() => { + if (!isUnconnectedMode) return true + if (isGranolaUnconnected) return true + if (c.providers.includes('fireflies-ai')) { + const firefliesState = c.providerStates['fireflies-ai'] + if (!firefliesState?.isConnected || c.providerStatus['fireflies-ai']?.error) return true + } + return false + })() + + const hasUnconnectedSlack = !isUnconnectedMode || isSlackUnconnected + + const isRowboatUnconnected = (() => { + if (!c.providers.includes('rowboat')) return false + const rowboatState = c.providerStates['rowboat'] + return !rowboatState?.isConnected || rowboatState?.isLoading + })() + + const allConnected = isUnconnectedMode && !isRowboatUnconnected && !hasUnconnectedEmailCalendar && !hasUnconnectedMeetingNotes && !hasUnconnectedSlack + return ( <> { - setGoogleClientIdOpen(nextOpen) + c.setGoogleClientIdOpen(nextOpen) if (!nextOpen) { - setGoogleClientIdDescription(undefined) + c.setGoogleClientIdDescription(undefined) } }} - onSubmit={handleGoogleClientIdSubmit} - isSubmitting={providerStates.google?.isConnecting ?? false} - description={googleClientIdDescription} + onSubmit={c.handleGoogleClientIdSubmit} + isSubmitting={c.providerStates.google?.isConnecting ?? false} + description={c.googleClientIdDescription} /> {tooltip ? ( @@ -726,91 +200,106 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha >

- Connected accounts - {hasProviderError && ( + {isUnconnectedMode ? "Connect Accounts" : "Connected accounts"} + {!isUnconnectedMode && c.hasProviderError && ( )}

- Connect accounts to sync data + {isUnconnectedMode ? "Add new account connections" : "Connect accounts to sync data"}

- {providersLoading ? ( + {c.providersLoading ? (
+ ) : allConnected ? ( +
+

All accounts connected

+

+ Manage your connections in Settings +

+
) : ( <> - {/* Rowboat Account */} - {providers.includes('rowboat') && ( - <> -
- Account -
- {renderOAuthProvider('rowboat', 'Rowboat', , 'Connect your Rowboat account')} - - - )} + {/* Rowboat Account - show in "all" mode always, or in "unconnected" mode only when not connected */} + {c.providers.includes('rowboat') && (() => { + const rowboatState = c.providerStates['rowboat'] + const isRowboatConnected = rowboatState?.isConnected && !rowboatState?.isLoading + if (isUnconnectedMode && isRowboatConnected) return null + return ( + <> +
+ Account +
+ {renderOAuthProvider('rowboat', 'Rowboat', , 'Log in to your Rowboat account')} + + + ) + })()} {/* Email & Calendar Section */} - {(useComposioForGoogle || useComposioForGoogleCalendar || providers.includes('google')) && ( + {(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && hasUnconnectedEmailCalendar && ( <>
Email & Calendar
- {useComposioForGoogle ? ( -
-
-
- + {c.useComposioForGoogle ? ( + // In unconnected mode, only show if not connected + (!isUnconnectedMode || isGmailUnconnected) ? ( +
+
+
+ +
+
+ Gmail + {c.gmailLoading ? ( + Checking... + ) : ( + + Sync emails + + )} +
-
- Gmail - {gmailLoading ? ( - Checking... +
+ {c.gmailLoading ? ( + + ) : c.gmailConnected ? ( + ) : ( - - Sync emails - + )}
-
- {gmailLoading ? ( - - ) : gmailConnected ? ( - - ) : ( - - )} -
-
+ ) : null ) : ( renderOAuthProvider('google', 'Google', , 'Sync emails and calendar') )} - {useComposioForGoogleCalendar && ( + {c.useComposioForGoogleCalendar && (!isUnconnectedMode || isGoogleCalendarUnconnected) && (
@@ -818,7 +307,7 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
Google Calendar - {googleCalendarLoading ? ( + {c.googleCalendarLoading ? ( Checking... ) : ( @@ -828,13 +317,13 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
- {googleCalendarLoading ? ( + {c.googleCalendarLoading ? ( - ) : googleCalendarConnected ? ( + ) : c.googleCalendarConnected ? ( + )} +
-
- Slack - {slackEnabled && slackWorkspaces.length > 0 ? ( - - {slackWorkspaces.map(w => w.name).join(', ')} - - ) : ( - - Send messages and view channels - - )} -
-
-
- {(slackLoading || slackDiscovering) && ( - - )} - {slackEnabled ? ( - handleSlackDisable()} - disabled={slackLoading} - /> - ) : ( - + {c.slackPickerOpen && ( +
+ {c.slackDiscoverError ? ( +

{c.slackDiscoverError}

+ ) : ( + <> + {c.slackAvailableWorkspaces.map(w => ( + + ))} + + + )} +
)}
-
- {slackPickerOpen && ( -
- {slackDiscoverError ? ( -

{slackDiscoverError}

- ) : ( - <> - {slackAvailableWorkspaces.map(w => ( - - ))} - - - )} -
- )} -
+ + )} )}
) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 65c5b424..8c513232 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback, useMemo } from "react" -import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, Mail, BookOpen, ChevronRight, Plus, X } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, Mail, BookOpen, ChevronRight, Plus, X, User, Plug } from "lucide-react" import { Dialog, @@ -22,8 +22,10 @@ import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" import { useTheme } from "@/contexts/theme-context" import { toast } from "sonner" +import { AccountSettings } from "@/components/settings/account-settings" +import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings" -type ConfigTab = "models" | "mcp" | "security" | "appearance" | "note-tagging" +type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "note-tagging" interface TabConfig { id: ConfigTab @@ -34,6 +36,18 @@ interface TabConfig { } const tabs: TabConfig[] = [ + { + id: "account", + label: "Account", + icon: User, + description: "Manage your Rowboat account", + }, + { + id: "connected-accounts", + label: "Connected Accounts", + icon: Plug, + description: "Manage connected services", + }, { id: "models", label: "Models", @@ -1259,7 +1273,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) { } const loadConfig = useCallback(async (tab: ConfigTab) => { - if (tab === "appearance" || tab === "models" || tab === "note-tagging") return + if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connected-accounts") return const tabConfig = tabs.find((t) => t.id === tab)! if (!tabConfig.path) return setLoading(true) @@ -1367,8 +1381,12 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
{/* Content */} -
- {activeTab === "models" ? ( +
+ {activeTab === "account" ? ( + + ) : activeTab === "connected-accounts" ? ( + + ) : activeTab === "models" ? ( rowboatConnected ? : diff --git a/apps/x/apps/renderer/src/components/settings/account-settings.tsx b/apps/x/apps/renderer/src/components/settings/account-settings.tsx new file mode 100644 index 00000000..04a1e805 --- /dev/null +++ b/apps/x/apps/renderer/src/components/settings/account-settings.tsx @@ -0,0 +1,211 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { Loader2, User, CreditCard, LogOut } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Separator } from "@/components/ui/separator" +import { useBilling } from "@/hooks/useBilling" +import { toast } from "sonner" + +interface AccountSettingsProps { + dialogOpen: boolean +} + +export function AccountSettings({ dialogOpen }: AccountSettingsProps) { + const [isRowboatConnected, setIsRowboatConnected] = useState(false) + const [connectionLoading, setConnectionLoading] = useState(true) + const [disconnecting, setDisconnecting] = useState(false) + const [connecting, setConnecting] = useState(false) + const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected) + + const checkConnection = useCallback(async () => { + try { + setConnectionLoading(true) + const result = await window.ipc.invoke('oauth:getState', null) + const connected = result.config?.rowboat?.connected ?? false + setIsRowboatConnected(connected) + } catch { + setIsRowboatConnected(false) + } finally { + setConnectionLoading(false) + } + }, []) + + useEffect(() => { + if (dialogOpen) { + checkConnection() + } + }, [dialogOpen, checkConnection]) + + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + if (event.provider === 'rowboat') { + setIsRowboatConnected(event.success) + setConnecting(false) + if (event.success) { + toast.success('Logged in to Rowboat') + } + } + }) + return cleanup + }, []) + + const handleConnect = useCallback(async () => { + try { + setConnecting(true) + const result = await window.ipc.invoke('oauth:connect', { provider: 'rowboat' }) + if (!result.success) { + toast.error(result.error || 'Failed to log in to Rowboat') + setConnecting(false) + } + } catch { + toast.error('Failed to log in to Rowboat') + setConnecting(false) + } + }, []) + + const handleDisconnect = useCallback(async () => { + try { + setDisconnecting(true) + const result = await window.ipc.invoke('oauth:disconnect', { provider: 'rowboat' }) + if (result.success) { + setIsRowboatConnected(false) + toast.success('Logged out of Rowboat') + } else { + toast.error('Failed to log out of Rowboat') + } + } catch { + toast.error('Failed to log out of Rowboat') + } finally { + setDisconnecting(false) + } + }, []) + + if (connectionLoading) { + return ( +
+ +
+ ) + } + + if (!isRowboatConnected) { + return ( +
+
+ +
+
+

Not logged in

+

Log in to your Rowboat account to access premium features

+
+ +
+ ) + } + + return ( +
+ {/* Profile Section */} +
+
+
+ +
+
+

+ {billing?.userEmail ?? 'Loading...'} +

+

Rowboat Account

+
+
+
+ + + + {/* Plan Section */} +
+
+ +

Plan

+
+ + {billingLoading ? ( +
+ + Loading plan details... +
+ ) : billing ? ( +
+
+
+

{billing.subscriptionPlan ?? 'Free'} Plan

+ {billing.subscriptionStatus && ( +

{billing.subscriptionStatus}

+ )} +
+ +
+
+ ) : ( +

Unable to load plan details

+ )} +
+ + + + {/* Log Out Section */} +
+
+ +

Log Out

+
+

+ Logging out will remove access to synced data and Rowboat-provided models. +

+ + + + + + + Log out of your Rowboat account? + + This will remove access to synced data and Rowboat-provided models. You can log back in at any time. + + + + Cancel + + {disconnecting ? : null} + Log Out + + + + +
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx b/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx new file mode 100644 index 00000000..1cb12f6e --- /dev/null +++ b/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx @@ -0,0 +1,364 @@ +"use client" + +import * as React from "react" +import { Loader2, Mic, Mail, Calendar, MessageSquare } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { Separator } from "@/components/ui/separator" +import { GoogleClientIdModal } from "@/components/google-client-id-modal" +import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" +import { useConnectors } from "@/hooks/useConnectors" + +interface ConnectedAccountsSettingsProps { + dialogOpen: boolean +} + +export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSettingsProps) { + const c = useConnectors(dialogOpen) + + const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => { + const state = c.providerStates[provider] || { + isConnected: false, + isLoading: true, + isConnecting: false, + } + const needsReconnect = Boolean(c.providerStatus[provider]?.error) + + return ( +
+
+
+ {icon} +
+
+ {displayName} + {state.isLoading ? ( + Checking... + ) : needsReconnect ? ( + Needs reconnect + ) : state.isConnected ? ( + Connected + ) : ( + {description} + )} +
+
+
+ {state.isLoading ? ( + + ) : needsReconnect ? ( + + ) : state.isConnected ? ( + + ) : ( + + )} +
+
+ ) + } + + if (c.providersLoading) { + return ( +
+ +
+ ) + } + + return ( + <> + { + c.setGoogleClientIdOpen(nextOpen) + if (!nextOpen) { + c.setGoogleClientIdDescription(undefined) + } + }} + onSubmit={c.handleGoogleClientIdSubmit} + isSubmitting={c.providerStates.google?.isConnecting ?? false} + description={c.googleClientIdDescription} + /> + + +
+ {/* Email & Calendar Section */} + {(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && ( + <> +
+ + Email & Calendar + +
+ {c.useComposioForGoogle ? ( +
+
+
+ +
+
+ Gmail + {c.gmailLoading ? ( + Checking... + ) : c.gmailConnected ? ( + Connected + ) : ( + Sync emails + )} +
+
+
+ {c.gmailLoading ? ( + + ) : c.gmailConnected ? ( + + ) : ( + + )} +
+
+ ) : ( + c.providers.includes('google') && renderOAuthProvider('google', 'Google', , 'Sync emails and calendar') + )} + {c.useComposioForGoogleCalendar && ( +
+
+
+ +
+
+ Google Calendar + {c.googleCalendarLoading ? ( + Checking... + ) : c.googleCalendarConnected ? ( + Connected + ) : ( + Sync calendar events + )} +
+
+
+ {c.googleCalendarLoading ? ( + + ) : c.googleCalendarConnected ? ( + + ) : ( + + )} +
+
+ )} + + + )} + + {/* Meeting Notes Section */} +
+ + Meeting Notes + +
+ + {/* Granola */} +
+
+
+ +
+
+ Granola + + Local meeting notes + +
+
+
+ {c.granolaLoading && ( + + )} + +
+
+ + {/* Fireflies */} + {c.providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')} + + + + {/* Team Communication Section */} +
+ + Team Communication + +
+ + {/* Slack */} +
+
+
+
+ +
+
+ Slack + {c.slackEnabled && c.slackWorkspaces.length > 0 ? ( + + {c.slackWorkspaces.map(w => w.name).join(', ')} + + ) : ( + + Send messages and view channels + + )} +
+
+
+ {(c.slackLoading || c.slackDiscovering) && ( + + )} + {c.slackEnabled ? ( + c.handleSlackDisable()} + disabled={c.slackLoading} + /> + ) : ( + + )} +
+
+ {c.slackPickerOpen && ( +
+ {c.slackDiscoverError ? ( +

{c.slackDiscoverError}

+ ) : ( + <> + {c.slackAvailableWorkspaces.map(w => ( + + ))} + + + )} +
+ )} +
+
+ + ) +} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 7f7b52aa..ee280031 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { Bot, ChevronRight, @@ -403,8 +403,21 @@ export function SidebarContentPanel({ const [openConnectorsAfterClose, setOpenConnectorsAfterClose] = useState(false) const connectorsButtonRef = useRef(null) const [isRowboatConnected, setIsRowboatConnected] = useState(false) + const [loggingIn, setLoggingIn] = useState(false) const { billing } = useBilling(isRowboatConnected) + const handleRowboatLogin = useCallback(async () => { + try { + setLoggingIn(true) + const result = await window.ipc.invoke('oauth:connect', { provider: 'rowboat' }) + if (!result.success) { + setLoggingIn(false) + } + } catch { + setLoggingIn(false) + } + }, []) + useEffect(() => { let mounted = true @@ -433,6 +446,7 @@ export function SidebarContentPanel({ refreshOauthError() const cleanup = window.ipc.on('oauth:didConnect', () => { refreshOauthError() + setLoggingIn(false) }) return () => { @@ -488,8 +502,8 @@ export function SidebarContentPanel({ /> )} - {/* Billing / upgrade CTA */} - {isRowboatConnected && billing && ( + {/* Billing / upgrade CTA or Log in CTA */} + {isRowboatConnected && billing ? (
@@ -500,18 +514,30 @@ export function SidebarContentPanel({
+ ) : null} + {/* Sign in CTA */} + {!isRowboatConnected && ( +
+ +
)} {/* Bottom actions */}
- + {hasOauthError && ( diff --git a/apps/x/apps/renderer/src/hooks/useBilling.ts b/apps/x/apps/renderer/src/hooks/useBilling.ts index 49459fdb..d2be5807 100644 --- a/apps/x/apps/renderer/src/hooks/useBilling.ts +++ b/apps/x/apps/renderer/src/hooks/useBilling.ts @@ -1,6 +1,8 @@ import { useState, useEffect, useCallback } from 'react' interface BillingInfo { + userEmail: string | null + userId: string | null subscriptionPlan: string subscriptionStatus: string sanctionedCredits: number diff --git a/apps/x/apps/renderer/src/hooks/useConnectors.ts b/apps/x/apps/renderer/src/hooks/useConnectors.ts new file mode 100644 index 00000000..cad319d7 --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useConnectors.ts @@ -0,0 +1,618 @@ +import { useState, useEffect, useCallback } from "react" +import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store" +import { toast } from "sonner" + +export interface ProviderState { + isConnected: boolean + isLoading: boolean + isConnecting: boolean +} + +export interface ProviderStatus { + error?: string +} + +export function useConnectors(active: boolean) { + const [providers, setProviders] = useState([]) + const [providersLoading, setProvidersLoading] = useState(true) + const [providerStates, setProviderStates] = useState>({}) + const [providerStatus, setProviderStatus] = useState>({}) + const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false) + const [googleClientIdDescription, setGoogleClientIdDescription] = useState(undefined) + + // Granola state + const [granolaEnabled, setGranolaEnabled] = useState(false) + const [granolaLoading, setGranolaLoading] = useState(true) + + // Composio API key state + const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) + const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') + + // Slack state + const [slackEnabled, setSlackEnabled] = useState(false) + const [slackLoading, setSlackLoading] = useState(true) + const [slackWorkspaces, setSlackWorkspaces] = useState>([]) + const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState>([]) + const [slackSelectedUrls, setSlackSelectedUrls] = useState>(new Set()) + const [slackPickerOpen, setSlackPickerOpen] = useState(false) + const [slackDiscovering, setSlackDiscovering] = useState(false) + const [slackDiscoverError, setSlackDiscoverError] = useState(null) + + // Composio/Gmail state + const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) + const [gmailConnected, setGmailConnected] = useState(false) + const [gmailLoading, setGmailLoading] = useState(true) + const [gmailConnecting, setGmailConnecting] = useState(false) + + // Composio/Google Calendar state + const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) + const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) + const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) + + // 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() + }, []) + + // Re-check composio-for-google flags when active + useEffect(() => { + if (!active) return + async function loadComposioForGoogleFlag() { + try { + const result = await window.ipc.invoke('composio:use-composio-for-google', null) + setUseComposioForGoogle(result.enabled) + } catch (error) { + console.error('Failed to check composio-for-google flag:', error) + } + } + async function loadComposioForGoogleCalendarFlag() { + try { + const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) + setUseComposioForGoogleCalendar(result.enabled) + } catch (error) { + console.error('Failed to check composio-for-google-calendar flag:', error) + } + } + loadComposioForGoogleFlag() + loadComposioForGoogleCalendarFlag() + }, [active]) + + // 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) + } + }, []) + + const handleGranolaToggle = useCallback(async (enabled: boolean) => { + try { + setGranolaLoading(true) + await window.ipc.invoke('granola:setConfig', { enabled }) + setGranolaEnabled(enabled) + toast.success(enabled ? 'Granola sync enabled' : 'Granola sync disabled') + } catch (error) { + console.error('Failed to update Granola config:', error) + toast.error('Failed to update Granola sync settings') + } finally { + setGranolaLoading(false) + } + }, []) + + // Slack + const refreshSlackConfig = useCallback(async () => { + try { + setSlackLoading(true) + const result = await window.ipc.invoke('slack:getConfig', null) + setSlackEnabled(result.enabled) + setSlackWorkspaces(result.workspaces || []) + } catch (error) { + console.error('Failed to load Slack config:', error) + setSlackEnabled(false) + setSlackWorkspaces([]) + } finally { + setSlackLoading(false) + } + }, []) + + const handleSlackEnable = useCallback(async () => { + setSlackDiscovering(true) + setSlackDiscoverError(null) + try { + const result = await window.ipc.invoke('slack:listWorkspaces', null) + if (result.error || result.workspaces.length === 0) { + setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop') + setSlackAvailableWorkspaces([]) + setSlackPickerOpen(true) + } else { + setSlackAvailableWorkspaces(result.workspaces) + setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url))) + setSlackPickerOpen(true) + } + } catch (error) { + console.error('Failed to discover Slack workspaces:', error) + setSlackDiscoverError('Failed to discover Slack workspaces') + setSlackPickerOpen(true) + } finally { + setSlackDiscovering(false) + } + }, []) + + const handleSlackSaveWorkspaces = useCallback(async () => { + const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url)) + try { + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected }) + setSlackEnabled(true) + setSlackWorkspaces(selected) + setSlackPickerOpen(false) + toast.success('Slack enabled') + } catch (error) { + console.error('Failed to save Slack config:', error) + toast.error('Failed to save Slack settings') + } finally { + setSlackLoading(false) + } + }, [slackAvailableWorkspaces, slackSelectedUrls]) + + const handleSlackDisable = useCallback(async () => { + try { + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] }) + setSlackEnabled(false) + setSlackWorkspaces([]) + setSlackPickerOpen(false) + toast.success('Slack disabled') + } catch (error) { + console.error('Failed to update Slack config:', error) + toast.error('Failed to update Slack settings') + } finally { + setSlackLoading(false) + } + }, []) + + // Gmail (Composio) + const refreshGmailStatus = useCallback(async () => { + try { + setGmailLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' }) + setGmailConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Gmail status:', error) + setGmailConnected(false) + } finally { + setGmailLoading(false) + } + }, []) + + const startGmailConnect = useCallback(async () => { + try { + setGmailConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Gmail') + setGmailConnecting(false) + } + } catch (error) { + console.error('Failed to connect to Gmail:', error) + toast.error('Failed to connect to Gmail') + setGmailConnecting(false) + } + }, []) + + const handleConnectGmail = useCallback(async () => { + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyTarget('gmail') + setComposioApiKeyOpen(true) + return + } + await startGmailConnect() + }, [startGmailConnect]) + + const handleDisconnectGmail = useCallback(async () => { + try { + setGmailLoading(true) + const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'gmail' }) + if (result.success) { + setGmailConnected(false) + toast.success('Disconnected from Gmail') + } else { + toast.error('Failed to disconnect from Gmail') + } + } catch (error) { + console.error('Failed to disconnect from Gmail:', error) + toast.error('Failed to disconnect from Gmail') + } finally { + setGmailLoading(false) + } + }, []) + + // Google Calendar (Composio) + const refreshGoogleCalendarStatus = useCallback(async () => { + try { + setGoogleCalendarLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' }) + setGoogleCalendarConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Google Calendar status:', error) + setGoogleCalendarConnected(false) + } finally { + setGoogleCalendarLoading(false) + } + }, []) + + const startGoogleCalendarConnect = useCallback(async () => { + try { + setGoogleCalendarConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'googlecalendar' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Google Calendar') + setGoogleCalendarConnecting(false) + } + } catch (error) { + console.error('Failed to connect to Google Calendar:', error) + toast.error('Failed to connect to Google Calendar') + setGoogleCalendarConnecting(false) + } + }, []) + + const handleConnectGoogleCalendar = useCallback(async () => { + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyTarget('gmail') + setComposioApiKeyOpen(true) + return + } + await startGoogleCalendarConnect() + }, [startGoogleCalendarConnect]) + + const handleDisconnectGoogleCalendar = useCallback(async () => { + try { + setGoogleCalendarLoading(true) + const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'googlecalendar' }) + if (result.success) { + setGoogleCalendarConnected(false) + toast.success('Disconnected from Google Calendar') + } else { + toast.error('Failed to disconnect from Google Calendar') + } + } catch (error) { + console.error('Failed to disconnect from Google Calendar:', error) + toast.error('Failed to disconnect from Google Calendar') + } finally { + setGoogleCalendarLoading(false) + } + }, []) + + // Composio API key + const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => { + try { + await window.ipc.invoke('composio:set-api-key', { apiKey }) + setComposioApiKeyOpen(false) + toast.success('Composio API key saved') + await startGmailConnect() + } catch (error) { + console.error('Failed to save Composio API key:', error) + toast.error('Failed to save API key') + } + }, [startGmailConnect]) + + // OAuth connect/disconnect + const startConnect = useCallback(async (provider: string, clientId?: string) => { + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isConnecting: true } + })) + + try { + const result = await window.ipc.invoke('oauth:connect', { provider, clientId }) + + if (!result.success) { + toast.error(result.error || (provider === 'rowboat' ? 'Failed to log in to Rowboat' : `Failed to connect to ${provider}`)) + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isConnecting: false } + })) + } + } catch (error) { + console.error('Failed to connect:', error) + toast.error(provider === 'rowboat' ? 'Failed to log in to Rowboat' : `Failed to connect to ${provider}`) + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isConnecting: false } + })) + } + }, []) + + const handleConnect = useCallback(async (provider: string) => { + if (provider === 'google') { + setGoogleClientIdDescription(undefined) + const existingClientId = getGoogleClientId() + if (!existingClientId) { + setGoogleClientIdOpen(true) + return + } + await startConnect(provider, existingClientId) + return + } + + await startConnect(provider) + }, [startConnect]) + + const handleGoogleClientIdSubmit = useCallback((clientId: string) => { + setGoogleClientId(clientId) + setGoogleClientIdOpen(false) + setGoogleClientIdDescription(undefined) + startConnect('google', clientId) + }, [startConnect]) + + 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) { + if (provider === 'google') { + clearGoogleClientId() + } + const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) + toast.success(provider === 'rowboat' ? 'Logged out of Rowboat' : `Disconnected from ${displayName}`) + setProviderStates(prev => ({ + ...prev, + [provider]: { + isConnected: false, + isLoading: false, + isConnecting: false, + } + })) + } else { + toast.error(provider === 'rowboat' ? 'Failed to log out of Rowboat' : `Failed to disconnect from ${provider}`) + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isLoading: false } + })) + } + } catch (error) { + console.error('Failed to disconnect:', error) + toast.error(provider === 'rowboat' ? 'Failed to log out of Rowboat' : `Failed to disconnect from ${provider}`) + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isLoading: false } + })) + } + }, []) + + // Refresh all statuses + const refreshAllStatuses = useCallback(async () => { + refreshGranolaConfig() + refreshSlackConfig() + + if (useComposioForGoogle) { + refreshGmailStatus() + } + + if (useComposioForGoogleCalendar) { + refreshGoogleCalendarStatus() + } + + if (providers.length === 0) return + + const newStates: Record = {} + + try { + const result = await window.ipc.invoke('oauth:getState', null) + const config = result.config || {} + const statusMap: Record = {} + + 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, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar]) + + // Refresh when active or providers change + useEffect(() => { + if (active) { + refreshAllStatuses() + } + }, [active, providers, refreshAllStatuses]) + + // Listen for OAuth events + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', async (event) => { + const { provider, success } = event + + setProviderStates(prev => ({ + ...prev, + [provider]: { + isConnected: success, + isLoading: false, + isConnecting: false, + } + })) + + if (success) { + const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) + if (provider === 'rowboat') { + toast.success('Logged in to Rowboat') + } else if (provider === 'google' || provider === 'fireflies-ai') { + toast.success(`Connected to ${displayName}`, { + description: 'Syncing your data in the background. This may take a few minutes before changes appear.', + duration: 8000, + }) + } else { + toast.success(`Connected to ${displayName}`) + } + + if (provider === 'rowboat') { + try { + const [googleResult, calendarResult] = await Promise.all([ + window.ipc.invoke('composio:use-composio-for-google', null), + window.ipc.invoke('composio:use-composio-for-google-calendar', null), + ]) + setUseComposioForGoogle(googleResult.enabled) + setUseComposioForGoogleCalendar(calendarResult.enabled) + } catch (err) { + console.error('Failed to re-check composio flags:', err) + } + } + + refreshAllStatuses() + } + }) + + return cleanup + }, [refreshAllStatuses]) + + // Listen for Composio events + useEffect(() => { + const cleanup = window.ipc.on('composio:didConnect', (event) => { + const { toolkitSlug, success, error } = event + + if (toolkitSlug === 'gmail') { + setGmailConnected(success) + setGmailConnecting(false) + + if (success) { + toast.success('Connected to Gmail', { + description: 'Syncing your emails in the background. This may take a few minutes before changes appear.', + duration: 8000, + }) + } else { + toast.error(error || 'Failed to connect to Gmail') + } + } + + if (toolkitSlug === 'googlecalendar') { + setGoogleCalendarConnected(success) + setGoogleCalendarConnecting(false) + + if (success) { + toast.success('Connected to Google Calendar', { + description: 'Syncing your calendar in the background. This may take a few minutes before changes appear.', + duration: 8000, + }) + } else { + toast.error(error || 'Failed to connect to Google Calendar') + } + } + }) + + return cleanup + }, []) + + const hasProviderError = Object.values(providerStatus).some( + (status) => Boolean(status?.error) + ) + + return { + // OAuth providers + providers, + providersLoading, + providerStates, + providerStatus, + hasProviderError, + handleConnect, + handleDisconnect, + startConnect, + + // Google client ID modal + googleClientIdOpen, + setGoogleClientIdOpen, + googleClientIdDescription, + setGoogleClientIdDescription, + handleGoogleClientIdSubmit, + + // Granola + granolaEnabled, + granolaLoading, + handleGranolaToggle, + + // Composio API key modal + composioApiKeyOpen, + setComposioApiKeyOpen, + composioApiKeyTarget, + setComposioApiKeyTarget, + handleComposioApiKeySubmit, + + // Slack + slackEnabled, + slackLoading, + slackWorkspaces, + slackAvailableWorkspaces, + slackSelectedUrls, + setSlackSelectedUrls, + slackPickerOpen, + setSlackPickerOpen, + slackDiscovering, + slackDiscoverError, + handleSlackEnable, + handleSlackSaveWorkspaces, + handleSlackDisable, + + // Gmail (Composio) + useComposioForGoogle, + gmailConnected, + gmailLoading, + gmailConnecting, + handleConnectGmail, + handleDisconnectGmail, + + // Google Calendar (Composio) + useComposioForGoogleCalendar, + googleCalendarConnected, + googleCalendarLoading, + googleCalendarConnecting, + handleConnectGoogleCalendar, + handleDisconnectGoogleCalendar, + + // Refresh + refreshAllStatuses, + } +} diff --git a/apps/x/packages/core/src/billing/billing.ts b/apps/x/packages/core/src/billing/billing.ts index 69cf6083..b3011130 100644 --- a/apps/x/packages/core/src/billing/billing.ts +++ b/apps/x/packages/core/src/billing/billing.ts @@ -2,6 +2,8 @@ import { getAccessToken } from '../auth/tokens.js'; import { API_URL } from '../config/env.js'; export interface BillingInfo { + userEmail: string | null; + userId: string | null; subscriptionPlan: string | null; subscriptionStatus: string | null; sanctionedCredits: number; @@ -31,6 +33,8 @@ export async function getBillingInfo(): Promise { }; }; return { + userEmail: body.user.email ?? null, + userId: body.user.id ?? null, subscriptionPlan: body.billing.plan, subscriptionStatus: body.billing.status, sanctionedCredits: body.billing.usage.sanctionedCredits, diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index b6abbf29..346a95d2 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -526,6 +526,8 @@ const ipcSchemas = { 'billing:getInfo': { req: z.null(), res: z.object({ + userEmail: z.string().nullable(), + userId: z.string().nullable(), subscriptionPlan: z.string().nullable(), subscriptionStatus: z.string().nullable(), sanctionedCredits: z.number(), From 91030a5fca7321b2bf143214e036a0b0516c180c Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:33:12 +0530 Subject: [PATCH 13/61] Blocks (#439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added blocks to notes and updated assistant skill with this. Image blocks — images with alt text and captions Embed blocks — inline YouTube videos, Figma designs, or link cards Chart blocks — line, bar, and pie charts from inline data or JSON files Table blocks — styled data tables with named columns --- apps/x/apps/renderer/package.json | 1 + .../src/components/markdown-editor.tsx | 16 ++ .../renderer/src/extensions/chart-block.tsx | 173 ++++++++++++++ .../renderer/src/extensions/embed-block.tsx | 143 +++++++++++ .../renderer/src/extensions/image-block.tsx | 104 ++++++++ .../renderer/src/extensions/table-block.tsx | 124 ++++++++++ apps/x/apps/renderer/src/styles/editor.css | 224 ++++++++++++++++++ .../assistant/skills/doc-collab/skill.ts | 50 ++++ apps/x/packages/shared/src/blocks.ts | 36 +++ apps/x/packages/shared/src/index.ts | 1 + apps/x/pnpm-lock.yaml | 144 +++++++++++ 11 files changed, 1016 insertions(+) create mode 100644 apps/x/apps/renderer/src/extensions/chart-block.tsx create mode 100644 apps/x/apps/renderer/src/extensions/embed-block.tsx create mode 100644 apps/x/apps/renderer/src/extensions/image-block.tsx create mode 100644 apps/x/apps/renderer/src/extensions/table-block.tsx create mode 100644 apps/x/packages/shared/src/blocks.ts diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index 1ff56246..ebf8a650 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -46,6 +46,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "recharts": "^3.8.0", "sonner": "^2.0.7", "streamdown": "^1.6.10", "tailwind-merge": "^3.4.0", diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 9f4a2d2a..ba86c638 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -9,6 +9,10 @@ import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' import { TaskBlockExtension } from '@/extensions/task-block' +import { ImageBlockExtension } from '@/extensions/image-block' +import { EmbedBlockExtension } from '@/extensions/embed-block' +import { ChartBlockExtension } from '@/extensions/chart-block' +import { TableBlockExtension } from '@/extensions/table-block' import { Markdown } from 'tiptap-markdown' import { useEffect, useCallback, useMemo, useRef, useState } from 'react' @@ -136,6 +140,14 @@ function getMarkdownWithBlankLines(editor: Editor): string { blocks.push(listLines.join('\n')) } else if (node.type === 'taskBlock') { blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'imageBlock') { + blocks.push('```image\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'embedBlock') { + blocks.push('```embed\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'chartBlock') { + blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'tableBlock') { + blocks.push('```table\n' + (node.attrs?.data as string || '{}') + '\n```') } else if (node.type === 'codeBlock') { const lang = (node.attrs?.language as string) || '' blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```') @@ -429,6 +441,10 @@ export function MarkdownEditor({ }), ImageUploadPlaceholderExtension, TaskBlockExtension, + ImageBlockExtension, + EmbedBlockExtension, + ChartBlockExtension, + TableBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { diff --git a/apps/x/apps/renderer/src/extensions/chart-block.tsx b/apps/x/apps/renderer/src/extensions/chart-block.tsx new file mode 100644 index 00000000..3377b157 --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/chart-block.tsx @@ -0,0 +1,173 @@ +import { mergeAttributes, Node } from '@tiptap/react' +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' +import { X, BarChart3 } from 'lucide-react' +import { blocks } from '@x/shared' +import { useState, useEffect } from 'react' +import { + LineChart, Line, + BarChart, Bar, + PieChart, Pie, Cell, + XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, +} from 'recharts' + +const CHART_COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#0088fe', '#00c49f'] + +function ChartBlockView({ node, deleteNode }: { node: { attrs: Record }; deleteNode: () => void }) { + const raw = node.attrs.data as string + let config: blocks.ChartBlock | null = null + + try { + config = blocks.ChartBlockSchema.parse(JSON.parse(raw)) + } catch { + // fallback below + } + + const [fileData, setFileData] = useState[] | null>(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!config?.source) return + setLoading(true) + setError(null) + ;(window as unknown as { ipc: { invoke: (channel: string, args: Record) => Promise } }) + .ipc.invoke('workspace:readFile', { path: config.source, encoding: 'utf-8' }) + .then((content: string) => { + const parsed = JSON.parse(content) + if (Array.isArray(parsed)) { + setFileData(parsed) + } else { + setError('Source file must contain a JSON array') + } + }) + .catch((err: Error) => { + setError(err.message || 'Failed to load data file') + }) + .finally(() => setLoading(false)) + }, [config?.source]) + + if (!config) { + return ( + +
+ + Invalid chart block +
+
+ ) + } + + const data = config.data || fileData + + const renderChart = () => { + if (loading) return
Loading data...
+ if (error) return
{error}
+ if (!data || data.length === 0) return
No data
+ + return ( + + {config!.chart === 'line' ? ( + + + + + + + + + ) : config!.chart === 'bar' ? ( + + + + + + + + + ) : ( + + + + + {data.map((_, index) => ( + + ))} + + + )} + + ) + } + + return ( + +
+ + {config.title &&
{config.title}
} + {renderChart()} +
+
+ ) +} + +export const ChartBlockExtension = Node.create({ + name: 'chartBlock', + group: 'block', + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + data: { + default: '{}', + }, + } + }, + + parseHTML() { + return [ + { + tag: 'pre', + priority: 60, + getAttrs(element) { + const code = element.querySelector('code') + if (!code) return false + const cls = code.className || '' + if (cls.includes('language-chart')) { + return { data: code.textContent || '{}' } + } + return false + }, + }, + ] + }, + + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'chart-block' })] + }, + + addNodeView() { + return ReactNodeViewRenderer(ChartBlockView) + }, + + addStorage() { + return { + markdown: { + serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) { + state.write('```chart\n' + node.attrs.data + '\n```') + state.closeBlock(node) + }, + parse: { + // handled by parseHTML + }, + }, + } + }, +}) diff --git a/apps/x/apps/renderer/src/extensions/embed-block.tsx b/apps/x/apps/renderer/src/extensions/embed-block.tsx new file mode 100644 index 00000000..b3bc6969 --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/embed-block.tsx @@ -0,0 +1,143 @@ +import { mergeAttributes, Node } from '@tiptap/react' +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' +import { X, ExternalLink } from 'lucide-react' +import { blocks } from '@x/shared' + +function getEmbedUrl(provider: string, url: string): string | null { + if (provider === 'youtube') { + // Handle youtube.com/watch?v=X and youtu.be/X + const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/) + if (match) return `https://www.youtube.com/embed/${match[1]}` + } + if (provider === 'figma') { + // Convert www.figma.com/design/:key/... → embed.figma.com/design/:key?embed-host=rowboat + const figmaMatch = url.match(/figma\.com\/(design|board|proto)\/([\w-]+)/) + if (figmaMatch) { + return `https://embed.figma.com/${figmaMatch[1]}/${figmaMatch[2]}?embed-host=rowboat` + } + // Legacy /file/ URLs + const legacyMatch = url.match(/figma\.com\/file\/([\w-]+)/) + if (legacyMatch) { + return `https://embed.figma.com/design/${legacyMatch[1]}?embed-host=rowboat` + } + } + return null +} + +function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record }; deleteNode: () => void }) { + const raw = node.attrs.data as string + let config: blocks.EmbedBlock | null = null + + try { + config = blocks.EmbedBlockSchema.parse(JSON.parse(raw)) + } catch { + // fallback below + } + + if (!config) { + return ( + +
+ + Invalid embed block +
+
+ ) + } + + const embedUrl = getEmbedUrl(config.provider, config.url) + + return ( + +
+ + {embedUrl ? ( +
+