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/entitlements.plist b/apps/x/apps/main/entitlements.plist new file mode 100644 index 00000000..c0899b9d --- /dev/null +++ b/apps/x/apps/main/entitlements.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.device.audio-input + + com.apple.security.device.screen-capture + + + diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 57f733f2..178cb7e1 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -11,8 +11,15 @@ module.exports = { icon: './icons/icon', // .icns extension added automatically appBundleId: 'com.rowboat.app', appCategoryType: 'public.app-category.productivity', + extendInfo: { + NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)', + }, osxSign: { batchCodesignCalls: true, + optionsForFile: () => ({ + entitlements: path.join(__dirname, 'entitlements.plist'), + 'entitlements-inherit': path.join(__dirname, 'entitlements.plist'), + }), }, osxNotarize: { appleId: process.env.APPLE_ID, diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts index b0b890c0..78e519d0 100644 --- a/apps/x/apps/main/src/auth-server.ts +++ b/apps/x/apps/main/src/auth-server.ts @@ -25,7 +25,7 @@ export interface AuthServerResult { */ export function createAuthServer( port: number = DEFAULT_PORT, - onCallback: (code: string, state: string) => void | Promise + onCallback: (params: Record) => void | Promise ): Promise { return new Promise((resolve, reject) => { const server = createServer((req, res) => { @@ -67,7 +67,7 @@ export function createAuthServer( // Handle callback - either traditional OAuth with code/state or Composio-style notification // Composio callbacks may not have code/state, just a notification that the flow completed - onCallback(code || '', state || ''); + onCallback(Object.fromEntries(url.searchParams.entries())); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(` diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index e5b25d1a..59532373 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -2,15 +2,21 @@ 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 { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js'; +import { CURATED_TOOLKIT_SLUGS } from '@x/shared/dist/composio.js'; +import type { LocalConnectedAccount, Toolkit } from '@x/core/dist/composio/types.js'; +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'; -// Store active OAuth flows +// Store active OAuth flows (keyed by toolkitSlug to prevent concurrent flows for the same toolkit) const activeFlows = new Map(); /** @@ -28,8 +34,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() }; } /** @@ -68,7 +74,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`, @@ -122,13 +128,14 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ }; } - // Store flow state - const flowKey = `${toolkitSlug}-${Date.now()}`; - activeFlows.set(flowKey, { - toolkitSlug, - connectedAccountId, - authConfigId, - }); + // Abort any existing flow for this toolkit before starting a new one + const existingFlow = activeFlows.get(toolkitSlug); + if (existingFlow) { + console.log(`[Composio] Aborting existing flow for ${toolkitSlug}`); + clearTimeout(existingFlow.timeout); + existingFlow.server.close(); + activeFlows.delete(toolkitSlug); + } // Save initial account state const account: LocalConnectedAccount = { @@ -142,15 +149,27 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ composioAccountsRepo.saveAccount(account); // Set up callback server - let cleanupTimeout: NodeJS.Timeout; - const { server } = await createAuthServer(8081, async (_code, _state) => { + const timeoutRef: { current: NodeJS.Timeout | null } = { current: null }; + let callbackHandled = false; + const { server } = await createAuthServer(8081, async () => { + // 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); composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status); if (accountStatus.status === 'ACTIVE') { + // Invalidate instructions cache so the copilot knows about the new connection + invalidateCopilotInstructionsCache(); emitComposioEvent({ toolkitSlug, success: true }); + if (toolkitSlug === 'gmail') { + triggerGmailSync(); + } + if (toolkitSlug === 'googlecalendar') { + triggerCalendarSync(); + } } else { emitComposioEvent({ toolkitSlug, @@ -166,17 +185,17 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ error: error instanceof Error ? error.message : 'Unknown error', }); } finally { - activeFlows.delete(flowKey); + activeFlows.delete(toolkitSlug); server.close(); - clearTimeout(cleanupTimeout); + if (timeoutRef.current) clearTimeout(timeoutRef.current); } }); // Timeout for abandoned flows (5 minutes) - cleanupTimeout = setTimeout(() => { - if (activeFlows.has(flowKey)) { + const cleanupTimeout = setTimeout(() => { + if (activeFlows.has(toolkitSlug)) { console.log(`[Composio] Cleaning up abandoned flow for ${toolkitSlug}`); - activeFlows.delete(flowKey); + activeFlows.delete(toolkitSlug); server.close(); emitComposioEvent({ toolkitSlug, @@ -185,6 +204,16 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ }); } }, 5 * 60 * 1000); + timeoutRef.current = cleanupTimeout; + + // Store flow state (keyed by toolkit to prevent concurrent flows) + activeFlows.set(toolkitSlug, { + toolkitSlug, + connectedAccountId, + authConfigId, + server, + timeout: cleanupTimeout, + }); // Open browser for OAuth shell.openExternal(redirectUrl); @@ -244,18 +273,16 @@ export async function disconnect(toolkitSlug: string): Promise<{ success: boolea try { const account = composioAccountsRepo.getAccount(toolkitSlug); if (account) { - // Delete from Composio await composioClient.deleteConnectedAccount(account.id); - // Delete local record - composioAccountsRepo.deleteAccount(toolkitSlug); } - return { success: true }; } catch (error) { console.error('[Composio] Disconnect failed:', error); - // Still delete local record even if API call fails + } finally { + // Always clean up local state, even if the API call fails composioAccountsRepo.deleteAccount(toolkitSlug); - return { success: true }; + invalidateCopilotInstructionsCache(); } + return { success: true }; } /** @@ -266,31 +293,38 @@ export function listConnected(): { toolkits: string[] } { } /** - * Execute a Composio action + * Check if Composio should be used for Google services (Gmail, etc.) */ -export async function executeAction( - actionSlug: string, - toolkitSlug: string, - input: Record -): Promise<{ success: boolean; data: unknown; error?: string }> { - try { - const account = composioAccountsRepo.getAccount(toolkitSlug); - if (!account || account.status !== 'ACTIVE') { - return { - success: false, - data: null, - error: `Toolkit ${toolkitSlug} is not connected`, - }; - } - - const result = await composioClient.executeAction(actionSlug, account.id, input); - return result; - } catch (error) { - console.error('[Composio] Action execution failed:', error); - return { - success: false, - data: null, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } +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() }; +} + +/** + * List available Composio toolkits — filtered to curated list only. + * Return type matches the ZToolkit schema from core/composio/types.ts. + */ +export async function listToolkits() { + // Paginate through all API pages to collect every curated toolkit + const allItems: Toolkit[] = []; + let cursor: string | null = null; + const maxPages = 10; // safety limit + for (let page = 0; page < maxPages; page++) { + const result = await composioClient.listToolkits(cursor); + allItems.push(...result.items); + cursor = result.next_cursor; + if (!cursor) break; + } + const filtered = allItems.filter(item => CURATED_TOOLKIT_SLUGS.has(item.slug)); + return { + items: filtered, + nextCursor: null as string | null, + totalItems: filtered.length, + }; } diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 84e2b3e4..e05b57b3 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1,4 +1,4 @@ -import { ipcMain, BrowserWindow, shell, dialog } from 'electron'; +import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer } from 'electron'; import { ipc } from '@x/shared'; import path from 'node:path'; import os from 'node:os'; @@ -39,7 +39,11 @@ import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo. import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; 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 { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js'; +import { getBillingInfo } from '@x/core/dist/billing/billing.js'; +import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; +import { getAccessToken } from '@x/core/dist/auth/tokens.js'; +import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; /** * Convert markdown to a styled HTML document for PDF/DOCX export. @@ -142,10 +146,10 @@ export function registerIpcHandlers(handlers: InvokeHandlers) { ipcMain.handle(channel, async (event, rawArgs) => { // Validate request payload const args = ipc.validateRequest(channel, rawArgs); - + // Call handler const result = await handler(event, args); - + // Validate response payload return ipc.validateResponse(channel, result); }); @@ -469,6 +473,21 @@ export function setupIpcHandlers() { const config = await repo.getClientFacingConfig(); return { config }; }, + 'account:getRowboat': async () => { + const signedIn = await isSignedIn(); + if (!signedIn) { + return { signedIn: false, accessToken: null, config: null }; + } + + const config = await getRowboatConfig(); + + try { + const accessToken = await getAccessToken(); + return { signedIn: true, accessToken, config }; + } catch { + return { signedIn: true, accessToken: null, config }; + } + }, 'granola:getConfig': async () => { const repo = container.resolve('granolaConfigRepo'); const config = await repo.getConfig(); @@ -540,8 +559,15 @@ export function setupIpcHandlers() { 'composio:list-connected': async () => { return composioHandler.listConnected(); }, - 'composio:execute-action': async (_event, args) => { - return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input); + // Composio Tools Library handlers + 'composio:list-toolkits': async () => { + return composioHandler.listToolkits(); + }, + '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 () => { @@ -694,18 +720,44 @@ export function setupIpcHandlers() { return { success: false, error: 'Unknown format' }; }, + 'meeting:checkScreenPermission': async () => { + if (process.platform !== 'darwin') return { granted: true }; + const status = systemPreferences.getMediaAccessStatus('screen'); + console.log('[meeting] Screen recording permission status:', status); + if (status === 'granted') return { granted: true }; + // Not granted — call desktopCapturer.getSources() to register the app + // in the macOS Screen Recording list. On first call this shows the + // native permission prompt (signed apps are remembered across restarts). + try { await desktopCapturer.getSources({ types: ['screen'] }); } catch { /* ignore */ } + // Re-check after the native prompt was dismissed + const statusAfter = systemPreferences.getMediaAccessStatus('screen'); + console.log('[meeting] Screen recording permission status after prompt:', statusAfter); + return { granted: statusAfter === 'granted' }; + }, + 'meeting:openScreenRecordingSettings': async () => { + await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'); + return { success: true }; + }, + 'meeting:summarize': async (_event, args) => { + const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson); + return { notes }; + }, 'inline-task:classifySchedule': async (_event, args) => { const schedule = await classifySchedule(args.instruction); return { schedule }; }, + 'inline-task:process': async (_event, args) => { + return await processRowboatInstruction(args.instruction, args.noteContent, args.notePath); + }, 'voice:getConfig': async () => { return voice.getVoiceConfig(); }, 'voice:synthesize': async (_event, args) => { return voice.synthesizeSpeech(args.text); }, - '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..a2ed0aa1 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, protocol, net, shell, session } from "electron"; +import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session } from "electron"; import path from "node:path"; import { setupIpcHandlers, @@ -21,8 +21,14 @@ import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.j import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js"; import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; +import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import started from "electron-squirrel-startup"; +import { execSync, exec } from "node:child_process"; +import { promisify } from "node:util"; +import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; + +const execAsync = promisify(exec); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -30,6 +36,28 @@ const __dirname = dirname(__filename); // run this as early in the main process as possible if (started) app.quit(); +// Fix PATH for packaged Electron apps on macOS/Linux. +// Packaged apps inherit a minimal environment that doesn't include paths from +// the user's shell profile (nvm, Homebrew, etc.). Spawn the user's login shell +// to resolve the full PATH, using delimiters to safely extract it from any +// surrounding shell output (motd, greeting messages, etc.). +if (process.platform !== 'win32') { + try { + const userShell = process.env.SHELL || '/bin/zsh'; + const delimiter = '__ROWBOAT_PATH__'; + const output = execSync( + `${userShell} -lc 'echo -n "${delimiter}$PATH${delimiter}"'`, + { encoding: 'utf-8', timeout: 5000 }, + ); + const match = output.match(new RegExp(`${delimiter}(.+?)${delimiter}`)); + if (match?.[1]) { + process.env.PATH = match[1]; + } + } catch { + // Silently fall back to the existing PATH if shell resolution fails + } +} + // Path resolution differs between development and production: const preloadPath = app.isPackaged ? path.join(__dirname, "../preload/dist/preload.js") @@ -92,17 +120,30 @@ function createWindow() { }, }); - // Grant microphone permission for voice mode + // Grant microphone and display-capture permissions session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { - if (permission === 'media') { + if (permission === 'media' || permission === 'display-capture') { callback(true); } else { callback(false); } }); + // Auto-approve display media requests and route system audio as loopback. + // Electron requires a video source in the callback even if we only want audio. + // We pass the first available screen source; the renderer discards the video track. + session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => { + const sources = await desktopCapturer.getSources({ types: ['screen'] }); + if (sources.length === 0) { + callback({}); + return; + } + callback({ video: sources[0], audio: 'loopback' }); + }); + // Show window when content is ready to prevent blank screen win.once("ready-to-show", () => { + win.maximize(); win.show(); }); @@ -147,6 +188,19 @@ app.whenReady().then(async () => { }); } + // Ensure agent-slack CLI is available + try { + execSync('agent-slack --version', { stdio: 'ignore', timeout: 5000 }); + } catch { + try { + console.log('agent-slack not found, installing...'); + await execAsync('npm install -g agent-slack', { timeout: 60000 }); + console.log('agent-slack installed successfully'); + } catch (e) { + console.error('Failed to install agent-slack:', e); + } + } + // Initialize all config files before UI can access them await initConfigs(); @@ -194,6 +248,12 @@ app.whenReady().then(async () => { // start background agent runner (scheduled agents) initAgentRunner(); + // start agent notes learning service + initAgentNotes(); + + // start chrome extension sync server + initChromeSync(); + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 2efc77c2..dde2246d 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -75,7 +75,7 @@ function getClientRegistrationRepo(): IClientRegistrationRepo { * Get or create OAuth configuration for a provider */ async function getProviderConfiguration(provider: string, clientIdOverride?: string): Promise { - const config = getProviderConfig(provider); + const config = await getProviderConfig(provider); const resolveClientId = async (): Promise => { if (config.client.mode === 'static' && config.client.clientId) { return config.client.clientId; @@ -156,7 +156,7 @@ export async function connectProvider(provider: string, clientId?: string): Prom cancelActiveFlow('new_flow_started'); const oauthRepo = getOAuthRepo(); - const providerConfig = getProviderConfig(provider); + const providerConfig = await getProviderConfig(provider); if (provider === 'google') { if (!clientId) { @@ -186,9 +186,13 @@ export async function connectProvider(provider: string, clientId?: string): Prom }); // Create callback server - const { server } = await createAuthServer(8080, async (code, receivedState) => { + let callbackHandled = false; + const { server } = await createAuthServer(8080, async (params: Record) => { + // Guard against duplicate callbacks (browser may send multiple requests) + if (callbackHandled) return; + callbackHandled = true; // Validate state - if (receivedState !== state) { + if (params.state !== state) { throw new Error('Invalid state parameter - possible CSRF attack'); } @@ -199,7 +203,7 @@ export async function connectProvider(provider: string, clientId?: string): Prom try { // Build callback URL for token exchange - const callbackUrl = new URL(`${REDIRECT_URI}?code=${code}&state=${receivedState}`); + const callbackUrl = new URL(`${REDIRECT_URI}?${new URLSearchParams(params).toString()}`); // Exchange code for tokens console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); @@ -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/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/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 45044f5f..9107189a 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon, RadioIcon, SquareIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -20,7 +20,7 @@ import { Conversation, ConversationContent, ConversationEmptyState, - ScrollPositionPreserver, + ConversationScrollButton, } from '@/components/ai-elements/conversation'; import { Message, @@ -33,9 +33,11 @@ import { } from '@/components/ai-elements/prompt-input'; import { Shimmer } from '@/components/ai-elements/shimmer'; -import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'; +import { useSmoothedText } from './hooks/useSmoothedText'; +import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'; import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; +import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; import { Suggestions } from '@/components/ai-elements/suggestions'; @@ -46,10 +48,12 @@ import { useSidebar, } from "@/components/ui/sidebar" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" 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' @@ -58,12 +62,15 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar' import { type ChatMessage, + type ChatViewportAnchorState, type ChatTabViewState, type ConversationItem, type ToolCall, createEmptyChatTabViewState, getWebSearchCardData, getAppActionCardData, + getComposioConnectCardData, + getToolDisplayName, inferRunTitleFromMessage, isChatMessage, isErrorMessage, @@ -73,11 +80,15 @@ import { parseAttachedFiles, toToolState, } from '@/lib/chat-conversation' +import { COMPOSIO_DISPLAY_NAMES as composioDisplayNames } from '@x/shared/src/composio.js' import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js' import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' import { toast } from "sonner" import { useVoiceMode } from '@/hooks/useVoiceMode' import { useVoiceTTS } from '@/hooks/useVoiceTTS' +import { useMeetingTranscription, type MeetingTranscriptionState, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' +import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity' +import * as analytics from '@/lib/analytics' type DirEntry = z.infer type RunEventType = z.infer @@ -90,6 +101,11 @@ interface TreeNode extends DirEntry { const streamdownComponents = { pre: MarkdownPreOverride } +function SmoothStreamingMessage({ text, components }: { text: string; components: typeof streamdownComponents }) { + const smoothText = useSmoothedText(text) + return {smoothText} +} + const DEFAULT_SIDEBAR_WIDTH = 256 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ @@ -258,10 +274,49 @@ const normalizeUsage = (usage?: Partial | null): LanguageMod } } -// Sort nodes (dirs first, then alphabetically) +// Sidebar folder ordering — listed folders appear in this order, unlisted ones follow alphabetically +const FOLDER_ORDER = ['People', 'Organizations', 'Projects', 'Topics', 'Meetings', 'Agent Notes', 'Notes'] + +/** + * Per-folder base view config: which columns to show and default sort. + * Folders not listed here fall back to DEFAULT_BASE_CONFIG. + */ +const FOLDER_BASE_CONFIGS: Record = { + 'Agent Notes': { + visibleColumns: ['name', 'folder', 'mtimeMs'], + sort: { field: 'mtimeMs', dir: 'desc' }, + }, + People: { + visibleColumns: ['name', 'relationship', 'organization', 'mtimeMs'], + sort: { field: 'name', dir: 'asc' }, + }, + Organizations: { + visibleColumns: ['name', 'relationship', 'mtimeMs'], + sort: { field: 'name', dir: 'asc' }, + }, + Projects: { + visibleColumns: ['name', 'status', 'topic', 'mtimeMs'], + sort: { field: 'name', dir: 'asc' }, + }, + Topics: { + visibleColumns: ['name', 'mtimeMs'], + sort: { field: 'name', dir: 'asc' }, + }, + Meetings: { + visibleColumns: ['name', 'topic', 'mtimeMs'], + sort: { field: 'mtimeMs', dir: 'desc' }, + }, +} + +// Sort nodes (dirs first, ordered folders by FOLDER_ORDER, then alphabetically) function sortNodes(nodes: TreeNode[]): TreeNode[] { return nodes.sort((a, b) => { if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1 + const aOrder = FOLDER_ORDER.indexOf(a.name) + const bOrder = FOLDER_ORDER.indexOf(b.name) + if (aOrder !== -1 && bOrder !== -1) return aOrder - bOrder + if (aOrder !== -1) return -1 + if (bOrder !== -1) return 1 return a.name.localeCompare(b.name) }).map(node => { if (node.children) { @@ -271,6 +326,73 @@ function sortNodes(nodes: TreeNode[]): TreeNode[] { }) } +/** + * Organize Meetings/ source folders into date-grouped subfolders. + * + * - rowboat: rowboat/2026-03-20/meeting-xxx.md → keeps date folders as-is + * - granola: granola/2026/03/18/Title.md → collapses into "2026-03-18" folders + * - Files directly under a source folder (no date subfolder) are grouped + * by the date prefix in their filename (e.g. meeting-2026-03-17T...). + */ +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 with their date group label + const dateGroups = new Map() + + function collectFiles(n: TreeNode, dateParts: string[]) { + for (const child of n.children ?? []) { + if (child.kind === 'file') { + const dateStr = dateParts.join('-') + // If file is at root of source folder, try to extract date from filename + const groupKey = dateStr || extractDateFromFilename(child.name) || 'other' + const group = dateGroups.get(groupKey) ?? [] + group.push(child) + dateGroups.set(groupKey, group) + } else if (child.kind === 'dir') { + collectFiles(child, [...dateParts, child.name]) + } + } + } + collectFiles(sourceNode, []) + + if (dateGroups.size === 0) return [] + + // Build date folder nodes, sorted reverse chronologically + const dateFolderNodes: TreeNode[] = [...dateGroups.entries()] + .sort(([a], [b]) => b.localeCompare(a)) + .map(([dateKey, files]) => { + // Sort files within each date group reverse chronologically + files.sort((a, b) => b.name.localeCompare(a.name)) + return { + name: dateKey, + path: `${sourceNode.path}/${dateKey}`, + kind: 'dir' as const, + children: files, + loaded: true, + } + }) + + return [{ ...sourceNode, children: dateFolderNodes }] + }) + + // Hide Meetings folder entirely if no source folders have files + if (flattenedSourceChildren.length === 0) return [] + + return [{ ...node, children: flattenedSourceChildren }] + }) +} + +/** Extract YYYY-MM-DD from filenames like "meeting-2026-03-17T05-01-47.md" */ +function extractDateFromFilename(name: string): string | null { + const match = name.match(/(\d{4}-\d{2}-\d{2})/) + return match ? match[1] : null +} + // Build tree structure from flat entries function buildTree(entries: DirEntry[]): TreeNode[] { const treeMap = new Map() @@ -332,6 +454,10 @@ function FixedSidebarToggle({ canNavigateForward, onNewChat, onOpenSearch, + meetingState, + meetingSummarizing, + meetingAvailable, + onToggleMeeting, leftInsetPx, }: { onNavigateBack: () => void @@ -340,6 +466,10 @@ function FixedSidebarToggle({ canNavigateForward: boolean onNewChat: () => void onOpenSearch: () => void + meetingState: MeetingTranscriptionState + meetingSummarizing: boolean + meetingAvailable: boolean + onToggleMeeting: () => void leftInsetPx: number }) { const { toggleSidebar, state } = useSidebar() @@ -375,6 +505,37 @@ function FixedSidebarToggle({ > + {meetingAvailable && ( + + + + + + {meetingSummarizing ? 'Generating meeting notes...' : meetingState === 'connecting' ? 'Starting transcription...' : meetingState === 'recording' ? 'Stop meeting notes' : 'Take new meeting notes'} + + + )} {/* Back / Forward navigation */} {isCollapsed && ( <> @@ -465,6 +626,8 @@ function App() { type ShortcutPane = 'left' | 'right' type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean } + useAnalyticsIdentity() + // File browser state (for Knowledge section) const [selectedPath, setSelectedPath] = useState(null) const [fileContent, setFileContent] = useState('') @@ -568,20 +731,38 @@ function App() { const voiceRef = useRef(voice) voiceRef.current = voice - // Check if voice is available on mount - useEffect(() => { + const handleToggleMeetingRef = useRef<(() => void) | undefined>(undefined) + const meetingTranscription = useMeetingTranscription(() => { + handleToggleMeetingRef.current?.() + }) + + // 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), ]).then(([config, oauthState]) => { const rowboatConnected = oauthState.config?.rowboat?.connected ?? false - setVoiceAvailable(!!config.deepgram || rowboatConnected) + const hasVoice = !!config.deepgram || rowboatConnected + setVoiceAvailable(hasVoice) setTtsAvailable(!!config.elevenlabs || rowboatConnected) + // Pre-cache auth details so mic click skips IPC round-trips + if (hasVoice) { + voice.warmup() + } }).catch(() => { setVoiceAvailable(false) setTtsAvailable(false) }) - }, []) + }, [voice.warmup]) + + useEffect(() => { + refreshVoiceAvailability() + const cleanup = window.ipc.on('oauth:didConnect', () => { + refreshVoiceAvailability() + }) + return cleanup + }, [refreshVoiceAvailability]) const handleStartRecording = useCallback(() => { setIsRecording(true) @@ -589,7 +770,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(() => { @@ -598,7 +779,7 @@ function App() { isRecordingRef.current = false if (text) { pendingVoiceInputRef.current = true - handlePromptSubmitRef.current?.({ text }) + handlePromptSubmitRef.current?.({ text, files: [] }) } }, [voice]) @@ -624,6 +805,22 @@ function App() { isRecordingRef.current = false }, [voice]) + // Enter to submit voice input, Escape to cancel + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isRecordingRef.current) return + if (e.key === 'Enter') { + e.preventDefault() + handleSubmitRecording() + } else if (e.key === 'Escape') { + e.preventDefault() + handleCancelRecording() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [handleSubmitRecording, handleCancelRecording]) + // Helper to cancel recording from any navigation handler const cancelRecordingIfActive = useCallback(() => { if (isRecordingRef.current) { @@ -649,6 +846,7 @@ function App() { const chatDraftsRef = useRef(new Map()) const chatScrollTopByTabRef = useRef(new Map()) const [toolOpenByTab, setToolOpenByTab] = useState>>({}) + const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState>({}) const activeChatTabIdRef = useRef(activeChatTabId) activeChatTabIdRef.current = activeChatTabId const setChatDraftForTab = useCallback((tabId: string, text: string) => { @@ -674,6 +872,18 @@ function App() { } }) }, []) + const setChatViewportAnchor = useCallback((tabId: string, messageId: string | null) => { + setChatViewportAnchorByTab((prev) => { + const prevForTab = prev[tabId] + return { + ...prev, + [tabId]: { + messageId, + requestKey: (prevForTab?.requestKey ?? 0) + 1, + }, + } + }) + }, []) const getChatScrollContainer = useCallback((tabId: string): HTMLElement | null => { if (typeof document === 'undefined') return null const panel = document.querySelector( @@ -775,6 +985,22 @@ function App() { }) }, [chatTabs]) + useEffect(() => { + const tabIds = new Set(chatTabs.map((tab) => tab.id)) + setChatViewportAnchorByTab((prev) => { + let changed = false + const next: Record = {} + for (const [tabId, state] of Object.entries(prev)) { + if (tabIds.has(tabId)) { + next[tabId] = state + } else { + changed = true + } + } + return changed ? next : prev + }) + }, [chatTabs]) + // Workspace root for full paths const [workspaceRoot, setWorkspaceRoot] = useState('') @@ -928,7 +1154,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 })) @@ -948,10 +1174,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 @@ -1673,7 +1901,7 @@ function App() { const inferredTitle = inferRunTitleFromMessage(msg.content) if (inferredTitle) { setRuns(prev => prev.map(run => ( - run.id === event.runId && run.title !== inferredTitle + run.id === event.runId && !run.title ? { ...run, title: inferredTitle } : run ))) @@ -1901,6 +2129,7 @@ function App() { ) => { if (isProcessing) return + const submitTabId = activeChatTabIdRef.current const { text } = message const userMessage = text.trim() const hasAttachments = stagedAttachments.length > 0 @@ -1925,6 +2154,7 @@ function App() { attachments: displayAttachments, timestamp: Date.now(), }]) + setChatViewportAnchor(submitTabId, userMessageId) try { let currentRunId = runId @@ -1937,9 +2167,10 @@ function App() { currentRunId = run.id newRunCreatedAt = run.createdAt setRunId(currentRunId) + analytics.chatSessionCreated(currentRunId) // Update active chat tab's runId to the new run setChatTabs((prev) => prev.map((tab) => ( - tab.id === activeChatTabId + tab.id === submitTabId ? { ...tab, runId: currentRunId } : tab ))) @@ -1997,6 +2228,11 @@ function App() { voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, }) + analytics.chatMessageSent({ + voiceInput: pendingVoiceInputRef.current || undefined, + voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, + searchEnabled: searchEnabled || undefined, + }) } else { // Legacy path: plain string with optional XML-formatted @mentions. let formattedMessage = userMessage @@ -2028,6 +2264,11 @@ function App() { voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, }) + analytics.chatMessageSent({ + voiceInput: pendingVoiceInputRef.current || undefined, + voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, + searchEnabled: searchEnabled || undefined, + }) titleSource = formattedMessage } @@ -2052,6 +2293,12 @@ function App() { } handlePromptSubmitRef.current = handlePromptSubmit + const handleComposioConnected = useCallback((toolkitSlug: string) => { + // Auto-send a continuation message when a Composio toolkit connects + const name = composioDisplayNames[toolkitSlug] || toolkitSlug + handlePromptSubmitRef.current?.({ text: `${name} connected successfully.`, files: [] }) + }, []) + const handleStop = useCallback(async () => { if (!runId) return const now = Date.now() @@ -2129,11 +2376,12 @@ function App() { setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) setSelectedBackgroundTask(null) + setChatViewportAnchor(activeChatTabIdRef.current, null) setChatViewStateByTab(prev => ({ ...prev, [activeChatTabIdRef.current]: createEmptyChatTabViewState(), })) - }, []) + }, [setChatViewportAnchor]) // Chat tab operations const applyChatTab = useCallback((tab: ChatTab) => { @@ -2151,8 +2399,9 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setChatViewportAnchor(tab.id, null) } - }, [loadRun]) + }, [loadRun, setChatViewportAnchor]) const restoreChatTabState = useCallback((tabId: string, fallbackRunId: string | null): boolean => { const cached = chatViewStateByTabRef.current[tabId] @@ -2992,6 +3241,31 @@ function App() { return } + // Top-level knowledge folders (except Notes) open as a bases view with folder filter + const parts = path.split('/') + if (parts.length === 2 && parts[0] === 'knowledge' && parts[1] !== 'Notes') { + const folderName = parts[1] + const folderCfg = FOLDER_BASE_CONFIGS[folderName] + setBaseConfigByPath((prev) => ({ + ...prev, + [BASES_DEFAULT_TAB_PATH]: { + ...DEFAULT_BASE_CONFIG, + name: folderName, + filters: [{ category: 'folder', value: folderName }], + ...(folderCfg && { + visibleColumns: folderCfg.visibleColumns, + sort: folderCfg.sort, + }), + }, + })) + if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) { + setIsChatSidebarOpen(false) + setIsRightPaneMaximized(false) + } + void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) + return + } + const newExpanded = new Set(expandedPaths) if (newExpanded.has(path)) { newExpanded.delete(path) @@ -3070,7 +3344,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 @@ -3093,7 +3367,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()}`, @@ -3195,7 +3469,14 @@ function App() { }, copyPath: (path: string) => { const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path - navigator.clipboard.writeText(fullPath) + navigator.clipboard.writeText(fullPath).catch(() => { + const textarea = document.createElement('textarea') + textarea.value = fullPath + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + }) }, onOpenInNewTab: (path: string) => { openFileInNewTab(path) @@ -3220,9 +3501,171 @@ 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 meetingNotePathRef = useRef(null) + const pendingCalendarEventRef = useRef(undefined) + const [meetingSummarizing, setMeetingSummarizing] = useState(false) + const [showMeetingPermissions, setShowMeetingPermissions] = useState(false) + + const [checkingPermission, setCheckingPermission] = useState(false) + + const startMeetingNow = useCallback(async () => { + const calEvent = pendingCalendarEventRef.current + pendingCalendarEventRef.current = undefined + const notePath = await meetingTranscription.start(calEvent) + if (notePath) { + meetingNotePathRef.current = notePath + await handleVoiceNoteCreated(notePath) + } + }, [meetingTranscription, handleVoiceNoteCreated]) + + const handleCheckPermissionAndRetry = useCallback(async () => { + setCheckingPermission(true) + try { + const { granted } = await window.ipc.invoke('meeting:checkScreenPermission', null) + if (granted) { + setShowMeetingPermissions(false) + await startMeetingNow() + } + } finally { + setCheckingPermission(false) + } + }, [startMeetingNow]) + + const handleOpenScreenRecordingSettings = useCallback(async () => { + await window.ipc.invoke('meeting:openScreenRecordingSettings', null) + }, []) + + const handleToggleMeeting = useCallback(async () => { + if (meetingTranscription.state === 'recording') { + await meetingTranscription.stop() + + // Read the final transcript and generate meeting notes via LLM + const notePath = meetingNotePathRef.current + if (notePath) { + setMeetingSummarizing(true) + try { + const result = await window.ipc.invoke('workspace:readFile', { path: notePath, encoding: 'utf8' }) + const fileContent = result.data + if (fileContent && fileContent.trim()) { + // Extract meeting start time and calendar event from frontmatter + const dateMatch = fileContent.match(/^date:\s*"(.+)"$/m) + const meetingStartTime = dateMatch?.[1] + // If a calendar event was linked, pass it directly so the summarizer + // skips scanning and uses this event for attendee/title info. + const calEventMatch = fileContent.match(/^calendar_event:\s*'(.+)'$/m) + const calendarEventJson = calEventMatch?.[1]?.replace(/''/g, "'") + const { notes } = await window.ipc.invoke('meeting:summarize', { transcript: fileContent, meetingStartTime, calendarEventJson }) + if (notes) { + // Prepend meeting notes above the existing transcript block + const { raw: fm, body } = splitFrontmatter(fileContent) + const fmTitleMatch = fileContent.match(/^title:\s*(.+)$/m) + const noteTitle = fmTitleMatch?.[1]?.trim() || 'Meeting Notes' + const cleanedNotes = notes.replace(/^#{1,2}\s+.+\n+/, '') + // Extract the existing transcript block and preserve it as-is + const transcriptBlockMatch = body.match(/(```transcript\n[\s\S]*?\n```)/) + const transcriptBlock = transcriptBlockMatch?.[1] || '' + const newBody = `# ${noteTitle}\n\n` + cleanedNotes + (transcriptBlock ? '\n\n' + transcriptBlock : '') + const newContent = fm ? `${fm}\n${newBody}` : newBody + await window.ipc.invoke('workspace:writeFile', { + path: notePath, + data: newContent, + opts: { encoding: 'utf8' }, + }) + // Refresh the file view + await handleVoiceNoteCreated(notePath) + } + } + } catch (err) { + console.error('[meeting] Failed to generate meeting notes:', err) + } + setMeetingSummarizing(false) + meetingNotePathRef.current = null + } + } else if (meetingTranscription.state === 'idle') { + // On macOS, check screen recording permission before starting + if (isMac) { + const result = await window.ipc.invoke('meeting:checkScreenPermission', null) + console.log('[meeting] Permission check result:', result) + if (!result.granted) { + setShowMeetingPermissions(true) + return + } + } + await startMeetingNow() + } + }, [meetingTranscription, handleVoiceNoteCreated, startMeetingNow]) + handleToggleMeetingRef.current = handleToggleMeeting + + // Listen for calendar block "join meeting & take notes" events + useEffect(() => { + const handler = () => { + // Read calendar event data set by the calendar block on window + const pending = window.__pendingCalendarEvent + window.__pendingCalendarEvent = undefined + if (pending) { + pendingCalendarEventRef.current = { + summary: pending.summary, + start: pending.start, + end: pending.end, + location: pending.location, + htmlLink: pending.htmlLink, + conferenceLink: pending.conferenceLink, + source: pending.source, + } + } + // Use the same toggle flow — it will pick up pendingCalendarEventRef + handleToggleMeetingRef.current?.() + } + window.addEventListener('calendar-block:join-meeting', handler) + return () => window.removeEventListener('calendar-block:join-meeting', handler) + }, []) + + // Email block: draft with assistant + useEffect(() => { + const handler = () => { + const pending = window.__pendingEmailDraft + if (pending) { + setPresetMessage(pending.prompt) + setIsChatSidebarOpen(true) + window.__pendingEmailDraft = undefined + } + } + window.addEventListener('email-block:draft-with-assistant', handler) + return () => window.removeEventListener('email-block:draft-with-assistant', handler) + }, []) const ensureWikiFile = useCallback(async (wikiPath: string) => { const resolvedPath = toKnowledgePath(wikiPath) @@ -3276,12 +3719,17 @@ function App() { return } - const nodeSet = new Set(knowledgeFilePaths) + const graphFilePaths = knowledgeFilePaths.filter((p) => { + const normalized = stripKnowledgePrefix(p) + return !normalized.toLowerCase().startsWith('meetings/') + }) + + const nodeSet = new Set(graphFilePaths) const edges: GraphEdge[] = [] const edgeKeys = new Set() const contents = await Promise.all( - knowledgeFilePaths.map(async (path) => { + graphFilePaths.map(async (path) => { try { const result = await window.ipc.invoke('workspace:readFile', { path }) return { path, data: result.data as string } @@ -3340,7 +3788,7 @@ function App() { } } - const nodes = knowledgeFilePaths.map((path) => { + const nodes = graphFilePaths.map((path) => { const degree = degreeMap.get(path) ?? 0 const radius = 6 + Math.min(18, degree * 2) const { group, depth } = getNodeGroup(path) @@ -3380,7 +3828,7 @@ function App() { if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { return ( - + @@ -3392,7 +3840,7 @@ function App() { } const { message, files } = parseAttachedFiles(item.content) return ( - + {files.length > 0 && (
@@ -3412,7 +3860,7 @@ function App() { ) } return ( - + {item.content} @@ -3437,6 +3885,22 @@ function App() { /> ) } + const composioConnectData = getComposioConnectCardData(item) + if (composioConnectData) { + // Skip rendering if this is a duplicate "already connected" card + if (composioConnectData.hidden) return null + return ( + + ) + } + const toolTitle = getToolDisplayName(item) const errorText = item.status === 'error' ? 'Tool error' : '' const output = normalizeToolOutput(item.result, item.status) const input = normalizeToolInput(item.input) @@ -3447,15 +3911,12 @@ function App() { onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)} > - - {output !== null ? ( - - ) : null} + ) @@ -3463,7 +3924,7 @@ function App() { if (isErrorMessage(item)) { return ( - +
{item.message}
@@ -3514,7 +3975,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 */} void handleBaseSave(name)} externalSearch={externalBaseSearch} onExternalSearchConsumed={() => setExternalBaseSearch(undefined)} + actions={{ + rename: knowledgeActions.rename, + remove: knowledgeActions.remove, + copyPath: knowledgeActions.copyPath, + }} />
) : isGraphOpen ? ( @@ -3743,7 +4213,7 @@ function App() { { navigateToFile(path) @@ -3775,6 +4245,7 @@ function App() { > { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }} onPrimaryHeadingCommit={() => { untitledRenameReadyPathsRef.current.add(tab.path) @@ -3811,6 +4282,7 @@ function App() { const title = getBaseName(tab.path) try { await window.ipc.invoke('export:note', { markdown, format, title }) + analytics.noteExported(format) } catch (err) { console.error('Export failed:', err) } @@ -3899,8 +4371,11 @@ function App() { data-chat-tab-panel={tab.id} aria-hidden={!isActive} > - - + {!tabHasConversation ? ( @@ -3947,7 +4422,7 @@ function App() { {tabState.currentAssistantMessage && ( - {tabState.currentAssistantMessage.replace(/<\/?voice>/g, '')} + /g, '')} components={streamdownComponents} /> )} @@ -3962,6 +4437,7 @@ function App() { )} +
) @@ -4038,6 +4514,7 @@ function App() { conversation={conversation} currentAssistantMessage={currentAssistantMessage} chatTabStates={chatViewStateByTab} + viewportAnchors={chatViewportAnchorByTab} isProcessing={isProcessing} isStopping={isStopping} onStop={handleStop} @@ -4071,6 +4548,7 @@ function App() { ttsMode={ttsMode} onToggleTts={handleToggleTts} onTtsModeChange={handleTtsModeChange} + onComposioConnected={handleComposioConnected} /> )} {/* Rendered last so its no-drag region paints over the sidebar drag region */} @@ -4081,6 +4559,10 @@ function App() { canNavigateForward={canNavigateForward} onNewChat={handleNewChatTab} onOpenSearch={() => setIsSearchOpen(true)} + meetingState={meetingTranscription.state} + meetingSummarizing={meetingSummarizing} + meetingAvailable={voiceAvailable} + onToggleMeeting={() => { void handleToggleMeeting() }} leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0} /> @@ -4097,6 +4579,31 @@ function App() { open={showOnboarding} onComplete={handleOnboardingComplete} /> + + + + Screen recording permission required + + Rowboat needs Screen Recording permission to capture meeting audio from other apps (Zoom, Meet, etc.). This feature won't work without it. + + +
+

To enable this:

+
    +
  1. Open System SettingsPrivacy & SecurityScreen Recording
  2. +
  3. Toggle on Rowboat
  4. +
  5. You may need to restart the app after granting permission
  6. +
+
+ + + + + +
+
) } diff --git a/apps/x/apps/renderer/src/components/ai-elements/composio-connect-card.tsx b/apps/x/apps/renderer/src/components/ai-elements/composio-connect-card.tsx new file mode 100644 index 00000000..731eeb09 --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/composio-connect-card.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + CheckCircleIcon, + Link2Icon, + LoaderIcon, + XCircleIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface ComposioConnectCardProps { + toolkitSlug: string; + toolkitDisplayName: string; + status: "pending" | "running" | "completed" | "error"; + alreadyConnected?: boolean; + onConnected?: (toolkitSlug: string) => void; +} + +export function ComposioConnectCard({ + toolkitSlug, + toolkitDisplayName, + status, + alreadyConnected, + onConnected, +}: ComposioConnectCardProps) { + const [connectionState, setConnectionState] = useState< + "idle" | "connecting" | "connected" | "error" + >(alreadyConnected ? "connected" : "idle"); + const [errorMessage, setErrorMessage] = useState(null); + const didFireCallback = useRef(alreadyConnected ?? false); + + // Listen for composio:didConnect events + useEffect(() => { + const cleanup = window.ipc.on( + "composio:didConnect", + (event: { toolkitSlug: string; success: boolean; error?: string }) => { + if (event.toolkitSlug !== toolkitSlug) return; + if (event.success) { + setConnectionState("connected"); + setErrorMessage(null); + if (!didFireCallback.current) { + didFireCallback.current = true; + onConnected?.(toolkitSlug); + } + } else { + setConnectionState("error"); + setErrorMessage(event.error || "Connection failed"); + } + } + ); + return cleanup; + }, [toolkitSlug, onConnected]); + + const handleConnect = useCallback(async () => { + setConnectionState("connecting"); + setErrorMessage(null); + try { + const result = await window.ipc.invoke("composio:initiate-connection", { + toolkitSlug, + }); + if (!result.success) { + setConnectionState("error"); + setErrorMessage(result.error || "Failed to initiate connection"); + } + } catch { + setConnectionState("error"); + setErrorMessage("Failed to initiate connection"); + } + }, [toolkitSlug]); + + const isToolRunning = status === "pending" || status === "running"; + const displayName = toolkitDisplayName || toolkitSlug; + + return ( +
+ {/* Toolkit initial */} +
+ + {displayName.charAt(0).toUpperCase()} + +
+ + {/* Name & status */} +
+
+ {displayName} + {connectionState === "connected" && ( + + Connected + + )} +
+ {connectionState === "error" && errorMessage && ( +

{errorMessage}

+ )} + {connectionState === "idle" && isToolRunning && ( +

Waiting to connect...

+ )} +
+ + {/* Action area */} + {connectionState === "connected" ? ( + + ) : connectionState === "connecting" ? ( + + ) : connectionState === "error" ? ( +
+ + +
+ ) : isToolRunning ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx index f1d514da..7a3f8836 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx @@ -3,163 +3,254 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { ArrowDownIcon } from "lucide-react"; -import type { ComponentProps, ReactNode } from "react"; -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; +import type { ComponentProps, ReactNode, RefObject } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; -// Context to share scroll preservation state -interface ScrollPreservationContextValue { - registerScrollContainer: (container: HTMLElement | null) => void; - markUserEngaged: () => void; - resetEngagement: () => void; +const BOTTOM_THRESHOLD_PX = 8; +const MAX_ANCHOR_RETRIES = 6; + +interface ConversationContextValue { + contentRef: RefObject; + isAtBottom: boolean; + scrollRef: RefObject; + scrollToBottom: () => void; } -const ScrollPreservationContext = createContext(null); +const ConversationContext = createContext(null); -export type ConversationProps = ComponentProps & { +export type ConversationProps = ComponentProps<"div"> & { + anchorMessageId?: string | null; + anchorRequestKey?: number; children?: ReactNode; }; -export const Conversation = ({ className, children, ...props }: ConversationProps) => { - const [scrollContainer, setScrollContainer] = useState(null); - const isUserEngagedRef = useRef(false); - const savedScrollTopRef = useRef(0); - const lastScrollHeightRef = useRef(0); +export const Conversation = ({ + anchorMessageId = null, + anchorRequestKey, + children, + className, + ...props +}: ConversationProps) => { + const contentRef = useRef(null); + const scrollRef = useRef(null); + const spacerRef = useRef(null); + const [isAtBottom, setIsAtBottom] = useState(true); - const contextValue: ScrollPreservationContextValue = { - registerScrollContainer: (container) => { - setScrollContainer(container); - }, - markUserEngaged: () => { - // Only save position on first engagement, not on repeated calls - if (!isUserEngagedRef.current && scrollContainer) { - savedScrollTopRef.current = scrollContainer.scrollTop; - lastScrollHeightRef.current = scrollContainer.scrollHeight; + const updateBottomState = useCallback(() => { + const container = scrollRef.current; + if (!container) return; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + setIsAtBottom(distanceFromBottom <= BOTTOM_THRESHOLD_PX); + }, []); + + const applyAnchorLayout = useCallback( + (scrollToAnchor: boolean): boolean => { + const container = scrollRef.current; + const content = contentRef.current; + const spacer = spacerRef.current; + + if (!container || !content || !spacer) { + return false; } - isUserEngagedRef.current = true; - }, - resetEngagement: () => { - isUserEngagedRef.current = false; - }, - }; - // Watch for content changes and restore scroll position if user was engaged + if (!anchorMessageId) { + spacer.style.height = "0px"; + updateBottomState(); + return true; + } + + const anchor = content.querySelector( + `[data-message-id="${anchorMessageId}"]` + ); + + if (!anchor) { + spacer.style.height = "0px"; + updateBottomState(); + return false; + } + + spacer.style.height = "0px"; + + const contentPaddingTop = Number.parseFloat( + window.getComputedStyle(content).paddingTop || "0" + ); + const anchorTop = anchor.offsetTop; + const targetScrollTop = Math.max(0, anchorTop - contentPaddingTop); + const requiredSlack = Math.max( + 0, + targetScrollTop - (content.scrollHeight - container.clientHeight) + ); + + spacer.style.height = `${Math.ceil(requiredSlack)}px`; + + if (scrollToAnchor) { + container.scrollTop = targetScrollTop; + } + + updateBottomState(); + return true; + }, + [anchorMessageId, updateBottomState] + ); + useEffect(() => { - if (!scrollContainer) return; + const container = scrollRef.current; + if (!container) return; + + const handleScroll = () => { + updateBottomState(); + }; + + handleScroll(); + container.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + container.removeEventListener("scroll", handleScroll); + }; + }, [updateBottomState]); + + useLayoutEffect(() => { + const container = scrollRef.current; + const content = contentRef.current; + if (!container || !content) return; let rafId: number | null = null; - const checkAndRestoreScroll = () => { - if (!isUserEngagedRef.current) return; - - const currentScrollTop = scrollContainer.scrollTop; - const currentScrollHeight = scrollContainer.scrollHeight; - const savedScrollTop = savedScrollTopRef.current; - - // If scroll position jumped significantly (auto-scroll happened) - // and scroll height also changed (content changed), restore position - if ( - Math.abs(currentScrollTop - savedScrollTop) > 50 && - currentScrollHeight !== lastScrollHeightRef.current - ) { - scrollContainer.scrollTop = savedScrollTop; + const schedule = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); } - - lastScrollHeightRef.current = currentScrollHeight; + rafId = requestAnimationFrame(() => { + applyAnchorLayout(false); + }); }; - // Use ResizeObserver to detect content changes - const resizeObserver = new ResizeObserver(() => { - if (rafId) cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(checkAndRestoreScroll); - }); - - resizeObserver.observe(scrollContainer); + const observer = new ResizeObserver(schedule); + observer.observe(container); + observer.observe(content); + schedule(); return () => { - resizeObserver.disconnect(); - if (rafId) cancelAnimationFrame(rafId); + observer.disconnect(); + if (rafId !== null) { + cancelAnimationFrame(rafId); + } }; - }, [scrollContainer]); + }, [applyAnchorLayout]); + + useLayoutEffect(() => { + if (anchorRequestKey === undefined) return; + + let attempts = 0; + let rafId: number | null = null; + + const tryAnchor = () => { + if (applyAnchorLayout(true)) { + return; + } + if (attempts >= MAX_ANCHOR_RETRIES) { + return; + } + attempts += 1; + rafId = requestAnimationFrame(tryAnchor); + }; + + tryAnchor(); + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + }; + }, [anchorRequestKey, applyAnchorLayout]); + + const scrollToBottom = useCallback(() => { + const container = scrollRef.current; + if (!container) return; + container.scrollTop = container.scrollHeight; + updateBottomState(); + }, [updateBottomState]); + + const contextValue = useMemo( + () => ({ + contentRef, + isAtBottom, + scrollRef, + scrollToBottom, + }), + [isAtBottom, scrollToBottom] + ); return ( - - +
- {children} - - +
+ {children} + +
+ ); }; -/** - * Component that tracks scroll engagement and preserves position. - * Must be used inside Conversation component. - */ -export const ScrollPositionPreserver = () => { - const { isAtBottom, scrollRef } = useStickToBottomContext(); - const preservationContext = useContext(ScrollPreservationContext); - const containerFoundRef = useRef(false); +const useConversationContext = () => { + const context = useContext(ConversationContext); - // Find and register scroll container on mount - useLayoutEffect(() => { - if (containerFoundRef.current || !preservationContext) return; + if (!context) { + throw new Error( + "Conversation components must be used within a Conversation component." + ); + } - // Use the local StickToBottom scroll container for this conversation instance. - const container = scrollRef.current; - if (container) { - preservationContext.registerScrollContainer(container); - containerFoundRef.current = true; - } - }, [preservationContext, scrollRef]); - - // Track engagement based on scroll position - useEffect(() => { - if (!preservationContext) return; - - if (!isAtBottom) { - // User is not at bottom - mark as engaged - preservationContext.markUserEngaged(); - } else { - // User is back at bottom - reset - preservationContext.resetEngagement(); - } - }, [isAtBottom, preservationContext]); - - return null; + return context; }; -export type ConversationContentProps = ComponentProps< - typeof StickToBottom.Content ->; +export type ConversationContentProps = ComponentProps<"div">; export const ConversationContent = ({ className, ...props -}: ConversationContentProps) => ( - -); +}: ConversationContentProps) => { + const { contentRef } = useConversationContext(); + + return ( +
+ ); +}; export type ConversationEmptyStateProps = ComponentProps<"div"> & { - title?: string; description?: string; - icon?: React.ReactNode; + icon?: ReactNode; + title?: string; }; export const ConversationEmptyState = ({ + children, className, - title = "No messages yet", description = "Start a conversation to see messages here", icon, - children, + title = "No messages yet", ...props }: ConversationEmptyStateProps) => (
); +export const ScrollPositionPreserver = () => null; + export type ConversationScrollButtonProps = ComponentProps; export const ConversationScrollButton = ({ className, ...props }: ConversationScrollButtonProps) => { - const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + const { isAtBottom, scrollToBottom } = useConversationContext(); const handleScrollToBottom = useCallback(() => { scrollToBottom(); @@ -199,16 +292,16 @@ export const ConversationScrollButton = ({ !isAtBottom && ( ) ); diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx index d9453aa1..18af8b0e 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx @@ -16,8 +16,8 @@ import { WrenchIcon, XCircleIcon, } from "lucide-react"; -import type { ComponentProps, ReactNode } from "react"; -import { isValidElement } from "react"; +import { type ComponentProps, type ReactNode, isValidElement, useState } from "react"; + const formatToolValue = (value: unknown) => { if (typeof value === "string") return value; try { @@ -37,7 +37,7 @@ const ToolCode = ({ }) => (
@@ -129,64 +129,90 @@ export const ToolContent = ({ className, ...props }: ToolContentProps) => (
   />
 );
 
-export type ToolInputProps = ComponentProps<"div"> & {
+/* ── Tabbed content (Parameters / Result) ────────────────────────── */
+
+export type ToolTabbedContentProps = {
   input: ToolUIPart["input"];
-};
-
-export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
-  
-

- Parameters -

-
- -
-
-); - -export type ToolOutputProps = ComponentProps<"div"> & { output: ToolUIPart["output"]; - errorText: ToolUIPart["errorText"]; + errorText?: ToolUIPart["errorText"]; }; -export const ToolOutput = ({ - className, +export const ToolTabbedContent = ({ + input, output, errorText, - ...props -}: ToolOutputProps) => { - if (!(output || errorText)) { - return null; - } +}: ToolTabbedContentProps) => { + const [activeTab, setActiveTab] = useState<"parameters" | "result">("parameters"); + const hasOutput = output != null || !!errorText; - let Output =
{output as ReactNode}
; - - if (typeof output === "object" && !isValidElement(output)) { - Output = ; - } else if (typeof output === "string") { - Output = ; + let OutputNode: ReactNode = null; + if (errorText) { + OutputNode = ; + } else if (output != null) { + if (typeof output === "object" && !isValidElement(output)) { + OutputNode = ; + } else if (typeof output === "string") { + OutputNode = ; + } else { + OutputNode =
{output as ReactNode}
; + } } return ( -
-

- {errorText ? "Error" : "Result"} -

-
- {errorText && ( -
- {errorText} +
+ {/* Tabs */} +
+ + +
+ + {/* Tab content */} +
+ {activeTab === "parameters" && ( +
+ +
+ )} + {activeTab === "result" && ( +
+ {hasOutput ? ( +
+ {OutputNode} +
+ ) : ( + (pending...) + )}
)} - {Output}
); }; + diff --git a/apps/x/apps/renderer/src/components/bases-view.tsx b/apps/x/apps/renderer/src/components/bases-view.tsx index 7462f5b5..a68eb360 100644 --- a/apps/x/apps/renderer/src/components/bases-view.tsx +++ b/apps/x/apps/renderer/src/components/bases-view.tsx @@ -1,9 +1,16 @@ import * as React from 'react' import { useEffect, useState, useMemo, useCallback, useRef } from 'react' -import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save } from 'lucide-react' +import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save, Copy, Pencil, Trash2 } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from '@/components/ui/command' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/context-menu' import { Dialog, DialogContent, @@ -91,6 +98,12 @@ type BasesViewProps = { externalSearch?: string /** Called after the external search has been consumed (applied to internal state). */ onExternalSearchConsumed?: () => void + /** Actions for context menu */ + actions?: { + rename: (oldPath: string, newName: string, isDir: boolean) => Promise + remove: (path: string) => Promise + copyPath: (path: string) => void + } } function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] { @@ -140,13 +153,15 @@ function getSortValue(note: NoteEntry, column: string): string | number { if (column === 'mtimeMs') return note.mtimeMs const v = note.fields[column] if (!v) return '' + if (column === 'last_update' || column === 'first_met') { + const s = Array.isArray(v) ? v[0] ?? '' : v + const ms = Date.parse(s) + return isNaN(ms) ? 0 : ms + } 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) { +export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave, externalSearch, onExternalSearchConsumed, actions }: BasesViewProps) { // Build notes instantly from tree const notes = useMemo(() => { return collectFiles(tree).map((f) => ({ @@ -655,22 +670,15 @@ export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaul {pageNotes.map((note) => ( - onSelectNote(note.path)} - > - {visibleColumns.map((col) => ( - - - - ))} - + note={note} + visibleColumns={visibleColumns} + filters={filters} + toggleFilter={toggleFilter} + onSelectNote={onSelectNote} + actions={actions} + /> ))} {pageNotes.length === 0 && ( @@ -773,6 +781,17 @@ function CellRenderer({ return {formatDate(note.mtimeMs)} } + // Date-like frontmatter columns — render like Last Modified + if (column === 'last_update' || column === 'first_met') { + const value = note.fields[column] + if (!value || Array.isArray(value)) return null + const ms = Date.parse(value) + if (!isNaN(ms)) { + return {formatDate(ms)} + } + return {value} + } + // Frontmatter column const value = note.fields[column] if (!value) return null @@ -804,6 +823,116 @@ function CellRenderer({ ) } +function NoteRow({ + note, + visibleColumns, + filters, + toggleFilter, + onSelectNote, + actions, +}: { + note: NoteEntry + visibleColumns: string[] + filters: ActiveFilter[] + toggleFilter: (category: string, value: string) => void + onSelectNote: (path: string) => void + actions?: BasesViewProps['actions'] +}) { + const [isRenaming, setIsRenaming] = useState(false) + const [newName, setNewName] = useState('') + const isSubmittingRef = useRef(false) + const inputRef = useRef(null) + + useEffect(() => { + if (isRenaming) inputRef.current?.focus() + }, [isRenaming]) + + const baseName = note.name + const handleRenameSubmit = useCallback(async () => { + if (isSubmittingRef.current) return + const trimmed = newName.trim() + if (!trimmed || trimmed === baseName) { + setIsRenaming(false) + return + } + isSubmittingRef.current = true + try { + await actions?.rename(note.path, trimmed, false) + } catch { + // ignore + } + setIsRenaming(false) + isSubmittingRef.current = false + }, [newName, baseName, actions, note.path]) + + const handleCopyPath = useCallback(() => { + actions?.copyPath(note.path) + }, [actions, note.path]) + + const handleDelete = useCallback(() => { + void actions?.remove(note.path) + }, [actions, note.path]) + + const row = ( + onSelectNote(note.path)} + > + {visibleColumns.map((col) => ( + + {col === 'name' && isRenaming ? ( + setNewName(e.target.value)} + onBlur={() => void handleRenameSubmit()} + onKeyDown={(e) => { + if (e.key === 'Enter') void handleRenameSubmit() + if (e.key === 'Escape') setIsRenaming(false) + }} + onClick={(e) => e.stopPropagation()} + className="w-full bg-transparent text-sm font-medium outline-none ring-1 ring-ring rounded px-1" + /> + ) : ( + + )} + + ))} + + ) + + if (!actions) return row + + return ( + + + {row} + + + + + Copy Path + + + { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}> + + Rename + + + + Delete + + + + ) +} + function CategoryBadge({ category, value, 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..03ab3f94 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,10 +66,11 @@ const providerDisplayNames: Record = { openrouter: 'OpenRouter', aigateway: 'AI Gateway', 'openai-compatible': 'OpenAI-Compatible', + rowboat: 'Rowboat', } interface ConfiguredModel { - flavor: string + flavor: "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" model: string apiKey?: string baseURL?: string @@ -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: flavor as ConfiguredModel['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() @@ -213,47 +266,54 @@ function ChatInputInner({ return () => window.removeEventListener('models-config-changed', handler) }, [loadModelConfig]) - // Check search tool availability (brave or exa) + // Check search tool availability (exa or signed-in via gateway) useEffect(() => { const checkSearch = async () => { + if (isRowboatConnected) { + setSearchAvailable(true) + return + } let available = false try { - const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/brave-search.json' }) + const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/exa-search.json' }) const config = JSON.parse(raw.data) if (config.apiKey) available = true } catch { /* not configured */ } - if (!available) { - try { - const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/exa-search.json' }) - const config = JSON.parse(raw.data) - if (config.apiKey) available = true - } catch { /* not configured */ } - } setSearchAvailable(available) } checkSearch() - }, [isActive]) + }, [isActive, isRowboatConnected]) const handleModelChange = useCallback(async (key: string) => { 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/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index ac7f23be..64b1e843 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -8,7 +8,7 @@ import { Conversation, ConversationContent, ConversationEmptyState, - ScrollPositionPreserver, + ConversationScrollButton, } from '@/components/ai-elements/conversation' import { Message, @@ -16,8 +16,9 @@ import { MessageResponse, } from '@/components/ai-elements/message' import { Shimmer } from '@/components/ai-elements/shimmer' -import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool' +import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool' import { WebSearchResult } from '@/components/ai-elements/web-search-result' +import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card' import { PermissionRequest } from '@/components/ai-elements/permission-request' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { Suggestions } from '@/components/ai-elements/suggestions' @@ -29,11 +30,14 @@ import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat- import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { wikiLabel } from '@/lib/wiki-links' import { + type ChatViewportAnchorState, type ChatTabViewState, type ConversationItem, type PermissionResponse, createEmptyChatTabViewState, getWebSearchCardData, + getComposioConnectCardData, + getToolDisplayName, isChatMessage, isErrorMessage, isToolCall, @@ -87,6 +91,7 @@ interface ChatSidebarProps { conversation: ConversationItem[] currentAssistantMessage: string chatTabStates?: Record + viewportAnchors?: Record isProcessing: boolean isStopping?: boolean onStop?: () => void @@ -121,6 +126,7 @@ interface ChatSidebarProps { ttsMode?: 'summary' | 'full' onToggleTts?: () => void onTtsModeChange?: (mode: 'summary' | 'full') => void + onComposioConnected?: (toolkitSlug: string) => void } export function ChatSidebar({ @@ -138,6 +144,7 @@ export function ChatSidebar({ conversation, currentAssistantMessage, chatTabStates = {}, + viewportAnchors = {}, isProcessing, isStopping, onStop, @@ -171,6 +178,7 @@ export function ChatSidebar({ ttsMode, onToggleTts, onTtsModeChange, + onComposioConnected, }: ChatSidebarProps) { const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth)) const [isResizing, setIsResizing] = useState(false) @@ -284,7 +292,7 @@ export function ChatSidebar({ if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { return ( - + @@ -296,7 +304,7 @@ export function ChatSidebar({ } const { message, files } = parseAttachedFiles(item.content) return ( - + {files.length > 0 && (
@@ -316,7 +324,7 @@ export function ChatSidebar({ ) } return ( - + {item.content} @@ -337,6 +345,21 @@ export function ChatSidebar({ /> ) } + const composioConnectData = getComposioConnectCardData(item) + if (composioConnectData) { + if (composioConnectData.hidden) return null + return ( + + ) + } + const toolTitle = getToolDisplayName(item) const errorText = item.status === 'error' ? 'Tool error' : '' const output = normalizeToolOutput(item.result, item.status) const input = normalizeToolInput(item.input) @@ -346,10 +369,9 @@ export function ChatSidebar({ open={isToolOpenForTab?.(tabId, item.id) ?? false} onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)} > - + - - {output !== null ? : null} + ) @@ -357,7 +379,7 @@ export function ChatSidebar({ if (isErrorMessage(item)) { return ( - +
{item.message}
@@ -466,9 +488,12 @@ export function ChatSidebar({ )} data-chat-tab-panel={tab.id} aria-hidden={!isActive} - > - - + > + {!tabHasConversation ? ( @@ -526,10 +551,11 @@ export function ChatSidebar({
)} - )} - - -
+ )} + + + +
) })}
diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index fe1d58ae..e28f662e 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -1,8 +1,8 @@ "use client" import * as React from "react" -import { useState, useEffect, useCallback } from "react" -import { AlertTriangle, Loader2, Mic, Mail, MessageSquare, User } from "lucide-react" +import { useState } from "react" +import { AlertTriangle, Loader2, Mic, Mail, Calendar, MessageSquare, User } from "lucide-react" import { Popover, @@ -18,367 +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" - -interface ProviderState { - isConnected: boolean - isLoading: boolean - isConnecting: boolean -} - -interface ProviderStatus { - error?: string -} +import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" +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) - // 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) - - // Load available providers on mount - useEffect(() => { - async function loadProviders() { - try { - setProvidersLoading(true) - const result = await window.ipc.invoke('oauth:list-providers', null) - setProviders(result.providers || []) - } catch (error) { - console.error('Failed to get available providers:', error) - setProviders([]) - } finally { - setProvidersLoading(false) - } - } - loadProviders() - }, []) - - // Load Granola config - const refreshGranolaConfig = useCallback(async () => { - try { - setGranolaLoading(true) - const result = await window.ipc.invoke('granola:getConfig', null) - setGranolaEnabled(result.enabled) - } catch (error) { - console.error('Failed to load Granola config:', error) - setGranolaEnabled(false) - } finally { - setGranolaLoading(false) - } - }, []) - - // Update Granola config - const handleGranolaToggle = useCallback(async (enabled: boolean) => { - try { - setGranolaLoading(true) - await window.ipc.invoke('granola:setConfig', { enabled }) - setGranolaEnabled(enabled) - toast.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) - } - }, []) - - // Check connection status for all providers - const refreshAllStatuses = useCallback(async () => { - // Refresh Granola - refreshGranolaConfig() - - // Refresh Slack config - refreshSlackConfig() - - // 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]) - - // Refresh statuses when popover opens or providers list changes - useEffect(() => { - if (open) { - refreshAllStatuses() - } - }, [open, providers, refreshAllStatuses]) - - // Listen for OAuth completion events - useEffect(() => { - const cleanup = window.ipc.on('oauth:didConnect', (event) => { - const { provider, success, error } = 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}`) - } - // Refresh status to ensure consistency - refreshAllStatuses() - } else { - toast.error(error || `Failed to connect to ${provider}`) - } - }) - - return cleanup - }, [refreshAllStatuses]) - - 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" > @@ -425,23 +98,23 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha ) : ( )} @@ -450,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 ? ( @@ -489,169 +200,296 @@ 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') && ( + {/* 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 */} + {(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && hasUnconnectedEmailCalendar && ( <>
- Account -
- {renderOAuthProvider('rowboat', 'Rowboat', , 'Connect your Rowboat account')} - - - )} - - {/* Email & Calendar Section - Google */} - {providers.includes('google') && ( - <> -
- Email & Calendar -
- {renderOAuthProvider('google', 'Google', , 'Sync emails and calendar')} - - - )} - - {/* Meeting Notes Section - Granola & Fireflies */} -
- Meeting Notes -
- - {/* Granola */} -
-
-
- -
-
- Granola - - Local meeting notes + + Email & Calendar
-
-
- {granolaLoading && ( - + {c.useComposioForGoogle ? ( + // In unconnected mode, only show if not connected + (!isUnconnectedMode || isGmailUnconnected) ? ( +
+
+
+ +
+
+ Gmail + {c.gmailLoading ? ( + Checking... + ) : ( + + Sync emails + + )} +
+
+
+ {c.gmailLoading ? ( + + ) : c.gmailConnected ? ( + + ) : ( + + )} +
+
+ ) : null + ) : ( + renderOAuthProvider('google', 'Google', , 'Sync emails and calendar') )} - -
-
- - {/* Fireflies */} - {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')} - - - - {/* Team Communication Section - Slack */} -
- Team Communication -
- - {/* Slack */} -
-
-
-
- + {c.useComposioForGoogleCalendar && (!isUnconnectedMode || isGoogleCalendarUnconnected) && ( +
+
+
+ +
+
+ Google Calendar + {c.googleCalendarLoading ? ( + Checking... + ) : ( + + Sync calendar events + + )} +
+
+
+ {c.googleCalendarLoading ? ( + + ) : c.googleCalendarConnected ? ( + + ) : ( + + )} +
-
- Slack - {slackEnabled && slackWorkspaces.length > 0 ? ( - - {slackWorkspaces.map(w => w.name).join(', ')} - - ) : ( - - Send messages and view channels - - )} + )} + + + )} + + {/* Meeting Notes Section */} + {hasUnconnectedMeetingNotes && ( + <> +
+ Meeting Notes +
+ + {/* Granola - show in unconnected mode only if not enabled */} + {(!isUnconnectedMode || isGranolaUnconnected) && ( +
+
+
+ +
+
+ Granola + + Local meeting notes + +
+
+
+ {c.granolaLoading && ( + + )} + +
+ )} + + {/* Fireflies */} + {c.providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')} + + + + )} + + {/* Team Communication Section */} + {hasUnconnectedSlack && ( + <> +
+ Team Communication
-
- {(slackLoading || slackDiscovering) && ( - - )} - {slackEnabled ? ( - handleSlackDisable()} - disabled={slackLoading} - /> - ) : ( - + +
+
+
+
+ +
+
+ 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 => ( + + ))} + + + )} +
)}
-
- {slackPickerOpen && ( -
- {slackDiscoverError ? ( -

{slackDiscoverError}

- ) : ( - <> - {slackAvailableWorkspaces.map(w => ( - - ))} - - - )} -
- )} -
+ + )} )}
+ ) } 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/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/graph-view.tsx b/apps/x/apps/renderer/src/components/graph-view.tsx index f30a937c..0a847f54 100644 --- a/apps/x/apps/renderer/src/components/graph-view.tsx +++ b/apps/x/apps/renderer/src/components/graph-view.tsx @@ -1,6 +1,6 @@ import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Loader2, Search, X } from 'lucide-react' +import { Search, X } from 'lucide-react' import { Input } from '@/components/ui/input' export type GraphNode = { @@ -456,22 +456,13 @@ export function GraphView({ nodes, edges, isLoading, error, onSelectNode }: Grap return (
- {isLoading ? ( -
-
- - Building graph… -
-
- ) : null} - - {error ? ( +{error ? (
{error}
) : null} - {!isLoading && !error && nodes.length === 0 ? ( + {!error && nodes.length === 0 ? (
No notes found.
diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 776b5a42..d7920b8b 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -9,8 +9,16 @@ 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 { CalendarBlockExtension } from '@/extensions/calendar-block' +import { EmailBlockExtension } from '@/extensions/email-block' +import { TranscriptBlockExtension } from '@/extensions/transcript-block' import { Markdown } from 'tiptap-markdown' import { useEffect, useCallback, useMemo, useRef, useState } from 'react' +import { Calendar, ChevronDown, ExternalLink } from 'lucide-react' // Zero-width space used as invisible marker for blank lines const BLANK_LINE_MARKER = '\u200B' @@ -101,41 +109,60 @@ function getMarkdownWithBlankLines(editor: Editor): string { const level = (node.attrs?.level as number) || 1 const text = nodeToText(node) blocks.push('#'.repeat(level) + ' ' + text) - } else if (node.type === 'bulletList' || node.type === 'orderedList') { - // Handle lists - all items are part of one block - const listLines: string[] = [] - const listItems = (node.content || []) as Array<{ content?: Array; attrs?: Record }> - listItems.forEach((item, index) => { - const prefix = node.type === 'orderedList' ? `${index + 1}. ` : '- ' - const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }> - itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }, paraIndex: number) => { - const text = nodeToText(para) - if (paraIndex === 0) { - listLines.push(prefix + text) + } else if (node.type === 'bulletList' || node.type === 'orderedList' || node.type === 'taskList') { + // Recursively serialize lists to handle nested bullets + const serializeList = ( + listNode: { type?: string; content?: Array>; attrs?: Record }, + indent: number + ): string[] => { + const lines: string[] = [] + const items = (listNode.content || []) as Array<{ content?: Array>; attrs?: Record }> + items.forEach((item, index) => { + const indentStr = ' '.repeat(indent) + let prefix: string + if (listNode.type === 'taskList') { + const checked = item.attrs?.checked ? 'x' : ' ' + prefix = `- [${checked}] ` + } else if (listNode.type === 'orderedList') { + prefix = `${index + 1}. ` } else { - listLines.push(' ' + text) + prefix = '- ' } + const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }> + let firstPara = true + itemContent.forEach(child => { + if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') { + lines.push(...serializeList(child, indent + 1)) + } else { + const text = nodeToText(child) + if (firstPara) { + lines.push(indentStr + prefix + text) + firstPara = false + } else { + lines.push(indentStr + ' ' + text) + } + } + }) }) - }) - blocks.push(listLines.join('\n')) - } else if (node.type === 'taskList') { - const listLines: string[] = [] - const listItems = (node.content || []) as Array<{ content?: Array; attrs?: Record }> - listItems.forEach(item => { - const checked = item.attrs?.checked ? 'x' : ' ' - const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }> - itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }, paraIndex: number) => { - const text = nodeToText(para) - if (paraIndex === 0) { - listLines.push(`- [${checked}] ${text}`) - } else { - listLines.push(' ' + text) - } - }) - }) - blocks.push(listLines.join('\n')) + return lines + } + blocks.push(serializeList(node, 0).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 === 'calendarBlock') { + blocks.push('```calendar\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'emailBlock') { + blocks.push('```email\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'transcriptBlock') { + blocks.push('```transcript\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```') @@ -206,6 +233,117 @@ type WikiLinkConfig = { onCreate: (path: string) => void | Promise } +// --- Meeting Event Banner --- + +interface ParsedCalendarEvent { + summary?: string + start?: string + end?: string + location?: string + htmlLink?: string + conferenceLink?: string + source?: string +} + +function parseCalendarEvent(raw: string | undefined): ParsedCalendarEvent | null { + if (!raw) return null + // Strip surrounding quotes if present (YAML single-quoted string) + let json = raw + if ((json.startsWith("'") && json.endsWith("'")) || (json.startsWith('"') && json.endsWith('"'))) { + json = json.slice(1, -1) + } + // Unescape doubled single quotes from YAML + json = json.replace(/''/g, "'") + try { + return JSON.parse(json) as ParsedCalendarEvent + } catch { + return null + } +} + +function formatEventTime(start?: string, end?: string): string { + if (!start) return '' + const s = new Date(start) + const startStr = s.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }) + const startTime = s.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + if (!end) return `${startStr} \u00b7 ${startTime}` + const e = new Date(end) + const endTime = e.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + return `${startStr} \u00b7 ${startTime} \u2013 ${endTime}` +} + +function formatEventDate(start?: string): string { + if (!start) return '' + const s = new Date(start) + const today = new Date() + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + const tomorrow = new Date(today) + tomorrow.setDate(tomorrow.getDate() + 1) + + if (s.toDateString() === today.toDateString()) return 'Today' + if (s.toDateString() === yesterday.toDateString()) return 'Yesterday' + if (s.toDateString() === tomorrow.toDateString()) return 'Tomorrow' + return s.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }) +} + +function MeetingEventBanner({ frontmatter }: { frontmatter: string | null | undefined }) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + if (!frontmatter) return null + const fields = extractAllFrontmatterValues(frontmatter) + if (fields.type !== 'meeting') return null + + const calStr = typeof fields.calendar_event === 'string' ? fields.calendar_event : undefined + const cal = parseCalendarEvent(calStr) + if (!cal) return null + + return ( +
+ + {open && ( +
+
+ +
+
{cal.summary || 'Meeting'}
+
{formatEventTime(cal.start, cal.end)}
+
+
+ {cal.htmlLink && ( + + )} +
+ )} +
+ ) +} + +// --- Editor --- + interface MarkdownEditorProps { content: string onChange: (markdown: string) => void @@ -220,6 +358,7 @@ interface MarkdownEditorProps { frontmatter?: string | null onFrontmatterChange?: (raw: string | null) => void onExport?: (format: 'md' | 'pdf' | 'docx') => void + notePath?: string } type WikiLinkMatch = { @@ -311,6 +450,7 @@ export function MarkdownEditor({ frontmatter, onFrontmatterChange, onExport, + notePath, }: MarkdownEditorProps) { const isInternalUpdate = useRef(false) const wrapperRef = useRef(null) @@ -429,6 +569,13 @@ export function MarkdownEditor({ }), ImageUploadPlaceholderExtension, TaskBlockExtension, + ImageBlockExtension, + EmbedBlockExtension, + ChartBlockExtension, + TableBlockExtension, + CalendarBlockExtension, + EmailBlockExtension, + TranscriptBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { @@ -555,7 +702,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) @@ -912,24 +1059,17 @@ export function MarkdownEditor({ } if (activeRowboatMention) { - // Classify schedule intent for new blocks - const blockData: Record = { instruction } - try { - const result = await window.ipc.invoke('inline-task:classifySchedule', { instruction }) - if (result.schedule) { - const { label, ...rest } = result.schedule - blockData.schedule = rest - blockData['schedule-label'] = label - } - } catch (error) { - console.error('[RowboatAdd] Schedule classification failed:', error) - } + // Insert a temporary processing block + const blockData: Record = { instruction, processing: true } + + const insertFrom = activeRowboatMention.range.from + const insertTo = activeRowboatMention.range.to editor .chain() .focus() .insertContentAt( - { from: activeRowboatMention.range.from, to: activeRowboatMention.range.to }, + { from: insertFrom, to: insertTo }, [ { type: 'taskBlock', attrs: { data: JSON.stringify(blockData) } }, { type: 'paragraph' }, @@ -937,17 +1077,125 @@ export function MarkdownEditor({ ) .run() - // Mark note as live - if (onFrontmatterChange) { - const fields = extractAllFrontmatterValues(frontmatter ?? null) - fields['live_note'] = 'true' - onFrontmatterChange(buildFrontmatter(fields)) - } - setActiveRowboatMention(null) setRowboatAnchorTop(null) + + // Get editor content for the agent + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const editorContent = (editor.storage as any).markdown?.getMarkdown?.() ?? '' + + // Helper to find the processing block + const findProcessingBlock = (): number | null => { + let pos: number | null = null + editor.state.doc.descendants((node, p) => { + if (pos !== null) return false + if (node.type.name === 'taskBlock') { + try { + const data = JSON.parse(node.attrs.data || '{}') + if (data.instruction === instruction && data.processing === true) { + pos = p + return false + } + } catch { /* skip */ } + } + }) + return pos + } + + try { + // Call the copilot assistant for both one-time and recurring tasks + const result = await window.ipc.invoke('inline-task:process', { + instruction, + noteContent: editorContent, + notePath: notePath ?? '', + }) + + const currentPos = findProcessingBlock() + if (currentPos === null) return + + const node = editor.state.doc.nodeAt(currentPos) + if (!node) return + + if (result.schedule) { + // Recurring/scheduled task: update block with schedule, write target tags to disk + const targetId = Math.random().toString(36).slice(2, 10) + const updatedData: Record = { + instruction: result.instruction, + schedule: result.schedule, + 'schedule-label': result.scheduleLabel, + targetId, + } + const tr = editor.state.tr.setNodeMarkup(currentPos, undefined, { + data: JSON.stringify(updatedData), + }) + editor.view.dispatch(tr) + + // Mark note as live + if (onFrontmatterChange) { + const fields = extractAllFrontmatterValues(frontmatter ?? null) + fields['live_note'] = 'true' + onFrontmatterChange(buildFrontmatter(fields)) + } + + // Write target tags directly to the file on disk after a short delay + // to let the editor save the updated content first + if (notePath) { + setTimeout(async () => { + try { + const file = await window.ipc.invoke('workspace:readFile', { path: notePath }) + const content = file.data + const openTag = `` + const closeTag = `` + + // Only add if not already present + if (content.includes(openTag)) return + + // Find the task block in the raw markdown and insert target tags after it + const blockJson = JSON.stringify(updatedData) + const blockStart = content.indexOf('```task\n' + blockJson) + if (blockStart !== -1) { + const blockEnd = content.indexOf('\n```', blockStart + 8) + if (blockEnd !== -1) { + const insertAt = blockEnd + 4 // after the closing ``` + const before = content.slice(0, insertAt) + const after = content.slice(insertAt) + const updated = before + '\n\n' + openTag + '\n' + closeTag + after + await window.ipc.invoke('workspace:writeFile', { + path: notePath, + data: updated, + opts: { encoding: 'utf8' }, + }) + } + } + } catch (err) { + console.error('[RowboatAdd] Failed to write target tags:', err) + } + }, 500) + } + } else { + // One-time task: remove the processing block, insert response in its place + const insertPos = currentPos + const deleteEnd = currentPos + node.nodeSize + editor.chain().focus().deleteRange({ from: insertPos, to: deleteEnd }).run() + + if (result.response) { + editor.chain().insertContentAt(insertPos, result.response).run() + } + } + } catch (error) { + console.error('[RowboatAdd] Processing failed:', error) + + // Remove the processing block on error + const currentPos = findProcessingBlock() + if (currentPos !== null) { + const node = editor.state.doc.nodeAt(currentPos) + if (node) { + editor.chain().focus().deleteRange({ from: currentPos, to: currentPos + node.nodeSize }).run() + } + } + } } - }, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange]) + }, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange, notePath]) const handleRowboatRemove = useCallback(() => { if (!editor || !rowboatBlockEdit) return @@ -1082,6 +1330,7 @@ export function MarkdownEditor({ editable={editable} /> )} +
{wikiLinks ? ( diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 3e663b91..82064205 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, @@ -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 @@ -37,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" @@ -49,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") @@ -78,6 +82,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 [, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') + // Slack state (agent-slack CLI) const [slackEnabled, setSlackEnabled] = useState(false) const [slackLoading, setSlackLoading] = useState(true) @@ -88,6 +96,18 @@ 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) + + // 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 => ({ @@ -115,7 +135,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 +151,25 @@ 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) + } + } + 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 @@ -254,6 +292,101 @@ 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) + } + }, []) + + // 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) + } + } 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]) + + // 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)) @@ -290,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() } @@ -341,6 +492,16 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // 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 @@ -368,7 +529,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshSlackConfig]) + }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar]) // Refresh statuses when modal opens or providers list changes useEffect(() => { @@ -377,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, @@ -390,18 +551,44 @@ 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 + }, []) + + // 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 } = event + + 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, @@ -449,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) => { @@ -544,6 +741,94 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) + // Render Gmail Composio row + const renderGmailRow = () => ( +
+
+
+ +
+
+ Gmail + {gmailLoading ? ( + Checking... + ) : ( + + Sync emails + + )} +
+
+
+ {gmailLoading ? ( + + ) : gmailConnected ? ( +
+ + Connected +
+ ) : ( + + )} +
+
+ ) + + // Render Google Calendar Composio row + const renderGoogleCalendarRow = () => ( +
+
+
+ +
+
+ Google Calendar + {googleCalendarLoading ? ( + Checking... + ) : ( + + Sync calendar events + + )} +
+
+
+ {googleCalendarLoading ? ( + + ) : googleCalendarConnected ? ( +
+ + Connected +
+ ) : ( + + )} +
+
+ ) + // Render Slack row const renderSlackRow = () => (
@@ -625,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" }, @@ -801,10 +1202,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)} -
+
+ - +
+ + +
) - // Step 2: Completion + // Step 4: Completion const renderCompletionStep = () => { - const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled + const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected || googleCalendarConnected return (
@@ -901,6 +1317,18 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {

Connected accounts:

+ {gmailConnected && ( +
+ + Gmail (Email) +
+ )} + {googleCalendarConnected && ( +
+ + Google Calendar +
+ )} {connectedProviders.includes('google') && (
@@ -945,6 +1373,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { onSubmit={handleGoogleClientIdSubmit} isSubmitting={providerStates.google?.isConnecting ?? false} /> + {}}> 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..534a67a8 --- /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, + 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/search-dialog.tsx b/apps/x/apps/renderer/src/components/search-dialog.tsx index 22e398fe..32bca1b3 100644 --- a/apps/x/apps/renderer/src/components/search-dialog.tsx +++ b/apps/x/apps/renderer/src/components/search-dialog.tsx @@ -1,4 +1,6 @@ import { useState, useEffect, useCallback } from 'react' +import posthog from 'posthog-js' +import * as analytics from '@/lib/analytics' import { FileTextIcon, MessageSquareIcon } from 'lucide-react' import { CommandDialog, @@ -68,6 +70,8 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: .then((res) => { if (!cancelled) { setResults(res.results) + analytics.searchExecuted(types) + posthog.people.set_once({ has_used_search: true }) } }) .catch((err) => { diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 6d6dfbfc..143c6292 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, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, 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" | "tools" | "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", @@ -61,6 +75,12 @@ const tabs: TabConfig[] = [ icon: Palette, description: "Customize the look and feel", }, + { + id: "tools", + label: "Tools Library", + icon: Wrench, + description: "Browse and enable toolkits", + }, { id: "note-tagging", label: "Note Tagging", @@ -693,6 +713,445 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { ) } +// --- Tools Library Settings --- + +interface ToolkitInfo { + slug: string + name: string + meta: { description: string; logo: string; tools_count: number; triggers_count: number } + no_auth?: boolean + auth_schemes?: string[] + composio_managed_auth_schemes?: string[] +} + +function ToolsLibrarySettings({ dialogOpen, rowboatConnected }: { dialogOpen: boolean; rowboatConnected: boolean }) { + // API key state + const [apiKeyConfigured, setApiKeyConfigured] = useState(false) + const [apiKeyInput, setApiKeyInput] = useState("") + const [apiKeySaving, setApiKeySaving] = useState(false) + const [showApiKeyInput, setShowApiKeyInput] = useState(false) + + // Toolkit browsing state + const [toolkits, setToolkits] = useState([]) + const [toolkitsLoading, setToolkitsLoading] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + + // Connection state + const [connectedToolkits, setConnectedToolkits] = useState>(new Set()) + const [connectingToolkit, setConnectingToolkit] = useState(null) + + // Check API key configuration + const checkApiKey = useCallback(async () => { + try { + const result = await window.ipc.invoke("composio:is-configured", null) + setApiKeyConfigured(result.configured) + if (!result.configured) { + setShowApiKeyInput(true) + } + } catch { + setApiKeyConfigured(false) + } + }, []) + + // Load connected toolkits + const loadConnected = useCallback(async () => { + try { + const result = await window.ipc.invoke("composio:list-connected", null) + setConnectedToolkits(new Set(result.toolkits)) + } catch { + // ignore + } + }, []) + + // Load toolkits + const loadToolkits = useCallback(async () => { + setToolkitsLoading(true) + try { + const result = await window.ipc.invoke("composio:list-toolkits", {}) + setToolkits(result.items) + } catch { + toast.error("Failed to load toolkits") + } finally { + setToolkitsLoading(false) + } + }, []) + + // Initial load + useEffect(() => { + if (!dialogOpen) return + checkApiKey() + loadConnected() + }, [dialogOpen, checkApiKey, loadConnected]) + + // Load toolkits when API key is configured + useEffect(() => { + if (dialogOpen && apiKeyConfigured) { + loadToolkits() + } + }, [dialogOpen, apiKeyConfigured, loadToolkits]) + + // Listen for composio connection events + useEffect(() => { + const cleanup = window.ipc.on('composio:didConnect', (event) => { + const { toolkitSlug, success, error } = event + setConnectingToolkit(null) + if (success) { + setConnectedToolkits(prev => new Set([...prev, toolkitSlug])) + toast.success(`Connected to ${toolkitSlug}`) + } else { + toast.error(error || `Failed to connect to ${toolkitSlug}`) + } + }) + return cleanup + }, []) + + // Save API key + const handleSaveApiKey = async () => { + const trimmed = apiKeyInput.trim() + if (!trimmed) return + setApiKeySaving(true) + try { + const result = await window.ipc.invoke("composio:set-api-key", { apiKey: trimmed }) + if (result.success) { + setApiKeyConfigured(true) + setShowApiKeyInput(false) + setApiKeyInput("") + toast.success("Composio API key saved") + } else { + toast.error(result.error || "Failed to save API key") + } + } catch { + toast.error("Failed to save API key") + } finally { + setApiKeySaving(false) + } + } + + // Connect a toolkit + const handleConnect = async (toolkitSlug: string) => { + setConnectingToolkit(toolkitSlug) + try { + const result = await window.ipc.invoke("composio:initiate-connection", { toolkitSlug }) + if (!result.success) { + toast.error(result.error || "Failed to connect") + setConnectingToolkit(null) + } + // Success will be handled by composio:didConnect event + } catch { + toast.error("Failed to connect") + setConnectingToolkit(null) + } + } + + // Disconnect a toolkit + const handleDisconnect = async (toolkitSlug: string) => { + try { + await window.ipc.invoke("composio:disconnect", { toolkitSlug }) + setConnectedToolkits(prev => { + const next = new Set(prev) + next.delete(toolkitSlug) + return next + }) + toast.success(`Disconnected from ${toolkitSlug}`) + } catch { + toast.error("Failed to disconnect") + } + } + + // Filter toolkits by search + const filteredToolkits = searchQuery.trim() + ? toolkits.filter(t => + t.name.toLowerCase().includes(searchQuery.toLowerCase()) || + t.slug.toLowerCase().includes(searchQuery.toLowerCase()) || + t.meta.description.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : toolkits + + return ( +
+ {/* Section A: API Key (only in BYOK mode) */} + {!rowboatConnected && ( +
+ Composio API Key + {apiKeyConfigured && !showApiKeyInput ? ( +
+
+ + API key configured +
+ +
+ ) : ( +
+

+ Enter your Composio API key to browse and enable tool integrations. + Get your key from{" "} + + app.composio.dev/settings + +

+
+ setApiKeyInput(e.target.value)} + placeholder="Paste your Composio API key" + onKeyDown={(e) => e.key === "Enter" && handleSaveApiKey()} + className="flex-1" + /> + + {apiKeyConfigured && ( + + )} +
+
+ )} +
+ )} + + {/* Section B: Toolkit Browser (only when API key configured) */} + {apiKeyConfigured && ( + <> +
+ Available Toolkits +
+ + setSearchQuery(e.target.value)} + placeholder="Search toolkits..." + className="pl-8" + /> +
+
+ + {toolkitsLoading ? ( +
+ + Loading toolkits... +
+ ) : ( +
+ {filteredToolkits.map((toolkit) => { + const isConnected = connectedToolkits.has(toolkit.slug) + const isConnecting = connectingToolkit === toolkit.slug + + return ( +
+
+ {/* Logo */} + {toolkit.meta.logo ? ( + { (e.target as HTMLImageElement).style.display = 'none' }} + /> + ) : ( +
+ +
+ )} + + {/* Name & description */} +
+
+ {toolkit.name} + {isConnected && ( + + Connected + + )} +
+

+ {toolkit.meta.description} +

+
+ + {/* Connect / Disconnect button */} + {isConnected ? ( + + ) : ( + + )} +
+
+ ) + })} + + {filteredToolkits.length === 0 && !toolkitsLoading && ( +
+ {searchQuery ? "No toolkits match your search" : "No toolkits available"} +
+ )} +
+ )} + + )} +
+ ) +} + +// --- 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 { @@ -709,7 +1168,7 @@ const NOTE_TAG_TYPE_ORDER = [ ] const EMAIL_TAG_TYPE_ORDER = [ - "relationship", "topic", "email-type", "filter", "action", "status", + "relationship", "topic", "email-type", "noise", "action", "status", ] const TAG_TYPE_LABELS: Record = { @@ -717,77 +1176,16 @@ const TAG_TYPE_LABELS: Record = { "relationship-sub": "Relationship Sub-Tags", "topic": "Topic", "email-type": "Email Type", - "filter": "Filter", + "noise": "Noise", "action": "Action", "status": "Status", "source": "Source", } -const DEFAULT_TAGS: TagDef[] = [ - { tag: "investor", type: "relationship", applicability: "both", noteEffect: "create", description: "Investors, VCs, or angels", example: "Following up on our meeting — we'd like to move forward with the Series A term sheet." }, - { tag: "customer", type: "relationship", applicability: "both", noteEffect: "create", description: "Paying customers", example: "We're seeing great results with Rowboat. Can we discuss expanding to more teams?" }, - { tag: "prospect", type: "relationship", applicability: "both", noteEffect: "create", description: "Potential customers", example: "Thanks for the demo yesterday. We're interested in starting a pilot." }, - { tag: "partner", type: "relationship", applicability: "both", noteEffect: "create", description: "Business partners", example: "Let's discuss how we can promote the integration to both our user bases." }, - { tag: "vendor", type: "relationship", applicability: "both", noteEffect: "create", description: "Service providers you work with", example: "Here are the updated employment agreements you requested." }, - { tag: "product", type: "relationship", applicability: "both", noteEffect: "skip", description: "Products or services you use (automated)", example: "Your AWS bill for January 2025 is now available." }, - { tag: "candidate", type: "relationship", applicability: "both", noteEffect: "create", description: "Job applicants", example: "Thanks for reaching out. I'd love to learn more about the engineering role." }, - { tag: "team", type: "relationship", applicability: "both", noteEffect: "create", description: "Internal team members", example: "Here's the updated roadmap for Q2. Let's discuss in our sync." }, - { tag: "advisor", type: "relationship", applicability: "both", noteEffect: "create", description: "Advisors, mentors, or board members", example: "I've reviewed the deck. Here are my thoughts on the GTM strategy." }, - { tag: "personal", type: "relationship", applicability: "both", noteEffect: "create", description: "Family or friends", example: "Are you coming to Thanksgiving this year? Let me know your travel dates." }, - { tag: "press", type: "relationship", applicability: "both", noteEffect: "create", description: "Journalists or media", example: "I'm writing a piece on AI agents. Would you be available for an interview?" }, - { tag: "community", type: "relationship", applicability: "both", noteEffect: "create", description: "Users, peers, or open source contributors", example: "Love what you're building with Rowboat. Here's a bug I found..." }, - { tag: "government", type: "relationship", applicability: "both", noteEffect: "create", description: "Government agencies", example: "Your Delaware franchise tax is due by March 1, 2025." }, - { tag: "primary", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Main contact or decision maker", example: "Sarah Chen — VP Engineering, your main point of contact at Acme." }, - { tag: "secondary", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Supporting contact, involved but not the lead", example: "David Kim — Engineer CC'd on customer emails." }, - { tag: "executive-assistant", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "EA or admin handling scheduling and logistics", example: "Lisa — Sarah's EA who schedules all her meetings." }, - { tag: "cc", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person who's CC'd but not actively engaged", example: "Manager looped in for visibility on deal." }, - { tag: "referred-by", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person who made an introduction or referral", example: "David Park — Investor who intro'd you to Sarah." }, - { tag: "former", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Previously held this relationship, no longer active", example: "John — Former customer who churned last year." }, - { tag: "champion", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Internal advocate pushing for you", example: "Engineer who loves your product and is selling internally." }, - { tag: "blocker", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person opposing or blocking progress", example: "CFO resistant to spending on new tools." }, - { tag: "sales", type: "topic", applicability: "both", noteEffect: "create", description: "Sales conversations, deals, and revenue", example: "Here's the pricing proposal we discussed. Let me know if you have questions." }, - { tag: "support", type: "topic", applicability: "both", noteEffect: "create", description: "Help requests, issues, and customer support", example: "We're seeing an error when trying to export. Can you help?" }, - { tag: "legal", type: "topic", applicability: "both", noteEffect: "create", description: "Contracts, terms, compliance, and legal matters", example: "Legal has reviewed the MSA. Attached are our requested changes." }, - { tag: "finance", type: "topic", applicability: "both", noteEffect: "create", description: "Money, invoices, payments, banking, and taxes", example: "Your invoice #1234 for $5,000 is attached. Payment due in 30 days." }, - { tag: "hiring", type: "topic", applicability: "both", noteEffect: "create", description: "Recruiting, interviews, and employment", example: "We'd like to move forward with a final round interview. Are you available Thursday?" }, - { tag: "fundraising", type: "topic", applicability: "both", noteEffect: "create", description: "Raising money and investor relations", example: "Thanks for sending the deck. We'd like to schedule a partner meeting." }, - { tag: "travel", type: "topic", applicability: "both", noteEffect: "skip", description: "Flights, hotels, trips, and travel logistics", example: "Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123." }, - { tag: "event", type: "topic", applicability: "both", noteEffect: "create", description: "Conferences, meetups, and gatherings", example: "You're invited to speak at TechCrunch Disrupt. Can you confirm your availability?" }, - { tag: "shopping", type: "topic", applicability: "both", noteEffect: "skip", description: "Purchases, orders, and returns", example: "Your order #12345 has shipped. Track it here." }, - { tag: "health", type: "topic", applicability: "both", noteEffect: "skip", description: "Medical, wellness, and health-related matters", example: "Your appointment with Dr. Smith is confirmed for Monday at 2pm." }, - { tag: "learning", type: "topic", applicability: "both", noteEffect: "skip", description: "Courses, education, and skill-building", example: "Welcome to the Advanced Python course. Here's your access link." }, - { tag: "research", type: "topic", applicability: "both", noteEffect: "create", description: "Research requests and information gathering", example: "Here's the market analysis you requested on the AI agent space." }, - { tag: "intro", type: "email-type", applicability: "both", noteEffect: "create", description: "Warm introduction from someone you know", example: "I'd like to introduce you to Sarah Chen, VP Engineering at Acme." }, - { tag: "followup", type: "email-type", applicability: "both", noteEffect: "create", description: "Following up on a previous conversation", example: "Following up on our call last week. Have you had a chance to review the proposal?" }, - { tag: "scheduling", type: "email-type", applicability: "email", noteEffect: "skip", description: "Meeting and calendar scheduling", example: "Are you available for a call next Tuesday at 2pm?" }, - { tag: "cold-outreach", type: "email-type", applicability: "email", noteEffect: "skip", description: "Unsolicited contact from someone you don't know", example: "Hi, I noticed your company is growing fast. I'd love to show you how we can help with..." }, - { tag: "newsletter", type: "email-type", applicability: "email", noteEffect: "skip", description: "Newsletters, marketing emails, and subscriptions", example: "This week in AI: The latest developments in agent frameworks..." }, - { tag: "notification", type: "email-type", applicability: "email", noteEffect: "skip", description: "Automated alerts, receipts, and system notifications", example: "Your password was changed successfully. If this wasn't you, contact support." }, - { tag: "spam", type: "filter", applicability: "email", noteEffect: "skip", description: "Junk and unwanted email", example: "Congratulations! You've won $1,000,000..." }, - { tag: "promotion", type: "filter", applicability: "email", noteEffect: "skip", description: "Marketing offers and sales pitches", example: "50% off all items this weekend only!" }, - { tag: "social", type: "filter", applicability: "email", noteEffect: "skip", description: "Social media notifications", example: "John Smith commented on your post." }, - { tag: "forums", type: "filter", applicability: "email", noteEffect: "skip", description: "Mailing lists and group discussions", example: "Re: [dev-list] Question about API design" }, - { tag: "action-required", type: "action", applicability: "both", noteEffect: "create", description: "Needs a response or action from you", example: "Can you send me the pricing by Friday?" }, - { tag: "fyi", type: "action", applicability: "email", noteEffect: "skip", description: "Informational only, no action needed", example: "Just wanted to let you know the deal closed. Thanks for your help!" }, - { tag: "urgent", type: "action", applicability: "both", noteEffect: "create", description: "Time-sensitive, needs immediate attention", example: "We need your signature on the contract by EOD today or we lose the deal." }, - { tag: "waiting", type: "action", applicability: "both", noteEffect: "create", description: "Waiting on a response from them" }, - { tag: "unread", type: "status", applicability: "email", noteEffect: "none", description: "Not yet processed" }, - { tag: "to-reply", type: "status", applicability: "email", noteEffect: "none", description: "Need to respond" }, - { tag: "done", type: "status", applicability: "email", noteEffect: "none", description: "Handled, can be archived" }, - { tag: "active", type: "status", applicability: "notes", noteEffect: "none", description: "Currently relevant, recent activity" }, - { tag: "archived", type: "status", applicability: "notes", noteEffect: "none", description: "No longer active, kept for reference" }, - { tag: "stale", type: "status", applicability: "notes", noteEffect: "none", description: "No activity in 60+ days, needs attention or archive" }, - { tag: "email", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from email" }, - { tag: "meeting", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from meeting transcript" }, - { tag: "browser", type: "source", applicability: "notes", noteEffect: "none", description: "Content captured from web browsing" }, - { tag: "web-search", type: "source", applicability: "notes", noteEffect: "none", description: "Information from web search" }, - { tag: "manual", type: "source", applicability: "notes", noteEffect: "none", description: "Manually entered by user" }, - { tag: "import", type: "source", applicability: "notes", noteEffect: "none", description: "Imported from another system" }, -] function TagGroupTable({ group, - tags, + tags: _tags, collapsed, onToggle, onAdd, @@ -914,8 +1312,8 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) { setTags(parsed) setOriginalTags(parsed) } catch { - setTags([...DEFAULT_TAGS]) - setOriginalTags([...DEFAULT_TAGS]) + setTags([]) + setOriginalTags([]) } finally { setLoading(false) } @@ -976,7 +1374,7 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) { const isEmailSection = activeSection === "email" const applicability = isEmailSection ? "email" as const : "notes" as const // For email-only types, always use "email"; for notes-only types, always use "notes"; otherwise use "both" - const emailOnlyTypes = ["email-type", "filter"] + const emailOnlyTypes = ["email-type", "noise"] const notesOnlyTypes = ["relationship-sub", "source"] let finalApplicability: "email" | "notes" | "both" = "both" if (emailOnlyTypes.includes(type)) finalApplicability = "email" @@ -1014,11 +1412,6 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) { } }, [tags]) - const handleReset = useCallback(() => { - if (!confirm("Reset all tags to defaults? This will discard your changes.")) return - setTags([...DEFAULT_TAGS]) - }, []) - const toggleGroup = useCallback((type: string) => { setCollapsedGroups(prev => { const next = new Set(prev) @@ -1090,9 +1483,6 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) { )}
- @@ -1106,14 +1496,28 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) { export function SettingsDialog({ children }: SettingsDialogProps) { const [open, setOpen] = useState(false) - const [activeTab, setActiveTab] = useState("models") + const [activeTab, setActiveTab] = useState("account") const [content, setContent] = useState("") const [originalContent, setOriginalContent] = useState("") 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(() => rowboatConnected ? tabs.filter(t => t.id !== "models") : tabs, [rowboatConnected]) + + const activeTabConfig = visibleTabs.find((t) => t.id === activeTab) ?? visibleTabs[0] const isJsonTab = activeTab === "mcp" || activeTab === "security" const formatJson = (jsonString: string): string => { @@ -1125,7 +1529,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) @@ -1202,7 +1606,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {

Settings

+ ) + } + + 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 2ae699a9..4442c01b 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, @@ -10,6 +10,7 @@ import { Copy, ExternalLink, FilePlus, + Folder, FolderPlus, AlertTriangle, HelpCircle, @@ -87,6 +88,7 @@ import { ConnectorsPopover } from "@/components/connectors-popover" import { HelpPopover } from "@/components/help-popover" import { SettingsDialog } from "@/components/settings-dialog" import { toast } from "@/lib/toast" +import { useBilling } from "@/hooks/useBilling" import { ServiceEvent } from "@x/shared/src/service-events.js" import z from "zod" @@ -401,6 +403,21 @@ export function SidebarContentPanel({ const [connectorsOpen, setConnectorsOpen] = useState(false) 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 @@ -412,6 +429,7 @@ export function SidebarContentPanel({ const hasError = Object.values(config).some((entry) => Boolean(entry?.error)) if (mounted) { setHasOauthError(hasError) + setIsRowboatConnected(config['rowboat']?.connected ?? false) if (!hasError) { setShowOauthAlert(true) } @@ -420,6 +438,7 @@ export function SidebarContentPanel({ console.error('Failed to fetch OAuth state:', error) if (mounted) { setHasOauthError(false) + setIsRowboatConnected(false) setShowOauthAlert(true) } } @@ -428,6 +447,7 @@ export function SidebarContentPanel({ refreshOauthError() const cleanup = window.ipc.on('oauth:didConnect', () => { refreshOauthError() + setLoggingIn(false) }) return () => { @@ -483,17 +503,42 @@ export function SidebarContentPanel({ /> )} + {/* Billing / upgrade CTA or Log in CTA */} + {isRowboatConnected && billing ? ( +
+
+ + {billing.subscriptionPlan ?? 'Free'} plan + + +
+
+ ) : null} + {/* Sign in CTA */} + {!isRowboatConnected && ( +
+ +
+ )} {/* Bottom actions */}
- + {hasOauthError && ( @@ -608,6 +653,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 +690,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 +708,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 +756,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 +778,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 +807,7 @@ ${transcript} }) // Re-select to trigger refresh - onNoteCreated?.(currentNotePath) + onNoteCreatedRef.current?.(currentNotePath) if (transcript) { toast('Voice note transcribed', 'success') @@ -929,6 +981,16 @@ function KnowledgeSection({ ) } +function countFiles(node: TreeNode): number { + if (node.kind === 'file') return 1 + return (node.children ?? []).reduce((sum, child) => sum + countFiles(child), 0) +} + +/** Display name overrides for top-level knowledge folders */ +const FOLDER_DISPLAY_NAMES: Record = { + Notes: 'My Notes', +} + // Tree component for file browser function Tree({ item, @@ -948,6 +1010,7 @@ function Tree({ const isSelected = selectedPath === item.path const [isRenaming, setIsRenaming] = useState(false) const isSubmittingRef = React.useRef(false) + const displayName = (isDir && FOLDER_DISPLAY_NAMES[item.name]) || item.name // For files, strip .md extension for editing const baseName = !isDir && item.name.endsWith('.md') @@ -1076,6 +1139,29 @@ function Tree({ ) } + // Top-level knowledge folders (except Notes) open bases view — render as flat items + const parts = item.path.split('/') + const isBasesFolder = isDir && parts.length === 2 && parts[0] === 'knowledge' && parts[1] !== 'Notes' + + if (isBasesFolder) { + return ( + + + + onSelect(item.path, item.kind)}> + +
+ {displayName} + {countFiles(item)} +
+
+
+
+ {contextMenuContent} +
+ ) + } + if (!isDir) { return ( @@ -1118,7 +1204,10 @@ function Tree({ - {item.name} +
+ {displayName} + {countFiles(item)} +
@@ -1235,9 +1324,6 @@ function TasksSection({ }} >
- {processingRunIds?.has(run.id) ? ( - - ) : null} {run.title || '(Untitled chat)'} {run.createdAt ? ( diff --git a/apps/x/apps/renderer/src/components/tab-bar.tsx b/apps/x/apps/renderer/src/components/tab-bar.tsx index 744f578d..b4374ffb 100644 --- a/apps/x/apps/renderer/src/components/tab-bar.tsx +++ b/apps/x/apps/renderer/src/components/tab-bar.tsx @@ -67,9 +67,6 @@ export function TabBar({ )} style={layout === 'scroll' ? { flex: '0 0 auto' } : { flex: '1 1 0px' }} > - {processing && ( - - )} {title} {(allowSingleTabClose || tabs.length > 1) && ( ): string | undefined { + // Check conferenceData.entryPoints for video entry + const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined + if (confData?.entryPoints) { + const video = confData.entryPoints.find(ep => ep.entryPointType === 'video') + if (video?.uri) return video.uri + } + // Check hangoutLink (Google Meet shortcut) + if (typeof raw.hangoutLink === 'string') return raw.hangoutLink + // Fall back to conferenceLink if present + if (typeof raw.conferenceLink === 'string') return raw.conferenceLink + return undefined +} + +interface ResolvedEvent { + event: blocks.CalendarEvent + loaded: blocks.CalendarEvent | null + conferenceLink?: string +} + +const GCAL_EVENT_COLOR = '#039be5' +const GCAL_TODAY_COLOR = '#1a73e8' + +function JoinMeetingSplitButton({ onJoinAndNotes, onNotesOnly }: { + onJoinAndNotes: () => void + onNotesOnly: () => void +}) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + const target = e.target + if (ref.current && target instanceof globalThis.Node && !ref.current.contains(target)) setOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + return ( +
+ +
+ + {open && ( +
+ +
+ )} +
+
+ ) +} + +// Shared global to pass calendar event data to App.tsx when joining a meeting. +// Set before dispatching the custom event, read by the handler in App.tsx. +declare global { + interface Window { + __pendingCalendarEvent?: { + summary?: string + start?: { dateTime?: string; date?: string } + end?: { dateTime?: string; date?: string } + location?: string + htmlLink?: string + conferenceLink?: string + source?: string + } + } +} + +function CalendarBlockView({ node, deleteNode }: { node: { attrs: Record }; deleteNode: () => void }) { + const raw = node.attrs.data as string + let config: blocks.CalendarBlock | null = null + + try { + config = blocks.CalendarBlockSchema.parse(JSON.parse(raw)) + } catch { + // fallback below + } + + const [resolvedEvents, setResolvedEvents] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!config) return + + const eventsWithSources = config.events.filter(e => e.source) + if (eventsWithSources.length === 0) { + setResolvedEvents(config.events.map(e => ({ event: e, loaded: null }))) + return + } + + setLoading(true) + const ipc = (window as unknown as { ipc: { invoke: (channel: string, args: Record) => Promise<{ data: string }> } }).ipc + + Promise.all( + config.events.map(async (event): Promise => { + if (!event.source) return { event, loaded: null } + try { + const result = await ipc.invoke('workspace:readFile', { path: event.source, encoding: 'utf8' }) + const content = typeof result === 'string' ? result : result.data + const rawEvent = JSON.parse(content) as Record + const parsed = blocks.CalendarEventSchema.parse(rawEvent) + const conferenceLink = extractConferenceLink(rawEvent) + return { event, loaded: parsed, conferenceLink } + } catch { + return { event, loaded: null } + } + }) + ).then(results => { + setResolvedEvents(results) + setLoading(false) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [raw]) + + if (!config) { + return ( + +
+ + Invalid calendar block +
+
+ ) + } + + const showJoinButton = config.showJoinButton === true + + const events = resolvedEvents.map(r => { + const e = r.loaded || r.event + return { + ...e, + htmlLink: e.htmlLink || r.event.htmlLink, + conferenceLink: r.conferenceLink || e.conferenceLink || r.event.conferenceLink, + } + }) + + // Group events by date + const dateGroups: { dateKey: string; dateStr: string; events: (blocks.CalendarEvent & { _idx: number; conferenceLink?: string })[] }[] = [] + + let globalIdx = 0 + for (const event of events) { + const dateStr = getEventDate(event) + const dateKey = dateStr ? new Date(dateStr).toDateString() : 'Unknown' + + let group = dateGroups.find(g => g.dateKey === dateKey) + if (!group) { + group = { dateKey, dateStr, events: [] } + dateGroups.push(group) + } + group.events.push({ ...event, _idx: globalIdx++ }) + } + + const handleEventClick = (event: blocks.CalendarEvent) => { + if (event.htmlLink) { + window.open(event.htmlLink, '_blank') + } + } + + const handleJoinMeeting = (event: blocks.CalendarEvent & { conferenceLink?: string }, resolvedIdx: number, joinCall: boolean) => { + if (joinCall) { + const meetingUrl = event.conferenceLink + if (meetingUrl) { + window.open(meetingUrl, '_blank') + } + } + + // Find the original source path from config + const originalEvent = config!.events[resolvedIdx] + + // Set calendar event data on window so App.tsx handler can read it + window.__pendingCalendarEvent = { + summary: event.summary, + start: event.start, + end: event.end, + location: event.location, + htmlLink: event.htmlLink, + conferenceLink: event.conferenceLink, + source: originalEvent?.source, + } + // Dispatch custom event so App.tsx can start meeting transcription + window.dispatchEvent(new Event('calendar-block:join-meeting')) + } + + return ( + +
+ + {config.title &&
{config.title}
} + {loading ? ( +
Loading events...
+ ) : events.length === 0 ? ( +
No events
+ ) : ( +
+ {dateGroups.map((group, groupIdx) => { + const parts = group.dateStr ? getDateParts(group.dateStr) : null + return ( +
+ {groupIdx > 0 &&
} +
+
+ {parts ? ( + <> + {parts.weekday} + {parts.day} + + ) : ( + ? + )} +
+
+ {group.events.map(event => ( +
e.stopPropagation()} + onClick={(e) => { e.stopPropagation(); handleEventClick(event) }} + > +
+
+ {event.summary || '(No title)'} +
+
+ {getTimeRange(event)} +
+ {showJoinButton && event.conferenceLink && ( + handleJoinMeeting(event, event._idx, true)} + onNotesOnly={() => handleJoinMeeting(event, event._idx, false)} + /> + )} +
+
+ ))} +
+
+
+ ) + })} +
+ )} +
+ + ) +} + +export const CalendarBlockExtension = TiptapNode.create({ + name: 'calendarBlock', + 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-calendar')) { + return { data: code.textContent || '{}' } + } + return false + }, + }, + ] + }, + + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'calendar-block' })] + }, + + addNodeView() { + return ReactNodeViewRenderer(CalendarBlockView) + }, + + addStorage() { + return { + markdown: { + serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) { + state.write('```calendar\n' + node.attrs.data + '\n```') + state.closeBlock(node) + }, + parse: { + // handled by parseHTML + }, + }, + } + }, +}) 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/email-block.tsx b/apps/x/apps/renderer/src/extensions/email-block.tsx new file mode 100644 index 00000000..7356c94c --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/email-block.tsx @@ -0,0 +1,286 @@ +import { mergeAttributes, Node } from '@tiptap/react' +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' +import { X, Mail, ChevronDown, ExternalLink, Copy, Check, MessageSquare } from 'lucide-react' +import { blocks } from '@x/shared' +import { useState, useEffect, useRef, useCallback } from 'react' +import { useTheme } from '@/contexts/theme-context' + +// --- Helpers --- + +function formatEmailDate(dateStr: string): string { + try { + const d = new Date(dateStr) + if (isNaN(d.getTime())) return dateStr + return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) + + ' ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + } catch { + return dateStr + } +} + +/** Extract just the name part from "Name " format */ +function senderFirstName(from: string): string { + const name = from.replace(/<.*>/, '').trim() + return name.split(/\s+/)[0] || name +} + +declare global { + interface Window { + __pendingEmailDraft?: { prompt: string } + } +} + +// --- Email Block --- + +function EmailBlockView({ node, deleteNode, updateAttributes }: { + node: { attrs: Record } + deleteNode: () => void + updateAttributes: (attrs: Record) => void +}) { + const raw = node.attrs.data as string + let config: blocks.EmailBlock | null = null + + try { + config = blocks.EmailBlockSchema.parse(JSON.parse(raw)) + } catch { + // fallback below + } + + const hasDraft = !!config?.draft_response + const hasPastSummary = !!config?.past_summary + + const { resolvedTheme } = useTheme() + + // Local draft state for editing + const [draftBody, setDraftBody] = useState(config?.draft_response || '') + const [emailExpanded, setEmailExpanded] = useState(false) + const [copied, setCopied] = useState(false) + const bodyRef = useRef(null) + + // Sync draft from external changes + useEffect(() => { + try { + const parsed = blocks.EmailBlockSchema.parse(JSON.parse(raw)) + setDraftBody(parsed.draft_response || '') + } catch { /* ignore */ } + }, [raw]) + + // Auto-resize textarea + useEffect(() => { + if (bodyRef.current) { + bodyRef.current.style.height = 'auto' + bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px' + } + }, [draftBody]) + + const commitDraft = useCallback((newBody: string) => { + try { + const current = JSON.parse(raw) as Record + updateAttributes({ data: JSON.stringify({ ...current, draft_response: newBody }) }) + } catch { /* ignore */ } + }, [raw, updateAttributes]) + + const draftWithAssistant = useCallback(() => { + if (!config) return + let prompt = draftBody + ? `Help me refine this draft response to an email` + : `Help me draft a response to this email` + if (config.threadId) { + prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context` + } + prompt += `.\n\n` + prompt += `**From:** ${config.from || 'Unknown'}\n` + prompt += `**Subject:** ${config.subject || 'No subject'}\n` + if (draftBody) { + prompt += `\n**Current draft:**\n${draftBody}\n` + } + window.__pendingEmailDraft = { prompt } + window.dispatchEvent(new Event('email-block:draft-with-assistant')) + }, [config, draftBody]) + + if (!config) { + return ( + +
+ + Invalid email block +
+
+ ) + } + + const gmailUrl = config.threadId + ? `https://mail.google.com/mail/u/0/#all/${config.threadId}` + : null + + // Build summary: use explicit summary, or auto-generate from sender + subject + const summary = config.summary + || (config.from && config.subject + ? `${senderFirstName(config.from)} reached out about ${config.subject}` + : config.subject || 'New email') + + return ( + +
e.stopPropagation()}> + + + {/* Header: Email badge */} +
+ + Email +
+ + {/* Summary */} +
{summary}
+ + {/* Expandable email details */} + + + {emailExpanded && ( +
+
+
+
+
+
{config.from || 'Unknown'}
+ {config.date &&
{formatEmailDate(config.date)}
} +
+ {config.subject &&
Subject: {config.subject}
} +
+
+
{config.latest_email}
+
+ {hasPastSummary && ( +
+
Earlier conversation
+
{config.past_summary}
+
+ )} +
+ )} + + {/* Draft section */} + {hasDraft && ( +
+
Draft reply
+