diff --git a/README.md b/README.md index 640ee35c..46be6f49 100644 --- a/README.md +++ b/README.md @@ -59,15 +59,19 @@ Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/do ### Google setup To connect Google services (Gmail, Calendar, and Drive), follow [Google setup](https://github.com/rowboatlabs/rowboat/blob/main/google-setup.md). -### Voice notes -To enable voice notes (optional), add a Deepgram API key in ~/.rowboat/config/deepgram.json: +### Voice input +To enable voice input and voice notes (optional), add a Deepgram API key in ~/.rowboat/config/deepgram.json: ``` { "apiKey": "" } ``` + +### Voice output + +To enable voice output (optional), add a Elevenlabs API key in ~/.rowboat/config/elevenlabs.json + ### Web search -To use Brave web search (optional), add the Brave API key in ~/.rowboat/config/brave-search.json. To use Exa research search (optional), add the Exa API key in ~/.rowboat/config/exa-search.json. 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/package.json b/apps/x/apps/main/package.json index c777a237..74cb1598 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -17,6 +17,7 @@ "@x/shared": "workspace:*", "chokidar": "^4.0.3", "electron-squirrel-startup": "^1.0.1", + "html-to-docx": "^1.8.0", "mammoth": "^1.11.0", "papaparse": "^5.5.3", "pdf-parse": "^2.4.5", diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index 452a76e3..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 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/html-to-docx.d.ts b/apps/x/apps/main/src/html-to-docx.d.ts new file mode 100644 index 00000000..24f298ef --- /dev/null +++ b/apps/x/apps/main/src/html-to-docx.d.ts @@ -0,0 +1,7 @@ +declare module 'html-to-docx' { + export default function htmlToDocx( + htmlString: string, + headerHTMLString?: string, + options?: Record, + ): Promise; +} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 4d272275..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 } 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'; @@ -15,23 +15,100 @@ import { bus } from '@x/core/dist/runs/bus.js'; import { serviceBus } from '@x/core/dist/services/service_bus.js'; import type { FSWatcher } from 'chokidar'; import fs from 'node:fs/promises'; +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; import z from 'zod'; + +const execAsync = promisify(exec); import { RunEvent } from '@x/shared/dist/runs.js'; import { ServiceEvent } from '@x/shared/dist/service-events.js'; import container from '@x/core/dist/di/container.js'; import { listOnboardingModels } from '@x/core/dist/models/models-dev.js'; import { testModelConnection } from '@x/core/dist/models/models.js'; +import { isSignedIn } from '@x/core/dist/account/account.js'; +import { listGatewayModels } from '@x/core/dist/models/gateway.js'; import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; +import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import * as composioHandler from './composio-handler.js'; import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; import { search } from '@x/core/dist/search/search.js'; -import { versionHistory } from '@x/core'; +import { versionHistory, voice } from '@x/core'; +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. + */ +function markdownToHtml(markdown: string, title: string): string { + // Simple markdown to HTML conversion for export purposes + let html = markdown + // Resolve wiki links [[Folder/Note Name]] or [[Folder/Note Name|Display]] to plain text + .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, (_match, _path, display) => display.trim()) + .replace(/\[\[([^\]]+)\]\]/g, (_match, linkPath: string) => { + // Use the last segment (filename) as the display name + const segments = linkPath.trim().split('/') + return segments[segments.length - 1] + }) + // Escape HTML entities (but preserve markdown syntax) + .replace(/&/g, '&') + .replace(//g, '>') + + // Headings (must come before other processing) + html = html.replace(/^######\s+(.+)$/gm, '
$1
') + html = html.replace(/^#####\s+(.+)$/gm, '
$1
') + html = html.replace(/^####\s+(.+)$/gm, '

$1

') + html = html.replace(/^###\s+(.+)$/gm, '

$1

') + html = html.replace(/^##\s+(.+)$/gm, '

$1

') + html = html.replace(/^#\s+(.+)$/gm, '

$1

') + + // Bold and italic + html = html.replace(/\*\*\*(.+?)\*\*\*/g, '$1') + html = html.replace(/\*\*(.+?)\*\*/g, '$1') + html = html.replace(/\*(.+?)\*/g, '$1') + + // Inline code + html = html.replace(/`([^`]+)`/g, '$1') + + // Horizontal rules + html = html.replace(/^---$/gm, '
') + + // Unordered lists + html = html.replace(/^[-*]\s+(.+)$/gm, '
  • $1
  • ') + + // Links + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + + // Blockquotes + html = html.replace(/^>\s+(.+)$/gm, '
    $1
    ') + + // Paragraphs: wrap remaining lines that aren't already wrapped in HTML tags + html = html.replace(/^(?!<[a-z/])((?!^\s*$).+)$/gm, '

    $1

    ') + + // Clean up consecutive list items into lists + html = html.replace(/(
  • .*<\/li>\n?)+/g, (match) => `
      ${match}
    `) + + return ` +${title} +${html}` +} type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -69,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); }); @@ -344,7 +421,7 @@ export function setupIpcHandlers() { return runsCore.createRun(args); }, 'runs:createMessage': async (_event, args) => { - return { messageId: await runsCore.createMessage(args.runId, args.message) }; + return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled) }; }, 'runs:authorizePermission': async (_event, args) => { await runsCore.authorizePermission(args.runId, args.authorization); @@ -369,6 +446,9 @@ export function setupIpcHandlers() { return { success: true }; }, 'models:list': async () => { + if (await isSignedIn()) { + return await listGatewayModels(); + } return await listOnboardingModels(); }, 'models:test': async (_event, args) => { @@ -393,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(); @@ -409,6 +504,30 @@ export function setupIpcHandlers() { return { success: true }; }, + 'slack:getConfig': async () => { + const repo = container.resolve('slackConfigRepo'); + const config = await repo.getConfig(); + return { enabled: config.enabled, workspaces: config.workspaces }; + }, + 'slack:setConfig': async (_event, args) => { + const repo = container.resolve('slackConfigRepo'); + await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces }); + return { success: true }; + }, + 'slack:listWorkspaces': async () => { + try { + const { stdout } = await execAsync('agent-slack auth whoami', { timeout: 10000 }); + const parsed = JSON.parse(stdout); + const workspaces = (parsed.workspaces || []).map((w: { workspace_url?: string; workspace_name?: string }) => ({ + url: w.workspace_url || '', + name: w.workspace_name || '', + })); + return { workspaces }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to list Slack workspaces'; + return { workspaces: [], error: message }; + } + }, 'onboarding:getStatus': async () => { // Show onboarding if it hasn't been completed yet const complete = isOnboardingComplete(); @@ -440,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 () => { @@ -531,5 +657,107 @@ export function setupIpcHandlers() { 'search:query': async (_event, args) => { return search(args.query, args.limit, args.types); }, + // Inline task schedule classification + 'export:note': async (event, args) => { + const { markdown, format, title } = args; + const sanitizedTitle = title.replace(/[/\\?%*:|"<>]/g, '-').trim() || 'Untitled'; + + const filterMap: Record = { + md: [{ name: 'Markdown', extensions: ['md'] }], + pdf: [{ name: 'PDF', extensions: ['pdf'] }], + docx: [{ name: 'Word Document', extensions: ['docx'] }], + }; + + const win = BrowserWindow.fromWebContents(event.sender); + const result = await dialog.showSaveDialog(win!, { + defaultPath: `${sanitizedTitle}.${format}`, + filters: filterMap[format], + }); + + if (result.canceled || !result.filePath) { + return { success: false }; + } + + const filePath = result.filePath; + + if (format === 'md') { + await fs.writeFile(filePath, markdown, 'utf8'); + return { success: true }; + } + + if (format === 'pdf') { + // Render markdown as HTML in a hidden window, then print to PDF + const htmlContent = markdownToHtml(markdown, sanitizedTitle); + const hiddenWin = new BrowserWindow({ + show: false, + width: 800, + height: 600, + webPreferences: { offscreen: true }, + }); + await hiddenWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`); + // Small delay to ensure CSS/fonts render + await new Promise(resolve => setTimeout(resolve, 300)); + const pdfBuffer = await hiddenWin.webContents.printToPDF({ + printBackground: true, + pageSize: 'A4', + }); + hiddenWin.destroy(); + await fs.writeFile(filePath, pdfBuffer); + return { success: true }; + } + + if (format === 'docx') { + const htmlContent = markdownToHtml(markdown, sanitizedTitle); + const { default: htmlToDocx } = await import('html-to-docx'); + const docxBuffer = await htmlToDocx(htmlContent, undefined, { + table: { row: { cantSplit: true } }, + footer: false, + header: false, + }); + await fs.writeFile(filePath, Buffer.from(docxBuffer as ArrayBuffer)); + return { success: true }; + } + + 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); + }, + // 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 34363b28..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 } from "electron"; +import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session } from "electron"; import path from "node:path"; import { setupIpcHandlers, @@ -17,9 +17,18 @@ import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.j import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js"; import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js"; import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js"; +import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.js"; +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); @@ -27,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") @@ -89,8 +120,30 @@ function createWindow() { }, }); + // Grant microphone and display-capture permissions + session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { + 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(); }); @@ -135,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(); @@ -170,9 +236,24 @@ app.whenReady().then(async () => { // start knowledge graph builder initGraphBuilder(); + // start email labeling service + initEmailLabeling(); + + // start note tagging service + initNoteTagging(); + + // start inline task service (@rowboat: mentions) + initInlineTasks(); + // 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 bf9c77ff..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,7 +186,11 @@ export async function connectProvider(provider: string, clientId?: string): Prom }); // Create callback server + 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 (params.state !== state) { throw new Error('Invalid state parameter - possible CSRF attack'); @@ -282,6 +286,8 @@ export async function disconnectProvider(provider: string): Promise<{ success: b try { const oauthRepo = getOAuthRepo(); await oauthRepo.delete(provider); + // Notify renderer so sidebar, voice, and billing re-check state + emitOAuthEvent({ provider, success: false }); return { success: true }; } catch (error) { console.error('OAuth disconnect failed:', error); diff --git a/apps/x/apps/renderer/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 af6a4740..9107189a 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,13 +5,14 @@ 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'; import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; +import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view'; import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; @@ -19,7 +20,7 @@ import { Conversation, ConversationContent, ConversationEmptyState, - ScrollPositionPreserver, + ConversationScrollButton, } from '@/components/ai-elements/conversation'; import { Message, @@ -32,8 +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'; @@ -44,9 +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 { OnboardingModal } from '@/components/onboarding-modal' +import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' +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' @@ -55,11 +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, @@ -69,9 +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 @@ -84,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 = [ @@ -105,6 +127,7 @@ const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12 const TITLEBAR_BUTTONS_COLLAPSED = 5 const TITLEBAR_BUTTON_GAPS_COLLAPSED = 4 const GRAPH_TAB_PATH = '__rowboat_graph_view__' +const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) @@ -232,6 +255,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => { } const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH +const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => { if (!usage) return null @@ -250,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) { @@ -263,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() @@ -324,6 +454,10 @@ function FixedSidebarToggle({ canNavigateForward, onNewChat, onOpenSearch, + meetingState, + meetingSummarizing, + meetingAvailable, + onToggleMeeting, leftInsetPx, }: { onNavigateBack: () => void @@ -332,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() @@ -367,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 && ( <> @@ -457,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('') @@ -469,6 +640,7 @@ function App() { const [recentWikiFiles, setRecentWikiFiles] = useState([]) const [isGraphOpen, setIsGraphOpen] = useState(false) const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null) + const [baseConfigByPath, setBaseConfigByPath] = useState>({}) const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ nodes: [], edges: [], @@ -491,6 +663,11 @@ function App() { const editorPathRef = useRef(null) const fileLoadRequestIdRef = useRef(0) const initialContentByPathRef = useRef>(new Map()) + const recentLocalMarkdownWritesRef = useRef>(new Map()) + const untitledRenameReadyPathsRef = useRef>(new Set()) + + // Pending app-navigation result to process once navigation functions are ready + const pendingAppNavRef = useRef | null>(null) // Global navigation history (back/forward) across views (chat/file/graph/task) const historyRef = useRef<{ back: ViewState[]; forward: ViewState[] }>({ back: [], forward: [] }) @@ -507,6 +684,9 @@ function App() { const initialContentRef = useRef('') const renameInProgressRef = useRef(false) + // Frontmatter state: store raw frontmatter per file path + const frontmatterByPathRef = useRef>(new Map()) + // Version history state const [versionHistoryPath, setVersionHistoryPath] = useState(null) const [viewingHistoricalVersion, setViewingHistoricalVersion] = useState<{ @@ -531,6 +711,125 @@ function App() { const [agentId] = useState('copilot') const [presetMessage, setPresetMessage] = useState(undefined) + // Voice mode state + const [voiceAvailable, setVoiceAvailable] = useState(false) + const [ttsAvailable, setTtsAvailable] = useState(false) + const [ttsEnabled, setTtsEnabled] = useState(false) + const ttsEnabledRef = useRef(false) + const [ttsMode, setTtsMode] = useState<'summary' | 'full'>('summary') + const ttsModeRef = useRef<'summary' | 'full'>('summary') + const [isRecording, setIsRecording] = useState(false) + const voiceTextBufferRef = useRef('') + const spokenIndexRef = useRef(0) + const isRecordingRef = useRef(false) + + const tts = useVoiceTTS() + const ttsRef = useRef(tts) + ttsRef.current = tts + + const voice = useVoiceMode() + const voiceRef = useRef(voice) + voiceRef.current = voice + + 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 + 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) + isRecordingRef.current = true + voice.start() + }, [voice]) + + const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean) => Promise) | null>(null) + const pendingVoiceInputRef = useRef(false) + + const handleSubmitRecording = useCallback(() => { + const text = voice.submit() + setIsRecording(false) + isRecordingRef.current = false + if (text) { + pendingVoiceInputRef.current = true + handlePromptSubmitRef.current?.({ text, files: [] }) + } + }, [voice]) + + const handleToggleTts = useCallback(() => { + setTtsEnabled(prev => { + const next = !prev + ttsEnabledRef.current = next + if (!next) { + ttsRef.current.cancel() + } + return next + }) + }, []) + + const handleTtsModeChange = useCallback((mode: 'summary' | 'full') => { + setTtsMode(mode) + ttsModeRef.current = mode + }, []) + + const handleCancelRecording = useCallback(() => { + voice.cancel() + setIsRecording(false) + 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) { + voiceRef.current.cancel() + setIsRecording(false) + isRecordingRef.current = false + } + }, []) + // Runs history state type RunListItem = { id: string; title?: string; createdAt: string; agentId: string } const [runs, setRuns] = useState([]) @@ -547,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) => { @@ -572,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( @@ -614,6 +926,8 @@ function App() { const getFileTabTitle = useCallback((tab: FileTab) => { if (isGraphTabPath(tab.path)) return 'Graph View' + if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' + if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path }, []) @@ -671,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('') @@ -738,6 +1068,7 @@ function App() { const removeEditorCacheForPath = useCallback((path: string) => { editorContentByPathRef.current.delete(path) + untitledRenameReadyPathsRef.current.delete(path) setEditorContentByPath((prev) => { if (!(path in prev)) return prev const next = { ...prev } @@ -746,6 +1077,29 @@ function App() { }) }, []) + const markRecentLocalMarkdownWrite = useCallback((path: string) => { + if (!path.endsWith('.md')) return + const now = Date.now() + recentLocalMarkdownWritesRef.current.set(path, now) + if (recentLocalMarkdownWritesRef.current.size > 200) { + for (const [knownPath, timestamp] of recentLocalMarkdownWritesRef.current.entries()) { + if (now - timestamp > 10_000) { + recentLocalMarkdownWritesRef.current.delete(knownPath) + } + } + } + }, []) + + const consumeRecentLocalMarkdownWrite = useCallback((path: string, windowMs: number = 2_500) => { + const timestamp = recentLocalMarkdownWritesRef.current.get(path) + if (timestamp === undefined) return false + const isRecent = Date.now() - timestamp <= windowMs + if (!isRecent) { + recentLocalMarkdownWritesRef.current.delete(path) + } + return isRecent + }, []) + const handleEditorChange = useCallback((path: string, markdown: string) => { setEditorCacheForPath(path, markdown) const nextSelectedPath = selectedPathRef.current @@ -787,20 +1141,47 @@ function App() { } }, [runId, processingRunIds]) - // Load directory tree + // Load directory tree (knowledge + bases) const loadDirectory = useCallback(async () => { try { - const result = await window.ipc.invoke('workspace:readdir', { - path: 'knowledge', - opts: { recursive: true, includeHidden: false } - }) - return buildTree(result) + const [knowledgeResult, basesResult] = await Promise.all([ + window.ipc.invoke('workspace:readdir', { + path: 'knowledge', + opts: { recursive: true, includeHidden: false, includeStats: true } + }), + window.ipc.invoke('workspace:readdir', { + path: 'bases', + opts: { recursive: false, includeHidden: false, includeStats: true } + }).catch(() => [] as DirEntry[]), + ]) + const knowledgeTree = flattenMeetingsTree(buildTree(knowledgeResult)) + const basesChildren: TreeNode[] = (basesResult as DirEntry[]) + .filter((e) => e.name.endsWith('.base')) + .map((e) => ({ ...e, kind: 'file' as const })) + if (basesChildren.length > 0) { + const basesFolder: TreeNode = { + name: 'Bases', + path: 'bases', + kind: 'dir', + children: basesChildren, + } + return [...knowledgeTree, basesFolder] + } + return knowledgeTree } catch (err) { console.error('Failed to load directory:', err) return [] } }, []) + // 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 useEffect(() => { loadDirectory().then(setTree) @@ -856,18 +1237,24 @@ function App() { changedPath === pathToReload || changedPaths.includes(pathToReload) if (isCurrentFileChanged) { + // Ignore immediate watcher echoes of our own autosaves to preserve undo history. + if (consumeRecentLocalMarkdownWrite(pathToReload)) { + return + } // Only reload if no unsaved edits const baseline = initialContentByPathRef.current.get(pathToReload) ?? initialContentRef.current if (editorContentRef.current === baseline) { const result = await window.ipc.invoke('workspace:readFile', { path: pathToReload }) if (selectedPathRef.current !== pathToReload) return setFileContent(result.data) - setEditorContent(result.data) - setEditorCacheForPath(pathToReload, result.data) - editorContentRef.current = result.data + const { raw: fm, body } = splitFrontmatter(result.data) + frontmatterByPathRef.current.set(pathToReload, fm) + setEditorContent(body) + setEditorCacheForPath(pathToReload, body) + editorContentRef.current = body editorPathRef.current = pathToReload - initialContentByPathRef.current.set(pathToReload, result.data) - initialContentRef.current = result.data + initialContentByPathRef.current.set(pathToReload, body) + initialContentRef.current = body } } }) @@ -885,9 +1272,37 @@ function App() { setLastSaved(null) return } + if (selectedPath === BASES_DEFAULT_TAB_PATH) { + // Virtual default base — no file to load, use DEFAULT_BASE_CONFIG + if (!baseConfigByPath[selectedPath]) { + setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: { ...DEFAULT_BASE_CONFIG } })) + } + return + } + if (selectedPath.endsWith('.base')) { + // Load base config from file only if not already cached + if (!baseConfigByPath[selectedPath]) { + window.ipc.invoke('workspace:readFile', { path: selectedPath, encoding: 'utf8' }) + .then((result: { data: string }) => { + try { + const parsed = JSON.parse(result.data) as BaseConfig + setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: parsed })) + } catch { + setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: { ...DEFAULT_BASE_CONFIG } })) + } + }) + .catch(() => { + setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: { ...DEFAULT_BASE_CONFIG } })) + }) + } + return + } if (selectedPath.endsWith('.md')) { const cachedContent = editorContentByPathRef.current.get(selectedPath) - if (cachedContent !== undefined) { + const hasBaseline = initialContentByPathRef.current.has(selectedPath) + // Only trust cache after we've loaded/saved this file at least once. + // This avoids a first-open race where an early empty editor update can poison the cache. + if (cachedContent !== undefined && hasBaseline) { setFileContent(cachedContent) setEditorContent(cachedContent) editorContentRef.current = cachedContent @@ -901,36 +1316,46 @@ function App() { let cancelled = false ;(async () => { try { - const stat = await window.ipc.invoke('workspace:stat', { path: pathToLoad }) - if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return - if (stat.kind === 'file') { - const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad }) + // For .md files (from the knowledge tree), skip stat and read directly. + // For other file types, stat first to check if it's a file vs directory. + const isKnownFile = pathToLoad.endsWith('.md') + if (!isKnownFile) { + const stat = await window.ipc.invoke('workspace:stat', { path: pathToLoad }) if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return - setFileContent(result.data) - const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() - const isSameEditorFile = editorPathRef.current === pathToLoad - const wouldClobberActiveEdits = - isSameEditorFile - && normalizeForCompare(editorContentRef.current) !== normalizeForCompare(result.data) - if (!wouldClobberActiveEdits) { - setEditorContent(result.data) - if (pathToLoad.endsWith('.md')) { - setEditorCacheForPath(pathToLoad, result.data) - } - editorContentRef.current = result.data - editorPathRef.current = pathToLoad - initialContentByPathRef.current.set(pathToLoad, result.data) - initialContentRef.current = result.data - setLastSaved(null) - } else { - // Still update the editor's path so subsequent autosaves write to the correct file. - editorPathRef.current = pathToLoad + if (stat.kind !== 'file') { + setFileContent('') + setEditorContent('') + editorContentRef.current = '' + initialContentRef.current = '' + return } + } + const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad }) + if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return + setFileContent(result.data) + const { raw: fm, body } = splitFrontmatter(result.data) + frontmatterByPathRef.current.set(pathToLoad, fm) + const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() + const isSameEditorFile = editorPathRef.current === pathToLoad + const knownBaseline = initialContentByPathRef.current.get(pathToLoad) + const hasKnownBaseline = knownBaseline !== undefined + const hasUnsavedEdits = + hasKnownBaseline + && normalizeForCompare(editorContentRef.current) !== normalizeForCompare(knownBaseline) + const shouldPreserveActiveDraft = isSameEditorFile && hasUnsavedEdits + if (!shouldPreserveActiveDraft) { + setEditorContent(body) + if (pathToLoad.endsWith('.md')) { + setEditorCacheForPath(pathToLoad, body) + } + editorContentRef.current = body + editorPathRef.current = pathToLoad + initialContentByPathRef.current.set(pathToLoad, body) + initialContentRef.current = body + setLastSaved(null) } else { - setFileContent('') - setEditorContent('') - editorContentRef.current = '' - initialContentRef.current = '' + // Still update the editor's path so subsequent autosaves write to the correct file. + editorPathRef.current = pathToLoad } } catch (err) { console.error('Failed to load file:', err) @@ -970,7 +1395,7 @@ function App() { const wasActiveAtStart = selectedPathRef.current === pathAtStart if (wasActiveAtStart) setIsSaving(true) let pathToSave = pathAtStart - let contentToSave = debouncedContent + let contentToSave = joinFrontmatter(frontmatterByPathRef.current.get(pathAtStart) ?? null, debouncedContent) let renamedFrom: string | null = null let renamedTo: string | null = null try { @@ -985,7 +1410,8 @@ function App() { if (isUntitledPlaceholderName(currentBase)) { const headingTitle = getHeadingTitle(debouncedContent) const desiredName = headingTitle ? sanitizeHeadingForFilename(headingTitle) : null - if (desiredName && desiredName !== currentBase) { + const shouldAutoRename = untitledRenameReadyPathsRef.current.has(pathAtStart) + if (shouldAutoRename && desiredName && desiredName !== currentBase) { const parentDir = pathAtStart.split('/').slice(0, -1).join('/') let targetPath = `${parentDir}/${desiredName}.md` if (targetPath !== pathAtStart) { @@ -999,15 +1425,21 @@ function App() { renameInProgressRef.current = true await window.ipc.invoke('workspace:rename', { from: pathAtStart, to: targetPath }) pathToSave = targetPath - contentToSave = rewriteWikiLinksForRenamedFileInMarkdown( + const rewrittenBody = rewriteWikiLinksForRenamedFileInMarkdown( debouncedContent, pathAtStart, targetPath ) + contentToSave = joinFrontmatter(frontmatterByPathRef.current.get(pathAtStart) ?? null, rewrittenBody) renamedFrom = pathAtStart renamedTo = targetPath editorPathRef.current = targetPath + untitledRenameReadyPathsRef.current.delete(pathAtStart) setFileTabs(prev => prev.map(tab => (tab.path === pathAtStart ? { ...tab, path: targetPath } : tab))) + // Migrate frontmatter entry + const fmEntry = frontmatterByPathRef.current.get(pathAtStart) + frontmatterByPathRef.current.delete(pathAtStart) + frontmatterByPathRef.current.set(targetPath, fmEntry ?? null) initialContentByPathRef.current.delete(pathAtStart) const cachedContent = editorContentByPathRef.current.get(pathAtStart) if (cachedContent !== undefined) { @@ -1032,8 +1464,9 @@ function App() { }) } if (selectedPathRef.current === pathAtStart) { - editorContentRef.current = contentToSave - setEditorContent(contentToSave) + const bodyForEditor = splitFrontmatter(contentToSave).body + editorContentRef.current = bodyForEditor + setEditorContent(bodyForEditor) } } } @@ -1044,7 +1477,9 @@ function App() { data: contentToSave, opts: { encoding: 'utf8' } }) - initialContentByPathRef.current.set(pathToSave, contentToSave) + markRecentLocalMarkdownWrite(pathToSave) + // Store body-only baseline (matches what debouncedContent compares against) + initialContentByPathRef.current.set(pathToSave, splitFrontmatter(contentToSave).body) // If we renamed the active file, update state/history AFTER the write completes so the editor // doesn't reload stale on-disk content mid-typing (which can drop the latest character). @@ -1065,7 +1500,7 @@ function App() { // Only update "current file" UI state if we're still on this file if (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave) { - initialContentRef.current = contentToSave + initialContentRef.current = splitFrontmatter(contentToSave).body setLastSaved(new Date()) } } catch (err) { @@ -1078,7 +1513,7 @@ function App() { } } saveFile() - }, [debouncedContent, setHistory]) + }, [debouncedContent, markRecentLocalMarkdownWrite, setHistory]) // Close version history panel when switching files useEffect(() => { @@ -1376,6 +1811,9 @@ function App() { if (!isActiveRun) return setIsProcessing(true) setModelUsage(null) + // Reset voice buffer for new response + voiceTextBufferRef.current = '' + spokenIndexRef.current = 0 break case 'run-processing-end': @@ -1425,6 +1863,20 @@ function App() { if (llmEvent.type === 'text-delta' && llmEvent.delta) { appendStreamingBuffer(event.runId, llmEvent.delta) setCurrentAssistantMessage(prev => prev + llmEvent.delta) + + // Extract tags and send to TTS when enabled + voiceTextBufferRef.current += llmEvent.delta + const remaining = voiceTextBufferRef.current.substring(spokenIndexRef.current) + const voiceRegex = /([\s\S]*?)<\/voice>/g + let voiceMatch: RegExpExecArray | null + while ((voiceMatch = voiceRegex.exec(remaining)) !== null) { + const voiceContent = voiceMatch[1].trim() + console.log('[voice] extracted voice tag:', voiceContent) + if (voiceContent && ttsEnabledRef.current) { + ttsRef.current.speak(voiceContent) + } + spokenIndexRef.current += voiceMatch.index + voiceMatch[0].length + } } else if (llmEvent.type === 'tool-call') { setConversation(prev => [...prev, { id: llmEvent.toolCallId || `tool-${Date.now()}`, @@ -1449,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 ))) @@ -1464,6 +1916,7 @@ function App() { if (msg.role === 'assistant') { setCurrentAssistantMessage(currentMsg => { if (currentMsg) { + const cleanedContent = currentMsg.replace(/<\/?voice>/g, '') setConversation(prev => { const exists = prev.some(m => m.id === event.messageId && 'role' in m && m.role === 'assistant' @@ -1472,7 +1925,7 @@ function App() { return [...prev, { id: event.messageId, role: 'assistant', - content: currentMsg, + content: cleanedContent, timestamp: Date.now(), }] }) @@ -1545,6 +1998,15 @@ function App() { } return next }) + + // Handle app-navigation tool results — trigger UI side effects + if (event.toolName === 'app-navigation') { + const result = event.result as { success?: boolean; action?: string; [key: string]: unknown } | undefined + if (result?.success) { + pendingAppNavRef.current = result + } + } + break } @@ -1662,10 +2124,12 @@ function App() { const handlePromptSubmit = async ( message: PromptInputMessage, mentions?: FileMention[], - stagedAttachments: StagedAttachment[] = [] + stagedAttachments: StagedAttachment[] = [], + searchEnabled?: boolean, ) => { if (isProcessing) return + const submitTabId = activeChatTabIdRef.current const { text } = message const userMessage = text.trim() const hasAttachments = stagedAttachments.length > 0 @@ -1690,6 +2154,7 @@ function App() { attachments: displayAttachments, timestamp: Date.now(), }]) + setChatViewportAnchor(submitTabId, userMessageId) try { let currentRunId = runId @@ -1702,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 ))) @@ -1758,6 +2224,14 @@ function App() { await window.ipc.invoke('runs:createMessage', { runId: currentRunId, message: attachmentPayload, + voiceInput: pendingVoiceInputRef.current || undefined, + 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. @@ -1786,11 +2260,21 @@ function App() { await window.ipc.invoke('runs:createMessage', { runId: currentRunId, message: formattedMessage, + voiceInput: pendingVoiceInputRef.current || undefined, + 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 } + pendingVoiceInputRef.current = false + if (isNewRun) { const inferredTitle = inferRunTitleFromMessage(titleSource) setRuns((prev) => { @@ -1807,6 +2291,13 @@ function App() { console.error('Failed to send message:', error) } } + 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 @@ -1885,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) => { @@ -1907,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] @@ -1936,6 +2429,7 @@ function App() { }, []) const openChatInNewTab = useCallback((targetRunId: string) => { + cancelRecordingIfActive() const existingTab = chatTabs.find(t => t.runId === targetRunId) if (existingTab) { // Cancel stale in-flight loads from previously focused tabs. @@ -1951,12 +2445,18 @@ function App() { setChatTabs(prev => [...prev, { id, runId: targetRunId }]) setActiveChatTabId(id) loadRun(targetRunId) - }, [chatTabs, loadRun, restoreChatTabState]) + }, [chatTabs, loadRun, restoreChatTabState, cancelRecordingIfActive]) const switchChatTab = useCallback((tabId: string) => { const tab = chatTabs.find(t => t.id === tabId) if (!tab) return if (tabId === activeChatTabId) return + // Cancel any active recording when switching tabs + if (isRecordingRef.current) { + voiceRef.current.cancel() + setIsRecording(false) + isRecordingRef.current = false + } saveChatScrollForTab(activeChatTabId) // Cancel stale in-flight loads from previously focused tabs. loadRunRequestIdRef.current += 1 @@ -2119,20 +2619,29 @@ function App() { const closeFileTab = useCallback((tabId: string) => { const closingTab = fileTabs.find(t => t.id === tabId) - if (closingTab && !isGraphTabPath(closingTab.path)) { + if (closingTab && !isGraphTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { removeEditorCacheForPath(closingTab.path) initialContentByPathRef.current.delete(closingTab.path) + untitledRenameReadyPathsRef.current.delete(closingTab.path) + frontmatterByPathRef.current.delete(closingTab.path) if (editorPathRef.current === closingTab.path) { editorPathRef.current = null } } + if (closingTab && isBaseFilePath(closingTab.path)) { + setBaseConfigByPath((prev) => { + const next = { ...prev } + delete next[closingTab.path] + return next + }) + } setFileTabs(prev => { if (prev.length <= 1) { // Last file tab - close it and go back to chat setActiveFileTabId(null) setSelectedPath(null) setIsGraphOpen(false) - return [] + return [] } const idx = prev.findIndex(t => t.id === tabId) if (idx === -1) return prev @@ -2146,7 +2655,7 @@ function App() { setIsGraphOpen(true) } else { setIsGraphOpen(false) - setSelectedPath(newActiveTab.path) + setSelectedPath(newActiveTab.path) } } return next @@ -2254,7 +2763,7 @@ function App() { if (activeFileTabId) { const activeTab = fileTabs.find((tab) => tab.id === activeFileTabId) - if (activeTab && !isGraphTabPath(activeTab.path)) { + if (activeTab && !isGraphTabPath(activeTab.path) && !isBaseFilePath(activeTab.path)) { setFileTabs((prev) => prev.map((tab) => ( tab.id === activeFileTabId ? { ...tab, path } : tab ))) @@ -2333,13 +2842,14 @@ function App() { const current = currentViewState if (viewStatesEqual(current, nextView)) return + cancelRecordingIfActive() const nextHistory = { back: appendUnique(historyRef.current.back, current), forward: [] as ViewState[], } setHistory(nextHistory) await applyViewState(nextView) - }, [appendUnique, applyViewState, currentViewState, setHistory]) + }, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory]) const navigateBack = useCallback(async () => { const { back, forward } = historyRef.current @@ -2399,6 +2909,146 @@ function App() { void navigateToView({ type: 'file', path }) }, [navigateToView]) + const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => { + setBaseConfigByPath((prev) => ({ ...prev, [path]: config })) + }, []) + + const handleBaseSave = useCallback(async (name: string | null) => { + if (!selectedPath) return + const isDefault = selectedPath === BASES_DEFAULT_TAB_PATH + const config = baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG + + if (isDefault && name) { + // Save as new base file + const safeName = name.replace(/[\\/]/g, '-').trim() + const newPath = `bases/${safeName}.base` + const fileConfig = { ...config, name: safeName } + try { + await window.ipc.invoke('workspace:writeFile', { + path: newPath, + data: JSON.stringify(fileConfig, null, 2), + }) + setBaseConfigByPath((prev) => ({ ...prev, [newPath]: fileConfig })) + // Refresh tree then navigate to the new file + const newTree = await loadDirectory() + setTree(newTree) + void navigateToView({ type: 'file', path: newPath }) + } catch (err) { + console.error('Failed to save base:', err) + } + } else if (!isDefault) { + // Save in place + try { + await window.ipc.invoke('workspace:writeFile', { + path: selectedPath, + data: JSON.stringify(config, null, 2), + }) + } catch (err) { + console.error('Failed to save base:', err) + } + } + }, [selectedPath, baseConfigByPath, loadDirectory, navigateToView]) + + // External search set by app-navigation tool (passed to BasesView) + const [externalBaseSearch, setExternalBaseSearch] = useState(undefined) + + // Process pending app-navigation results + useEffect(() => { + const result = pendingAppNavRef.current + if (!result) return + pendingAppNavRef.current = null + + switch (result.action) { + case 'open-note': + navigateToFile(result.path as string) + break + case 'open-view': + if (result.view === 'graph') void navigateToView({ type: 'graph' }) + if (result.view === 'bases') { + void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) + } + break + case 'update-base-view': { + // Navigate to bases if not already there + const targetPath = selectedPath && isBaseFilePath(selectedPath) ? selectedPath : BASES_DEFAULT_TAB_PATH + if (!selectedPath || !isBaseFilePath(selectedPath)) { + void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) + } + + // Apply updates to the base config + const updates = result.updates as Record | undefined + if (updates) { + setBaseConfigByPath(prev => { + const current = prev[targetPath] ?? { ...DEFAULT_BASE_CONFIG } + const next = { ...current } + + // Apply filter updates + const filterUpdates = updates.filters as Record | undefined + if (filterUpdates) { + if (filterUpdates.clear) { + next.filters = [] + } + if (filterUpdates.set) { + next.filters = filterUpdates.set as Array<{ category: string; value: string }> + } + if (filterUpdates.add) { + const toAdd = filterUpdates.add as Array<{ category: string; value: string }> + const existing = next.filters + for (const f of toAdd) { + if (!existing.some(e => e.category === f.category && e.value === f.value)) { + existing.push(f) + } + } + } + if (filterUpdates.remove) { + const toRemove = filterUpdates.remove as Array<{ category: string; value: string }> + next.filters = next.filters.filter( + e => !toRemove.some(r => r.category === e.category && r.value === e.value) + ) + } + } + + // Apply column updates + const colUpdates = updates.columns as Record | undefined + if (colUpdates) { + if (colUpdates.set) { + next.visibleColumns = colUpdates.set as string[] + } + if (colUpdates.add) { + const toAdd = colUpdates.add as string[] + for (const col of toAdd) { + if (!next.visibleColumns.includes(col)) next.visibleColumns.push(col) + } + } + if (colUpdates.remove) { + const toRemove = new Set(colUpdates.remove as string[]) + next.visibleColumns = next.visibleColumns.filter(c => !toRemove.has(c)) + } + } + + // Apply sort + if (updates.sort) { + next.sort = updates.sort as { field: string; dir: 'asc' | 'desc' } + } + + return { ...prev, [targetPath]: next } + }) + + // Apply search externally + if (updates.search !== undefined) { + setExternalBaseSearch(updates.search as string || undefined) + } + } + break + } + case 'create-base': + if (result.path) { + navigateToFile(result.path as string) + } + break + } + }) + const navigateToFullScreenChat = useCallback(() => { // Only treat this as navigation when coming from another view if (currentViewState.type !== 'chat') { @@ -2591,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) @@ -2669,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 @@ -2692,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()}`, @@ -2711,6 +3386,13 @@ function App() { } void navigateToView({ type: 'graph' }) }, + openBases: () => { + if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) { + setIsChatSidebarOpen(false) + setIsRightPaneMaximized(false) + } + void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) + }, expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))), collapseAll: () => setExpandedPaths(new Set()), rename: async (oldPath: string, newName: string, isDir: boolean) => { @@ -2721,12 +3403,19 @@ function App() { parts[parts.length - 1] = finalName const newPath = parts.join('/') await window.ipc.invoke('workspace:rename', { from: oldPath, to: newPath }) + untitledRenameReadyPathsRef.current.delete(oldPath) const rewriteForRename = (content: string) => isDir ? content : rewriteWikiLinksForRenamedFileInMarkdown(content, oldPath, newPath) setFileTabs(prev => prev.map(tab => (tab.path === oldPath ? { ...tab, path: newPath } : tab))) if (editorPathRef.current === oldPath) { editorPathRef.current = newPath } + // Migrate frontmatter entry + const fmEntry = frontmatterByPathRef.current.get(oldPath) + if (fmEntry !== undefined) { + frontmatterByPathRef.current.delete(oldPath) + frontmatterByPathRef.current.set(newPath, fmEntry) + } const baseline = initialContentByPathRef.current.get(oldPath) if (baseline !== undefined) { initialContentByPathRef.current.delete(oldPath) @@ -2763,6 +3452,8 @@ function App() { if (path.endsWith('.md')) { removeEditorCacheForPath(path) initialContentByPathRef.current.delete(path) + untitledRenameReadyPathsRef.current.delete(path) + frontmatterByPathRef.current.delete(path) } // Close any file tab showing the deleted file const tabForFile = fileTabs.find(t => t.path === path) @@ -2778,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) @@ -2803,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) @@ -2859,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 } @@ -2923,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) @@ -2963,7 +3828,7 @@ function App() { if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { return ( - + @@ -2975,7 +3840,7 @@ function App() { } const { message, files } = parseAttachedFiles(item.content) return ( - + {files.length > 0 && (
    @@ -2995,7 +3860,7 @@ function App() { ) } return ( - + {item.content} @@ -3004,6 +3869,10 @@ function App() { } if (isToolCall(item)) { + const appActionData = getAppActionCardData(item) + if (appActionData) { + return + } const webSearchData = getWebSearchCardData(item) if (webSearchData) { return ( @@ -3016,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) @@ -3026,15 +3911,12 @@ function App() { onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)} > - - {output !== null ? ( - - ) : null} + ) @@ -3042,7 +3924,7 @@ function App() { if (isErrorMessage(item)) { return ( - +
    {item.message}
    @@ -3093,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 */} { + cancelRecordingIfActive() if (selectedPath || isGraphOpen) { setIsChatSidebarOpen(true) } @@ -3201,7 +4088,7 @@ function App() { getTabId={(t) => t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && isGraphOpen} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} /> ) : ( )} - {selectedPath && ( + {selectedPath && selectedPath.endsWith('.md') && (
    {isSaving ? ( <> @@ -3303,12 +4190,30 @@ function App() { )} - {isGraphOpen ? ( + {selectedPath && isBaseFilePath(selectedPath) ? ( +
    + navigateToFile(path)} + config={baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG} + onConfigChange={(cfg) => handleBaseConfigChange(selectedPath, cfg)} + isDefaultBase={selectedPath === BASES_DEFAULT_TAB_PATH} + onSave={(name) => void handleBaseSave(name)} + externalSearch={externalBaseSearch} + onExternalSearchConsumed={() => setExternalBaseSearch(undefined)} + actions={{ + rename: knowledgeActions.rename, + remove: knowledgeActions.remove, + copyPath: knowledgeActions.copyPath, + }} + /> +
    + ) : isGraphOpen ? (
    { navigateToFile(path) @@ -3340,11 +4245,30 @@ function App() { > { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }} + onPrimaryHeadingCommit={() => { + untitledRenameReadyPathsRef.current.add(tab.path) + }} + preserveUntitledTitleHeading={isUntitledPlaceholderName(getBaseName(tab.path))} placeholder="Start writing..." wikiLinks={wikiLinkConfig} onImageUpload={handleImageUpload} editorSessionKey={editorSessionByTabId[tab.id] ?? 0} + frontmatter={frontmatterByPathRef.current.get(tab.path) ?? null} + onFrontmatterChange={(newRaw) => { + frontmatterByPathRef.current.set(tab.path, newRaw) + // Write updated frontmatter to disk immediately + const currentBody = editorContentRef.current + const fullContent = joinFrontmatter(newRaw, currentBody) + initialContentByPathRef.current.set(tab.path, splitFrontmatter(fullContent).body) + initialContentRef.current = splitFrontmatter(fullContent).body + void window.ipc.invoke('workspace:writeFile', { + path: tab.path, + data: fullContent, + opts: { encoding: 'utf8' }, + }) + }} onHistoryHandlersChange={(handlers) => { if (handlers) { fileHistoryHandlersRef.current.set(tab.id, handlers) @@ -3353,6 +4277,16 @@ function App() { } }} editable={!isViewingHistory} + onExport={async (format) => { + const markdown = tabContent + 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) + } + }} />
    ) @@ -3437,8 +4371,11 @@ function App() { data-chat-tab-panel={tab.id} aria-hidden={!isActive} > - - + {!tabHasConversation ? ( @@ -3485,7 +4422,7 @@ function App() { {tabState.currentAssistantMessage && ( - {tabState.currentAssistantMessage} + /g, '')} components={streamdownComponents} /> )} @@ -3500,6 +4437,7 @@ function App() { )} +
    ) @@ -3536,6 +4474,18 @@ function App() { runId={tabState.runId} initialDraft={chatDraftsRef.current.get(tab.id)} onDraftChange={(text) => setChatDraftForTab(tab.id, text)} + isRecording={isActive && isRecording} + recordingText={isActive ? voice.interimText : undefined} + recordingState={isActive ? (voice.state === 'connecting' ? 'connecting' : 'listening') : undefined} + onStartRecording={isActive ? handleStartRecording : undefined} + onSubmitRecording={isActive ? handleSubmitRecording : undefined} + onCancelRecording={isActive ? handleCancelRecording : undefined} + voiceAvailable={isActive && voiceAvailable} + ttsAvailable={isActive && ttsAvailable} + ttsEnabled={ttsEnabled} + ttsMode={ttsMode} + onToggleTts={isActive ? handleToggleTts : undefined} + onTtsModeChange={isActive ? handleTtsModeChange : undefined} />
    ) @@ -3564,6 +4514,7 @@ function App() { conversation={conversation} currentAssistantMessage={currentAssistantMessage} chatTabStates={chatViewStateByTab} + viewportAnchors={chatViewportAnchorByTab} isProcessing={isProcessing} isStopping={isStopping} onStop={handleStop} @@ -3585,6 +4536,19 @@ function App() { onToolOpenChangeForTab={setToolOpenForTab} onOpenKnowledgeFile={(path) => { navigateToFile(path) }} onActivate={() => setActiveShortcutPane('right')} + isRecording={isRecording} + recordingText={voice.interimText} + recordingState={voice.state === 'connecting' ? 'connecting' : 'listening'} + onStartRecording={handleStartRecording} + onSubmitRecording={handleSubmitRecording} + onCancelRecording={handleCancelRecording} + voiceAvailable={voiceAvailable} + ttsAvailable={ttsAvailable} + ttsEnabled={ttsEnabled} + ttsMode={ttsMode} + onToggleTts={handleToggleTts} + onTtsModeChange={handleTtsModeChange} + onComposioConnected={handleComposioConnected} /> )} {/* Rendered last so its no-drag region paints over the sidebar drag region */} @@ -3595,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} /> @@ -3611,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/app-action-card.tsx b/apps/x/apps/renderer/src/components/ai-elements/app-action-card.tsx new file mode 100644 index 00000000..35f605c5 --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/app-action-card.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { + CheckCircleIcon, + FileTextIcon, + FilterIcon, + LayoutGridIcon, + LoaderIcon, + NetworkIcon, + PlusCircleIcon, +} from "lucide-react"; +import type { AppActionCardData } from "@/lib/chat-conversation"; + +interface AppActionCardProps { + data: AppActionCardData; + status: "pending" | "running" | "completed" | "error"; +} + +const actionIcons: Record = { + "open-note": , + "open-view": , + "update-base-view": , + "create-base": , +}; + +export function AppActionCard({ data, status }: AppActionCardProps) { + const isRunning = status === "pending" || status === "running"; + const isError = status === "error"; + + return ( +
    + + {actionIcons[data.action] || } + + {data.label} + {isRunning ? ( + + ) : isError ? ( + Failed + ) : ( + + )} +
    + ); +} 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 new file mode 100644 index 00000000..a68eb360 --- /dev/null +++ b/apps/x/apps/renderer/src/components/bases-view.tsx @@ -0,0 +1,962 @@ +import * as React from 'react' +import { useEffect, useState, useMemo, useCallback, useRef } from '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, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { cn } from '@/lib/utils' +import { splitFrontmatter, extractAllFrontmatterValues } from '@/lib/frontmatter' +import { useDebounce } from '@/hooks/use-debounce' + +interface TreeNode { + path: string + name: string + kind: 'file' | 'dir' + children?: TreeNode[] + stat?: { size: number; mtimeMs: number } +} + +type NoteEntry = { + path: string + name: string + folder: string + fields: Record + mtimeMs: number +} + +type SortDir = 'asc' | 'desc' +type ActiveFilter = { category: string; value: string } + +export type BaseConfig = { + name: string + visibleColumns: string[] + columnWidths: Record + sort: { field: string; dir: SortDir } + filters: ActiveFilter[] +} + +export const DEFAULT_BASE_CONFIG: BaseConfig = { + name: 'All Notes', + visibleColumns: ['name', 'folder', 'relationship', 'topic', 'status', 'mtimeMs'], + columnWidths: {}, + sort: { field: 'mtimeMs', dir: 'desc' }, + filters: [], +} + +const PAGE_SIZE = 25 + +/** Built-in columns that don't come from frontmatter */ +const BUILTIN_COLUMNS = ['name', 'folder', 'mtimeMs'] as const +type BuiltinColumn = (typeof BUILTIN_COLUMNS)[number] + +const BUILTIN_LABELS: Record = { + name: 'Name', + folder: 'Folder', + mtimeMs: 'Last Modified', +} + +/** Default pixel widths for columns */ +const DEFAULT_WIDTHS: Record = { + name: 200, + folder: 140, + mtimeMs: 140, +} +const DEFAULT_FRONTMATTER_WIDTH = 150 + +/** Convert key to title case: `first_met` → `First Met` */ +function toTitleCase(key: string): string { + if (key in BUILTIN_LABELS) return BUILTIN_LABELS[key as BuiltinColumn] + return key + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') +} + +type BasesViewProps = { + tree: TreeNode[] + onSelectNote: (path: string) => void + config: BaseConfig + onConfigChange: (config: BaseConfig) => void + isDefaultBase: boolean + onSave: (name: string | null) => void + /** Search query set externally (e.g. by app-navigation tool). */ + 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 }[] { + return nodes.flatMap((n) => + n.kind === 'file' && n.name.endsWith('.md') + ? [{ path: n.path, name: n.name.replace(/\.md$/i, ''), mtimeMs: n.stat?.mtimeMs ?? 0 }] + : n.children + ? collectFiles(n.children) + : [], + ) +} + +function getFolder(path: string): string { + const parts = path.split('/') + if (parts.length >= 3) return parts[1] + return '' +} + +function formatDate(ms: number): string { + if (!ms) return '' + const d = new Date(ms) + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +} + +function filtersEqual(a: ActiveFilter, b: ActiveFilter): boolean { + return a.category === b.category && a.value === b.value +} + +function hasFilter(filters: ActiveFilter[], f: ActiveFilter): boolean { + return filters.some((x) => filtersEqual(x, f)) +} + +/** Get the string values for a column from a note */ +function getColumnValues(note: NoteEntry, column: string): string[] { + if (column === 'name') return [note.name] + if (column === 'folder') return [note.folder] + if (column === 'mtimeMs') return [] + const v = note.fields[column] + if (!v) return [] + return Array.isArray(v) ? v : [v] +} + +/** Get a single sortable string for a column */ +function getSortValue(note: NoteEntry, column: string): string | number { + if (column === 'name') return note.name + if (column === 'folder') return note.folder + 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 +} + +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) => ({ + path: f.path, + name: f.name, + folder: getFolder(f.path), + fields: {}, + mtimeMs: f.mtimeMs, + })) + }, [tree]) + + // Frontmatter fields loaded async, keyed by path + const [fieldsByPath, setFieldsByPath] = useState>>(new Map()) + const loadGenRef = useRef(0) + + // Load frontmatter in background batches + useEffect(() => { + const gen = ++loadGenRef.current + let cancelled = false + const paths = notes.map((n) => n.path) + + async function load() { + const BATCH = 30 + for (let i = 0; i < paths.length; i += BATCH) { + if (cancelled) return + const batch = paths.slice(i, i + BATCH) + const results = await Promise.all( + batch.map(async (p) => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: p, encoding: 'utf8' }) + const { raw } = splitFrontmatter(result.data) + return { path: p, fields: extractAllFrontmatterValues(raw) } + } catch { + return { path: p, fields: {} as Record } + } + }), + ) + if (cancelled || gen !== loadGenRef.current) return + setFieldsByPath((prev) => { + const next = new Map(prev) + for (const r of results) next.set(r.path, r.fields) + return next + }) + } + } + + load() + return () => { cancelled = true } + }, [notes]) + + // Merge tree-derived notes with async-loaded fields + const enrichedNotes = useMemo(() => { + if (fieldsByPath.size === 0) return notes + return notes.map((n) => { + const f = fieldsByPath.get(n.path) + return f ? { ...n, fields: f } : n + }) + }, [notes, fieldsByPath]) + + // Collect all unique frontmatter property keys across all notes + const allPropertyKeys = useMemo(() => { + const keys = new Set() + for (const fields of fieldsByPath.values()) { + for (const k of Object.keys(fields)) keys.add(k) + } + return Array.from(keys).sort() + }, [fieldsByPath]) + + // Filterable categories: "folder" + all frontmatter keys + const filterCategories = useMemo(() => { + return ['folder', ...allPropertyKeys] + }, [allPropertyKeys]) + + // All unique values per category, across all enriched notes + const valuesByCategory = useMemo>(() => { + const result: Record> = {} + for (const cat of filterCategories) result[cat] = new Set() + for (const note of enrichedNotes) { + for (const cat of filterCategories) { + for (const v of getColumnValues(note, cat)) { + if (v) result[cat]?.add(v) + } + } + } + const out: Record = {} + for (const [cat, set] of Object.entries(result)) { + out[cat] = Array.from(set).sort((a, b) => a.localeCompare(b)) + } + return out + }, [filterCategories, enrichedNotes]) + + const visibleColumns = config.visibleColumns + const columnWidths = config.columnWidths + const filters = config.filters + const sortField = config.sort.field + const sortDir = config.sort.dir + const [page, setPage] = useState(0) + const [saveDialogOpen, setSaveDialogOpen] = useState(false) + const [saveName, setSaveName] = useState('') + const saveInputRef = useRef(null) + const [filterCategory, setFilterCategory] = useState(null) + + const handleSaveClick = useCallback(() => { + if (isDefaultBase) { + setSaveName('') + setSaveDialogOpen(true) + } else { + onSave(null) + } + }, [isDefaultBase, onSave]) + + const handleSaveConfirm = useCallback(() => { + const name = saveName.trim() + if (!name) return + setSaveDialogOpen(false) + onSave(name) + }, [saveName, onSave]) + + const getColWidth = useCallback((col: string) => { + return columnWidths[col] ?? DEFAULT_WIDTHS[col] ?? DEFAULT_FRONTMATTER_WIDTH + }, [columnWidths]) + + // Column resize via drag + const resizingRef = useRef<{ col: string; startX: number; startW: number } | null>(null) + + const configRef = useRef(config) + configRef.current = config + + const onResizeStart = useCallback((col: string, e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + const startX = e.clientX + const startW = configRef.current.columnWidths[col] ?? DEFAULT_WIDTHS[col] ?? DEFAULT_FRONTMATTER_WIDTH + resizingRef.current = { col, startX, startW } + + const onMouseMove = (ev: MouseEvent) => { + if (!resizingRef.current) return + const delta = ev.clientX - resizingRef.current.startX + const newW = Math.max(60, resizingRef.current.startW + delta) + const c = configRef.current + const updated = { ...c, columnWidths: { ...c.columnWidths, [resizingRef.current!.col]: newW } } + onConfigChange(updated) + } + + const onMouseUp = () => { + resizingRef.current = null + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + }, [onConfigChange]) + + // Search + const [searchOpen, setSearchOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + + // Apply external search from app-navigation tool + useEffect(() => { + if (externalSearch !== undefined) { + setSearchQuery(externalSearch) + setSearchOpen(true) + onExternalSearchConsumed?.() + } + }, [externalSearch, onExternalSearchConsumed]) + const debouncedSearch = useDebounce(searchQuery, 250) + const [searchMatchPaths, setSearchMatchPaths] = useState | null>(null) + const searchInputRef = useRef(null) + + useEffect(() => { + if (!debouncedSearch.trim()) { + setSearchMatchPaths(null) + return + } + let cancelled = false + window.ipc.invoke('search:query', { query: debouncedSearch, limit: 200, types: ['knowledge'] }) + .then((res: { results: { path: string }[] }) => { + if (!cancelled) { + setSearchMatchPaths(new Set(res.results.map((r) => r.path))) + } + }) + .catch(() => { + if (!cancelled) setSearchMatchPaths(new Set()) + }) + return () => { cancelled = true } + }, [debouncedSearch]) + + const toggleSearch = useCallback(() => { + setSearchOpen((prev) => { + if (prev) { + setSearchQuery('') + setSearchMatchPaths(null) + } + return !prev + }) + }, []) + + // Focus input when search opens + useEffect(() => { + if (searchOpen) searchInputRef.current?.focus() + }, [searchOpen]) + + // Reset page when filters or search change + useEffect(() => { setPage(0) }, [filters, searchMatchPaths]) + + // Filter (search + badge filters) + const filteredNotes = useMemo(() => { + let result = enrichedNotes + // Apply search filter + if (searchMatchPaths) { + result = result.filter((note) => searchMatchPaths.has(note.path)) + } + // Apply badge filters + if (filters.length > 0) { + const byCategory = new Map() + for (const f of filters) { + const vals = byCategory.get(f.category) ?? [] + vals.push(f.value) + byCategory.set(f.category, vals) + } + result = result.filter((note) => { + for (const [category, requiredValues] of byCategory) { + const noteValues = getColumnValues(note, category) + if (!requiredValues.some((v) => noteValues.includes(v))) return false + } + return true + }) + } + return result + }, [enrichedNotes, filters, searchMatchPaths]) + + // Sort + const sortedNotes = useMemo(() => { + return [...filteredNotes].sort((a, b) => { + const va = getSortValue(a, sortField) + const vb = getSortValue(b, sortField) + let cmp: number + if (typeof va === 'number' && typeof vb === 'number') { + cmp = va - vb + } else { + cmp = String(va).localeCompare(String(vb)) + } + return sortDir === 'asc' ? cmp : -cmp + }) + }, [filteredNotes, sortField, sortDir]) + + // Paginate + const totalPages = Math.max(1, Math.ceil(sortedNotes.length / PAGE_SIZE)) + const clampedPage = Math.min(page, totalPages - 1) + const pageNotes = useMemo( + () => sortedNotes.slice(clampedPage * PAGE_SIZE, (clampedPage + 1) * PAGE_SIZE), + [sortedNotes, clampedPage], + ) + + const toggleFilter = useCallback((category: string, value: string) => { + const c = configRef.current + const f: ActiveFilter = { category, value } + const next = hasFilter(c.filters, f) + ? c.filters.filter((x) => !filtersEqual(x, f)) + : [...c.filters, f] + onConfigChange({ ...c, filters: next }) + }, [onConfigChange]) + + const clearFilters = useCallback(() => { + onConfigChange({ ...configRef.current, filters: [] }) + }, [onConfigChange]) + + const handleSort = useCallback((field: string) => { + const c = configRef.current + if (field === c.sort.field) { + onConfigChange({ ...c, sort: { field, dir: c.sort.dir === 'asc' ? 'desc' : 'asc' } }) + } else { + onConfigChange({ ...c, sort: { field, dir: field === 'mtimeMs' ? 'desc' : 'asc' } }) + } + }, [onConfigChange]) + + const toggleColumn = useCallback((key: string) => { + const c = configRef.current + const next = c.visibleColumns.includes(key) + ? c.visibleColumns.filter((col) => col !== key) + : [...c.visibleColumns, key] + onConfigChange({ ...c, visibleColumns: next }) + }, [onConfigChange]) + + const SortIcon = ({ field }: { field: string }) => { + if (sortField !== field) return null + return sortDir === 'asc' + ? + : + } + + return ( +
    + {/* Toolbar */} +
    + + + + + + + + + No properties found. + + {BUILTIN_COLUMNS.map((col) => ( + toggleColumn(col)}> + + {BUILTIN_LABELS[col]} + + ))} + + + {allPropertyKeys.map((key) => ( + toggleColumn(key)}> + + {toTitleCase(key)} + + ))} + + + + + + + { if (!open) setFilterCategory(null) }}> + + + + +
    + {/* Left: categories */} +
    +
    + Attributes + {filters.length > 0 && ( + + )} +
    + {filterCategories.map((cat) => { + const activeCount = filters.filter((f) => f.category === cat).length + const isSelected = filterCategory === cat + return ( + + ) + })} +
    + {/* Right: values for selected category */} + {filterCategory && ( +
    + + + + No values found. + + {(valuesByCategory[filterCategory] ?? []).map((val) => { + const active = hasFilter(filters, { category: filterCategory, value: val }) + return ( + toggleFilter(filterCategory, val)}> + + {val} + + ) + })} + + + +
    + )} +
    +
    +
    + + + + {searchOpen && ( +
    + setSearchQuery(e.target.value)} + placeholder="Search notes..." + className="flex-1 min-w-0 bg-transparent text-xs text-foreground placeholder:text-muted-foreground outline-none" + /> + {searchQuery && ( + + {searchMatchPaths ? `${searchMatchPaths.size} matches` : '...'} + + )} + +
    + )} + +
    + + +
    + + {/* Filter bar */} + {filters.length > 0 && ( +
    +
    + + {sortedNotes.length} of {enrichedNotes.length} notes + + {filters.map((f) => ( + + ))} + +
    +
    + )} + + {/* Table */} +
    + + + {visibleColumns.map((col) => ( + + ))} + + + + {visibleColumns.map((col) => ( + + ))} + + + + {pageNotes.map((note) => ( + + ))} + {pageNotes.length === 0 && ( + + + + )} + +
    handleSort(col)} + > + {toTitleCase(col)} + {/* Resize handle */} +
    onResizeStart(col, e)} + onClick={(e) => e.stopPropagation()} + /> +
    + No notes found +
    +
    + + {/* Pagination */} +
    + + {sortedNotes.length === 0 + ? '0 notes' + : `${clampedPage * PAGE_SIZE + 1}\u2013${Math.min((clampedPage + 1) * PAGE_SIZE, sortedNotes.length)} of ${sortedNotes.length}`} + + {totalPages > 1 && ( +
    + + + Page {clampedPage + 1} of {totalPages} + + +
    + )} +
    + + {/* Save As dialog */} + + + + Save Base + Choose a name for this base view. + + setSaveName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSaveConfirm() }} + placeholder="e.g. Contacts, Projects..." + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring" + autoFocus + /> + + + + + + +
    + ) +} + +/** Renders a single table cell based on the column type */ +function CellRenderer({ + note, + column, + filters, + toggleFilter, +}: { + note: NoteEntry + column: string + filters: ActiveFilter[] + toggleFilter: (category: string, value: string) => void +}) { + if (column === 'name') { + return {note.name} + } + if (column === 'folder') { + return {note.folder} + } + if (column === 'mtimeMs') { + 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 + + if (Array.isArray(value)) { + return ( +
    + {value.map((v) => ( + + ))} +
    + ) + } + + // Single string value — render as badge for filterability + return ( + + ) +} + +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, + active, + onClick, +}: { + category: string + value: string + active: boolean + onClick: (category: string, value: string) => void +}) { + return ( + { + e.stopPropagation() + onClick(category, value) + }} + > + {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 d3554c00..37d8d053 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 @@ -1,20 +1,32 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ArrowUp, AudioLines, + ChevronDown, FileArchive, FileCode2, FileIcon, FileSpreadsheet, FileText, FileVideo, + Globe, + Headphones, LoaderIcon, + Mic, Plus, Square, X, } from 'lucide-react' import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { type AttachmentIconKind, getAttachmentDisplayName, @@ -45,6 +57,27 @@ export type StagedAttachment = { const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB + +const providerDisplayNames: Record = { + openai: 'OpenAI', + anthropic: 'Anthropic', + google: 'Gemini', + ollama: 'Ollama', + openrouter: 'OpenRouter', + aigateway: 'AI Gateway', + 'openai-compatible': 'OpenAI-Compatible', + rowboat: 'Rowboat', +} + +interface ConfiguredModel { + flavor: "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" + model: string + apiKey?: string + baseURL?: string + headers?: Record + knowledgeGraphModel?: string +} + function getAttachmentIcon(kind: AttachmentIconKind) { switch (kind) { case 'audio': @@ -65,7 +98,7 @@ function getAttachmentIcon(kind: AttachmentIconKind) { } interface ChatInputInnerProps { - onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean) => void onStop?: () => void isProcessing: boolean isStopping?: boolean @@ -75,6 +108,18 @@ interface ChatInputInnerProps { runId?: string | null initialDraft?: string onDraftChange?: (text: string) => void + isRecording?: boolean + recordingText?: string + recordingState?: 'connecting' | 'listening' + onStartRecording?: () => void + onSubmitRecording?: () => void + onCancelRecording?: () => void + voiceAvailable?: boolean + ttsAvailable?: boolean + ttsEnabled?: boolean + ttsMode?: 'summary' | 'full' + onToggleTts?: () => void + onTtsModeChange?: (mode: 'summary' | 'full') => void } function ChatInputInner({ @@ -88,6 +133,18 @@ function ChatInputInner({ runId, initialDraft, onDraftChange, + isRecording, + recordingText, + recordingState, + onStartRecording, + onSubmitRecording, + onCancelRecording, + voiceAvailable, + ttsAvailable, + ttsEnabled, + ttsMode, + onToggleTts, + onTtsModeChange, }: ChatInputInnerProps) { const controller = usePromptInputController() const message = controller.textInput.value @@ -96,6 +153,172 @@ function ChatInputInner({ const fileInputRef = useRef(null) const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing + const [configuredModels, setConfiguredModels] = useState([]) + const [activeModelKey, setActiveModelKey] = useState('') + const [searchEnabled, setSearchEnabled] = useState(false) + const [searchAvailable, setSearchAvailable] = useState(false) + const [isRowboatConnected, setIsRowboatConnected] = useState(false) + + // 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 { + 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) + } + } + } catch { + // No config yet + } + }, [isRowboatConnected]) + + useEffect(() => { + loadModelConfig() + }, [isActive, loadModelConfig]) + + // Reload when model config changes (e.g. from settings dialog) + useEffect(() => { + const handler = () => { loadModelConfig() } + window.addEventListener('models-config-changed', handler) + return () => window.removeEventListener('models-config-changed', handler) + }, [loadModelConfig]) + + // 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/exa-search.json' }) + const config = JSON.parse(raw.data) + if (config.apiKey) available = true + } catch { /* not configured */ } + setSearchAvailable(available) + } + checkSearch() + }, [isActive, isRowboatConnected]) + + const handleModelChange = useCallback(async (key: string) => { + const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key) + if (!entry) return + setActiveModelKey(key) + + try { + 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') + } + }, [configuredModels]) + // Restore the tab draft when this input mounts. useEffect(() => { if (initialDraft) { @@ -152,11 +375,12 @@ function ChatInputInner({ const handleSubmit = useCallback(() => { if (!canSubmit) return - onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments) + onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined) controller.textInput.clear() controller.mentions.clearMentions() setAttachments([]) - }, [attachments, canSubmit, controller, message, onSubmit]) + setSearchEnabled(false) + }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -239,24 +463,67 @@ function ChatInputInner({ })}
    )} -
    - { - const files = e.target.files - if (!files || files.length === 0) return - const paths = Array.from(files) - .map((file) => window.electronUtils?.getPathForFile(file)) - .filter(Boolean) as string[] - if (paths.length > 0) { - void addFiles(paths) - } - e.target.value = '' - }} + { + const files = e.target.files + if (!files || files.length === 0) return + const paths = Array.from(files) + .map((file) => window.electronUtils?.getPathForFile(file)) + .filter(Boolean) as string[] + if (paths.length > 0) { + void addFiles(paths) + } + e.target.value = '' + }} + /> + {isRecording ? ( + /* ── Recording bar ── */ +
    + +
    + + + {recordingState === 'connecting' ? 'Connecting...' : recordingText || 'Listening...'} + +
    + +
    + ) : ( + /* ── Normal input ── */ + <> +
    + +
    +
    - + {searchAvailable && ( + searchEnabled ? ( + + ) : ( + + ) + )} +
    + {configuredModels.length > 0 && ( + + + + + + + {configuredModels.map((m) => { + const key = `${m.flavor}/${m.model}` + return ( + + {m.model} + {providerDisplayNames[m.flavor] || m.flavor} + + ) + })} + + + + )} + {onToggleTts && ttsAvailable && ( +
    + + + + + + {ttsEnabled ? 'Voice output on' : 'Voice output off'} + + + {ttsEnabled && onTtsModeChange && ( + + + + + + onTtsModeChange(v as 'summary' | 'full')}> + Speak summary + Speak full response + + + + )} +
    + )} + {voiceAvailable && onStartRecording && ( + + )} {isProcessing ? (
    + + )} +
    + ) +} + +/** Animated waveform bars for the recording indicator */ +function VoiceWaveform() { + return ( +
    + {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
    ) } @@ -324,6 +717,18 @@ export interface ChatInputWithMentionsProps { runId?: string | null initialDraft?: string onDraftChange?: (text: string) => void + isRecording?: boolean + recordingText?: string + recordingState?: 'connecting' | 'listening' + onStartRecording?: () => void + onSubmitRecording?: () => void + onCancelRecording?: () => void + voiceAvailable?: boolean + ttsAvailable?: boolean + ttsEnabled?: boolean + ttsMode?: 'summary' | 'full' + onToggleTts?: () => void + onTtsModeChange?: (mode: 'summary' | 'full') => void } export function ChatInputWithMentions({ @@ -340,6 +745,18 @@ export function ChatInputWithMentions({ runId, initialDraft, onDraftChange, + isRecording, + recordingText, + recordingState, + onStartRecording, + onSubmitRecording, + onCancelRecording, + voiceAvailable, + ttsAvailable, + ttsEnabled, + ttsMode, + onToggleTts, + onTtsModeChange, }: ChatInputWithMentionsProps) { return ( @@ -354,6 +771,18 @@ export function ChatInputWithMentions({ runId={runId} initialDraft={initialDraft} onDraftChange={onDraftChange} + isRecording={isRecording} + recordingText={recordingText} + recordingState={recordingState} + onStartRecording={onStartRecording} + onSubmitRecording={onSubmitRecording} + onCancelRecording={onCancelRecording} + voiceAvailable={voiceAvailable} + ttsAvailable={ttsAvailable} + ttsEnabled={ttsEnabled} + ttsMode={ttsMode} + onToggleTts={onToggleTts} + onTtsModeChange={onTtsModeChange} /> ) diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index f020cdae..f94c94ba 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, @@ -45,6 +49,54 @@ import { const streamdownComponents = { pre: MarkdownPreOverride } +/* ─── Billing error helpers ─── */ + +const BILLING_ERROR_PATTERNS = [ + { + pattern: /upgrade required/i, + title: 'A subscription is required', + subtitle: 'Get started with a plan to access AI features in Rowboat.', + cta: 'Subscribe', + }, + { + pattern: /not enough credits/i, + title: 'You\'ve run out of credits', + subtitle: 'Upgrade your plan for more credits, or wait for your billing cycle to reset.', + cta: 'Upgrade plan', + }, + { + pattern: /subscription not active/i, + title: 'Your subscription is inactive', + subtitle: 'Reactivate your subscription to continue using AI features.', + cta: 'Reactivate', + }, +] as const + +function matchBillingError(message: string) { + return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null +} + +function BillingErrorCTA({ label }: { label: string }) { + const [appUrl, setAppUrl] = useState(null) + + useEffect(() => { + window.ipc.invoke('account:getRowboat', null) + .then((account: any) => setAppUrl(account.config?.appUrl ?? null)) + .catch(() => {}) + }, []) + + if (!appUrl) return null + + return ( + + ) +} + const MIN_WIDTH = 360 const MAX_WIDTH = 1600 const MIN_MAIN_PANE_WIDTH = 420 @@ -87,6 +139,7 @@ interface ChatSidebarProps { conversation: ConversationItem[] currentAssistantMessage: string chatTabStates?: Record + viewportAnchors?: Record isProcessing: boolean isStopping?: boolean onStop?: () => void @@ -108,6 +161,20 @@ interface ChatSidebarProps { onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void onOpenKnowledgeFile?: (path: string) => void onActivate?: () => void + // Voice / TTS props + isRecording?: boolean + recordingText?: string + recordingState?: 'connecting' | 'listening' + onStartRecording?: () => void + onSubmitRecording?: () => void + onCancelRecording?: () => void + voiceAvailable?: boolean + ttsAvailable?: boolean + ttsEnabled?: boolean + ttsMode?: 'summary' | 'full' + onToggleTts?: () => void + onTtsModeChange?: (mode: 'summary' | 'full') => void + onComposioConnected?: (toolkitSlug: string) => void } export function ChatSidebar({ @@ -125,6 +192,7 @@ export function ChatSidebar({ conversation, currentAssistantMessage, chatTabStates = {}, + viewportAnchors = {}, isProcessing, isStopping, onStop, @@ -146,6 +214,19 @@ export function ChatSidebar({ onToolOpenChangeForTab, onOpenKnowledgeFile, onActivate, + isRecording, + recordingText, + recordingState, + onStartRecording, + onSubmitRecording, + onCancelRecording, + voiceAvailable, + ttsAvailable, + ttsEnabled, + ttsMode, + onToggleTts, + onTtsModeChange, + onComposioConnected, }: ChatSidebarProps) { const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth)) const [isResizing, setIsResizing] = useState(false) @@ -259,7 +340,7 @@ export function ChatSidebar({ if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { return ( - + @@ -271,7 +352,7 @@ export function ChatSidebar({ } const { message, files } = parseAttachedFiles(item.content) return ( - + {files.length > 0 && (
    @@ -291,7 +372,7 @@ export function ChatSidebar({ ) } return ( - + {item.content} @@ -312,6 +393,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) @@ -321,18 +417,31 @@ export function ChatSidebar({ open={isToolOpenForTab?.(tabId, item.id) ?? false} onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)} > - + - - {output !== null ? : null} + ) } if (isErrorMessage(item)) { + const billingError = matchBillingError(item.message) + if (billingError) { + return ( + + +
    +

    {billingError.title}

    +

    {billingError.subtitle}

    + +
    +
    +
    + ) + } return ( - +
    {item.message}
    @@ -441,9 +550,12 @@ export function ChatSidebar({ )} data-chat-tab-panel={tab.id} aria-hidden={!isActive} - > - - + > + {!tabHasConversation ? ( @@ -501,10 +613,11 @@ export function ChatSidebar({
    )} - )} - - -
    + )} + + + +
    ) })}
    @@ -542,6 +655,18 @@ export function ChatSidebar({ runId={tabState.runId} initialDraft={getInitialDraft?.(tab.id)} onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined} + isRecording={isActive && isRecording} + recordingText={isActive ? recordingText : undefined} + recordingState={isActive ? recordingState : undefined} + onStartRecording={isActive ? onStartRecording : undefined} + onSubmitRecording={isActive ? onSubmitRecording : undefined} + onCancelRecording={isActive ? onCancelRecording : undefined} + voiceAvailable={isActive && voiceAvailable} + ttsAvailable={isActive && ttsAvailable} + ttsEnabled={ttsEnabled} + ttsMode={ttsMode} + onToggleTts={isActive ? onToggleTts : undefined} + onTtsModeChange={isActive ? onTtsModeChange : undefined} />
    ) diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index ad2285a0..92b13a48 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 } from "lucide-react" +import { useState } from "react" +import { AlertTriangle, Loader2, Mic, Mail, Calendar, User } from "lucide-react" import { Popover, @@ -15,387 +15,42 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" import { Button } from "@/components/ui/button" -import { Switch } from "@/components/ui/switch" import { Separator } from "@/components/ui/separator" -import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" 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) - // Composio/Slack state - const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) - const [slackConnected, setSlackConnected] = useState(false) - const [slackLoading, setSlackLoading] = useState(true) - const [slackConnecting, setSlackConnecting] = useState(false) - - // Load available providers on mount - useEffect(() => { - async function loadProviders() { - try { - setProvidersLoading(true) - const result = await window.ipc.invoke('oauth:list-providers', null) - setProviders(result.providers || []) - } catch (error) { - console.error('Failed to get available providers:', error) - setProviders([]) - } finally { - setProvidersLoading(false) - } - } - loadProviders() - }, []) - - // 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 connection status - const refreshSlackStatus = useCallback(async () => { - try { - setSlackLoading(true) - const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' }) - setSlackConnected(result.isConnected) - } catch (error) { - console.error('Failed to load Slack status:', error) - setSlackConnected(false) - } finally { - setSlackLoading(false) - } - }, []) - - // Connect to Slack via Composio - const startSlackConnect = useCallback(async () => { - try { - setSlackConnecting(true) - const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' }) - if (!result.success) { - toast.error(result.error || 'Failed to connect to Slack') - setSlackConnecting(false) - } - // Success will be handled by composio:didConnect event - } catch (error) { - console.error('Failed to connect to Slack:', error) - toast.error('Failed to connect to Slack') - setSlackConnecting(false) - } - }, []) - - // Handle Slack connect button click - const handleConnectSlack = useCallback(async () => { - // Check if Composio is configured - const configResult = await window.ipc.invoke('composio:is-configured', null) - if (!configResult.configured) { - setComposioApiKeyOpen(true) - return - } - await startSlackConnect() - }, [startSlackConnect]) - - // 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') - // Now start the Slack connection - await startSlackConnect() - } catch (error) { - console.error('Failed to save Composio API key:', error) - toast.error('Failed to save API key') - } - }, [startSlackConnect]) - - // Disconnect from Slack - const handleDisconnectSlack = useCallback(async () => { - try { - setSlackLoading(true) - const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'slack' }) - if (result.success) { - setSlackConnected(false) - toast.success('Disconnected from Slack') - } else { - toast.error('Failed to disconnect from Slack') - } - } catch (error) { - console.error('Failed to disconnect from Slack:', error) - toast.error('Failed to disconnect from Slack') - } finally { - setSlackLoading(false) - } - }, []) - - // Check connection status for all providers - const refreshAllStatuses = useCallback(async () => { - // Refresh Granola - refreshGranolaConfig() - - // Refresh Slack status - refreshSlackStatus() - - // 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, refreshSlackStatus]) - - // 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]) - - // Listen for Composio connection events - useEffect(() => { - const cleanup = window.ipc.on('composio:didConnect', (event) => { - const { toolkitSlug, success, error } = event - - if (toolkitSlug === 'slack') { - setSlackConnected(success) - setSlackConnecting(false) - - if (success) { - toast.success('Connected to Slack') - } else { - toast.error(error || 'Failed to connect to Slack') - } - } - }) - - return cleanup - }, []) - - const startConnect = useCallback(async (provider: string, clientId?: string) => { - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isConnecting: true } - })) - - try { - const result = await window.ipc.invoke('oauth:connect', { provider, clientId }) - - if (result.success) { - // OAuth flow started - keep isConnecting state, wait for event - // Event listener will handle the actual completion - } else { - // Immediate failure (e.g., couldn't start flow) - toast.error(result.error || `Failed to connect to ${provider}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isConnecting: false } - })) - } - } catch (error) { - console.error('Failed to connect:', error) - toast.error(`Failed to connect to ${provider}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isConnecting: false } - })) - } - }, []) - - // Connect to a provider - const handleConnect = useCallback(async (provider: string) => { - if (provider === 'google') { - setGoogleClientIdDescription(undefined) - const existingClientId = getGoogleClientId() - if (!existingClientId) { - setGoogleClientIdOpen(true) - return - } - await startConnect(provider, existingClientId) - return - } - - await startConnect(provider) - }, [startConnect]) - - const handleGoogleClientIdSubmit = useCallback((clientId: string) => { - setGoogleClientId(clientId) - setGoogleClientIdOpen(false) - setGoogleClientIdDescription(undefined) - startConnect('google', clientId) - }, [startConnect]) - - // Disconnect from a provider - const handleDisconnect = useCallback(async (provider: string) => { - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isLoading: true } - })) - - try { - const result = await window.ipc.invoke('oauth:disconnect', { provider }) - - if (result.success) { - if (provider === 'google') { - clearGoogleClientId() - } - const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) - toast.success(`Disconnected from ${displayName}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { - isConnected: false, - isLoading: false, - isConnecting: false, - } - })) - } else { - toast.error(`Failed to disconnect from ${provider}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isLoading: false } - })) - } - } catch (error) { - console.error('Failed to disconnect:', error) - toast.error(`Failed to disconnect from ${provider}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isLoading: false } - })) - } - }, []) - - const hasProviderError = Object.values(providerStatus).some( - (status) => Boolean(status?.error) - ) + const isUnconnectedMode = mode === "unconnected" // Helper to render an OAuth provider row const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => { - const state = providerStates[provider] || { + const state = c.providerStates[provider] || { isConnected: false, isLoading: true, isConnecting: false, } - const needsReconnect = Boolean(providerStatus[provider]?.error) + const needsReconnect = Boolean(c.providerStatus[provider]?.error) + + // In unconnected mode, skip connected providers (unless they need reconnect) + if (isUnconnectedMode && state.isConnected && !needsReconnect && !state.isLoading) { + return null + } return (
    { if (provider === 'google') { - setGoogleClientIdDescription( + c.setGoogleClientIdDescription( "To keep your Google account connected, please re-enter your client ID. You only need to do this once." ) - setGoogleClientIdOpen(true) + c.setGoogleClientIdOpen(true) return } - startConnect(provider) + c.startConnect(provider) }} className="h-7 px-2 text-xs" > @@ -442,23 +97,23 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha ) : ( )} @@ -467,19 +122,52 @@ 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 + + // 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 (c.providers.includes('fireflies-ai')) { + const firefliesState = c.providerStates['fireflies-ai'] + if (!firefliesState?.isConnected || c.providerStatus['fireflies-ai']?.error) return true + } + return false + })() + + 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 + 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 ? ( @@ -506,129 +194,179 @@ 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 +

    +
    ) : ( <> - {/* Email & Calendar Section - Google */} - {providers.includes('google') && ( + {/* 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 && ( <>
    - Email & Calendar + + Email & Calendar +
    - {renderOAuthProvider('google', 'Google', , 'Sync emails and calendar')} + {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') + )} + {c.useComposioForGoogleCalendar && (!isUnconnectedMode || isGoogleCalendarUnconnected) && ( +
    +
    +
    + +
    +
    + Google Calendar + {c.googleCalendarLoading ? ( + Checking... + ) : ( + + Sync calendar events + + )} +
    +
    +
    + {c.googleCalendarLoading ? ( + + ) : c.googleCalendarConnected ? ( + + ) : ( + + )} +
    +
    + )} )} - {/* Meeting Notes Section - Granola & Fireflies */} -
    - Meeting Notes -
    - - {/* Granola */} -
    -
    -
    - + {/* Meeting Notes Section */} + {hasUnconnectedMeetingNotes && ( + <> +
    + Meeting Notes
    -
    - Granola - - Local meeting notes - -
    -
    -
    - {granolaLoading && ( - - )} - -
    -
    - {/* Fireflies */} - {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')} + {/* Fireflies */} + {c.providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')} - - - {/* Team Communication Section - Slack */} -
    - Team Communication -
    - - {/* Slack */} -
    -
    -
    - -
    -
    - Slack - {slackLoading ? ( - Checking... - ) : ( - - Send messages and view channels - - )} -
    -
    -
    - {slackLoading ? ( - - ) : slackConnected ? ( - - ) : ( - - )} -
    -
    + + + )} )}
    ) diff --git a/apps/x/apps/renderer/src/components/editor-toolbar.tsx b/apps/x/apps/renderer/src/components/editor-toolbar.tsx index bf258633..72b1cb35 100644 --- a/apps/x/apps/renderer/src/components/editor-toolbar.tsx +++ b/apps/x/apps/renderer/src/components/editor-toolbar.tsx @@ -25,18 +25,30 @@ import { ExternalLinkIcon, Trash2Icon, ImageIcon, + DownloadIcon, + FileTextIcon, + FileIcon, + FileTypeIcon, } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' interface EditorToolbarProps { editor: Editor | null onSelectionHighlight?: (range: { from: number; to: number } | null) => void onImageUpload?: (file: File) => Promise | void + onExport?: (format: 'md' | 'pdf' | 'docx') => void } export function EditorToolbar({ editor, onSelectionHighlight, onImageUpload, + onExport, }: EditorToolbarProps) { const [linkUrl, setLinkUrl] = useState('') const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false) @@ -341,6 +353,38 @@ export function EditorToolbar({ )} + + {/* Export */} + {onExport && ( + <> +
    + + + + + + onExport('md')}> + + Markdown (.md) + + onExport('pdf')}> + + PDF (.pdf) + + onExport('docx')}> + + Word (.docx) + + + + + )}
    ) } diff --git a/apps/x/apps/renderer/src/components/frontmatter-properties.tsx b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx new file mode 100644 index 00000000..0ceb2c76 --- /dev/null +++ b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx @@ -0,0 +1,252 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import { ChevronRight, X, Plus } from 'lucide-react' +import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter' + +interface FrontmatterPropertiesProps { + raw: string | null + onRawChange: (raw: string | null) => void + editable?: boolean +} + +type FieldEntry = { key: string; value: string | string[] } + +function fieldsFromRaw(raw: string | null): FieldEntry[] { + const record = extractAllFrontmatterValues(raw) + return Object.entries(record).map(([key, value]) => ({ key, value })) +} + +function fieldsToRaw(fields: FieldEntry[]): string | null { + const record: Record = {} + for (const { key, value } of fields) { + if (key.trim()) record[key.trim()] = value + } + return buildFrontmatter(record) +} + +export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) { + const [expanded, setExpanded] = useState(false) + const [fields, setFields] = useState(() => fieldsFromRaw(raw)) + const [editingNewKey, setEditingNewKey] = useState(false) + const newKeyRef = useRef(null) + const lastCommittedRaw = useRef(raw) + + // Sync local fields when raw changes externally (e.g. tab switch) + useEffect(() => { + if (raw !== lastCommittedRaw.current) { + setFields(fieldsFromRaw(raw)) + lastCommittedRaw.current = raw + } + }, [raw]) + + useEffect(() => { + if (editingNewKey && newKeyRef.current) { + newKeyRef.current.focus() + } + }, [editingNewKey]) + + const commit = useCallback((updated: FieldEntry[]) => { + const newRaw = fieldsToRaw(updated) + lastCommittedRaw.current = newRaw + onRawChange(newRaw) + }, [onRawChange]) + + // For scalar fields: update local state immediately, commit on blur + const updateLocalValue = useCallback((index: number, newValue: string) => { + setFields(prev => { + const next = [...prev] + next[index] = { ...next[index], value: newValue } + return next + }) + }, []) + + const commitField = useCallback((_index: number) => { + setFields(prev => { + commit(prev) + return prev + }) + }, [commit]) + + // For array fields and structural changes: update + commit immediately + const updateAndCommit = useCallback((updater: (prev: FieldEntry[]) => FieldEntry[]) => { + setFields(prev => { + const next = updater(prev) + commit(next) + return next + }) + }, [commit]) + + const removeField = useCallback((index: number) => { + updateAndCommit(prev => prev.filter((_, i) => i !== index)) + }, [updateAndCommit]) + + const addField = useCallback((key: string) => { + const trimmed = key.trim() + if (!trimmed) return + if (fields.some(f => f.key === trimmed)) return + updateAndCommit(prev => [...prev, { key: trimmed, value: '' }]) + setEditingNewKey(false) + }, [fields, updateAndCommit]) + + const count = fields.length + + return ( +
    + + + {expanded && ( +
    + {fields.map((field, index) => ( +
    + + {field.key} + +
    + {Array.isArray(field.value) ? ( + updateAndCommit(prev => { + const next = [...prev] + next[index] = { ...next[index], value: v } + return next + })} + /> + ) : ( + updateLocalValue(index, e.target.value)} + onBlur={() => commitField(index)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur() + } + }} + /> + )} +
    + {editable && ( + + )} +
    + ))} + + {editable && ( + editingNewKey ? ( +
    + { + if (e.key === 'Enter') { + addField(e.currentTarget.value) + } else if (e.key === 'Escape') { + setEditingNewKey(false) + } + }} + onBlur={(e) => { + if (e.currentTarget.value.trim()) { + addField(e.currentTarget.value) + } else { + setEditingNewKey(false) + } + }} + /> +
    + ) : ( + + ) + )} +
    + )} +
    + ) +} + +function ArrayField({ + value, + editable, + onChange, +}: { + value: string[] + editable: boolean + onChange: (v: string[]) => void +}) { + const removeItem = (index: number) => { + onChange(value.filter((_, i) => i !== index)) + } + + const addItem = (text: string) => { + const trimmed = text.trim() + if (!trimmed) return + onChange([...value, trimmed]) + } + + return ( +
    + {value.map((item, i) => ( + + {item} + {editable && ( + + )} + + ))} + {editable && ( + { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + addItem(e.currentTarget.value) + e.currentTarget.value = '' + } else if (e.key === 'Backspace' && !e.currentTarget.value && value.length > 0) { + removeItem(value.length - 1) + } + }} + onBlur={(e) => { + if (e.currentTarget.value.trim()) { + addItem(e.currentTarget.value) + e.currentTarget.value = '' + } + }} + /> + )} +
    + ) +} 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..b0b57594 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 = { @@ -48,7 +48,7 @@ const FLOAT_VARIANCE = 2 const FLOAT_SPEED_BASE = 0.0006 const FLOAT_SPEED_VARIANCE = 0.00025 -export function GraphView({ nodes, edges, isLoading, error, onSelectNode }: GraphViewProps) { +export function GraphView({ nodes, edges, error, onSelectNode }: GraphViewProps) { const containerRef = useRef(null) const positionsRef = useRef>(new Map()) const motionSeedsRef = useRef>(new Map()) @@ -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 6bcaef29..d7920b8b 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -1,6 +1,6 @@ import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react' import { Plugin, PluginKey } from '@tiptap/pm/state' -import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' import Image from '@tiptap/extension-image' @@ -8,8 +8,17 @@ import Placeholder from '@tiptap/extension-placeholder' 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' @@ -100,39 +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```') @@ -176,12 +206,26 @@ function getMarkdownWithBlankLines(editor: Editor): string { return result } import { EditorToolbar } from './editor-toolbar' +import { FrontmatterProperties } from './frontmatter-properties' import { WikiLink } from '@/extensions/wiki-link' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links' +import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter' +import { RowboatMentionPopover } from './rowboat-mention-popover' import '@/styles/editor.css' +type RowboatMentionMatch = { + range: { from: number; to: number } +} + +type RowboatBlockEdit = { + /** ProseMirror position of the taskBlock node */ + nodePos: number + /** Existing instruction text */ + existingText: string +} + type WikiLinkConfig = { files: string[] recent: string[] @@ -189,15 +233,132 @@ 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 + onPrimaryHeadingCommit?: () => void + preserveUntitledTitleHeading?: boolean placeholder?: string wikiLinks?: WikiLinkConfig onImageUpload?: (file: File) => Promise editorSessionKey?: number onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void editable?: boolean + frontmatter?: string | null + onFrontmatterChange?: (raw: string | null) => void + onExport?: (format: 'md' | 'pdf' | 'docx') => void + notePath?: string } type WikiLinkMatch = { @@ -278,12 +439,18 @@ const TabIndentExtension = Extension.create({ export function MarkdownEditor({ content, onChange, + onPrimaryHeadingCommit, + preserveUntitledTitleHeading = false, placeholder = 'Start writing...', wikiLinks, onImageUpload, editorSessionKey = 0, onHistoryHandlersChange, editable = true, + frontmatter, + onFrontmatterChange, + onExport, + notePath, }: MarkdownEditorProps) { const isInternalUpdate = useRef(false) const wrapperRef = useRef(null) @@ -292,8 +459,20 @@ export function MarkdownEditor({ const [selectionHighlight, setSelectionHighlight] = useState(null) const selectionHighlightRef = useRef(null) const [wikiCommandValue, setWikiCommandValue] = useState('') + const onPrimaryHeadingCommitRef = useRef(onPrimaryHeadingCommit) const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' }) const handleSelectWikiLinkRef = useRef<(path: string) => void>(() => {}) + const [activeRowboatMention, setActiveRowboatMention] = useState(null) + const [rowboatBlockEdit, setRowboatBlockEdit] = useState(null) + const [rowboatAnchorTop, setRowboatAnchorTop] = useState<{ top: number; left: number; width: number } | null>(null) + const rowboatBlockEditRef = useRef(null) + + // @ mention autocomplete state (analogous to wiki-link state) + const [activeAtMention, setActiveAtMention] = useState<{ range: { from: number; to: number }; query: string } | null>(null) + const [atAnchorPosition, setAtAnchorPosition] = useState<{ left: number; top: number } | null>(null) + const [atCommandValue, setAtCommandValue] = useState('') + const atKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' }) + const handleSelectAtMentionRef = useRef<(value: string) => void>(() => {}) // Keep ref in sync with state for the plugin to access selectionHighlightRef.current = selectionHighlight @@ -304,6 +483,68 @@ export function MarkdownEditor({ [] ) + useEffect(() => { + onPrimaryHeadingCommitRef.current = onPrimaryHeadingCommit + }, [onPrimaryHeadingCommit]) + + const maybeCommitPrimaryHeading = useCallback((view: EditorView) => { + const onCommit = onPrimaryHeadingCommitRef.current + if (!onCommit) return + const { selection, doc } = view.state + if (!selection.empty) return + + const { $from } = selection + if ($from.depth < 1 || $from.index(0) !== 0) return + if (!['heading', 'paragraph'].includes($from.parent.type.name)) return + + const firstNode = doc.firstChild + if (!firstNode || !['heading', 'paragraph'].includes(firstNode.type.name)) return + + onCommit() + }, []) + + const preventTitleHeadingDemotion = useCallback((view: EditorView, event: KeyboardEvent) => { + if (!preserveUntitledTitleHeading) return false + if (event.key !== 'Backspace' || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return false + + const { selection } = view.state + if (!selection.empty) return false + + const { $from } = selection + if ($from.depth < 1 || $from.index(0) !== 0) return false + if ($from.parent.type.name !== 'heading') return false + + const headingLevel = (( + $from.parent.attrs as { level?: number } | null | undefined + )?.level) ?? 0 + if (headingLevel !== 1) return false + if ($from.parentOffset !== 0) return false + if ($from.parent.textContent.length > 0) return false + + event.preventDefault() + return true + }, [preserveUntitledTitleHeading]) + + const promoteFirstParagraphToTitleHeading = useCallback((view: EditorView) => { + if (!preserveUntitledTitleHeading) return + + const { state, dispatch } = view + const { selection } = state + if (!selection.empty) return + + const { $from } = selection + if ($from.depth < 1 || $from.index(0) !== 0) return + if ($from.parent.type.name !== 'paragraph') return + if ($from.parentOffset !== 0) return + if ($from.parent.textContent.length > 0) return + + const headingType = state.schema.nodes.heading + if (!headingType) return + + const tr = state.tr.setNodeMarkup($from.before(1), headingType, { level: 1 }) + dispatch(tr) + }, [preserveUntitledTitleHeading]) + const editor = useEditor({ editable, extensions: [ @@ -327,6 +568,14 @@ export function MarkdownEditor({ }, }), ImageUploadPlaceholderExtension, + TaskBlockExtension, + ImageBlockExtension, + EmbedBlockExtension, + ChartBlockExtension, + TableBlockExtension, + CalendarBlockExtension, + EmailBlockExtension, + TranscriptBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { @@ -359,11 +608,14 @@ export function MarkdownEditor({ markdown = postprocessMarkdown(markdown) onChange(markdown) }, + onBlur: ({ editor }) => { + maybeCommitPrimaryHeading(editor.view) + }, editorProps: { attributes: { class: 'prose prose-sm max-w-none focus:outline-none', }, - handleKeyDown: (_view, event) => { + handleKeyDown: (view, event) => { const state = wikiKeyStateRef.current if (state.open) { if (event.key === 'Escape') { @@ -396,6 +648,58 @@ export function MarkdownEditor({ } } + // @ mention autocomplete keyboard handling + const atState = atKeyStateRef.current + if (atState.open) { + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + setActiveAtMention(null) + setAtAnchorPosition(null) + setAtCommandValue('') + return true + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (atState.options.length === 0) return true + event.preventDefault() + event.stopPropagation() + const currentIndex = Math.max(0, atState.options.indexOf(atState.value)) + const delta = event.key === 'ArrowDown' ? 1 : -1 + const nextIndex = (currentIndex + delta + atState.options.length) % atState.options.length + setAtCommandValue(atState.options[nextIndex]) + return true + } + + if (event.key === 'Enter' || event.key === 'Tab') { + if (atState.options.length === 0) return true + event.preventDefault() + event.stopPropagation() + const selected = atState.options.includes(atState.value) ? atState.value : atState.options[0] + handleSelectAtMentionRef.current(selected) + return true + } + } + + if (preventTitleHeadingDemotion(view, event)) { + return true + } + + const isPrintableKey = event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey + if (isPrintableKey) { + promoteFirstParagraphToTitleHeading(view) + } + + if ( + event.key === 'Enter' + && !event.shiftKey + && !event.ctrlKey + && !event.metaKey + && !event.altKey + ) { + maybeCommitPrimaryHeading(view) + } + return false }, handleClickOn: (_view, _pos, node, _nodePos, event) => { @@ -407,7 +711,12 @@ export function MarkdownEditor({ return false }, }, - }, [editorSessionKey]) + }, [ + editorSessionKey, + maybeCommitPrimaryHeading, + preventTitleHeadingDemotion, + promoteFirstParagraphToTitleHeading, + ]) const orderedFiles = useMemo(() => { if (!wikiLinks) return [] @@ -476,6 +785,118 @@ export function MarkdownEditor({ }) }, [editor, wikiLinks]) + const updateRowboatMentionState = useCallback(() => { + if (!editor) return + const { selection } = editor.state + if (!selection.empty) { + setActiveRowboatMention(null) + setRowboatAnchorTop(null) + return + } + + const { $from } = selection + if ($from.parent.type.spec.code) { + setActiveRowboatMention(null) + setRowboatAnchorTop(null) + return + } + + const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n') + const textBefore = text.slice(0, $from.parentOffset) + + // Match @rowboat at a word boundary (preceded by nothing or whitespace) + const match = textBefore.match(/(^|\s)@rowboat$/) + if (!match) { + setActiveRowboatMention(null) + setRowboatAnchorTop(null) + return + } + + const triggerStart = textBefore.length - '@rowboat'.length + const from = selection.from - (textBefore.length - triggerStart) + const to = selection.from + setActiveRowboatMention({ range: { from, to } }) + + const wrapper = wrapperRef.current + if (!wrapper) { + setRowboatAnchorTop(null) + return + } + + const coords = editor.view.coordsAtPos(selection.from) + const wrapperRect = wrapper.getBoundingClientRect() + const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null + const pmRect = proseMirrorEl?.getBoundingClientRect() + setRowboatAnchorTop({ + top: coords.top - wrapperRect.top + wrapper.scrollTop, + left: pmRect ? pmRect.left - wrapperRect.left : 0, + width: pmRect ? pmRect.width : wrapperRect.width, + }) + }, [editor]) + + // Detect @ trigger for autocomplete popover (similar to [[ detection) + const updateAtMentionState = useCallback(() => { + if (!editor) return + const { selection } = editor.state + if (!selection.empty) { + setActiveAtMention(null) + setAtAnchorPosition(null) + return + } + + const { $from } = selection + // Skip code blocks + if ($from.parent.type.spec.code) { + setActiveAtMention(null) + setAtAnchorPosition(null) + return + } + // Skip inline code marks + if ($from.marks().some((mark) => mark.type.spec.code)) { + setActiveAtMention(null) + setAtAnchorPosition(null) + return + } + + const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n') + const textBefore = text.slice(0, $from.parentOffset) + + // Find @ at a word boundary (start of line or preceded by whitespace) + const atMatch = textBefore.match(/(^|[\s])@([a-zA-Z0-9]*)$/) + if (!atMatch) { + setActiveAtMention(null) + setAtAnchorPosition(null) + return + } + + const query = atMatch[2] // text after @ + + // If the full "@rowboat" is already typed, let updateRowboatMentionState handle it + if (query === 'rowboat') { + setActiveAtMention(null) + setAtAnchorPosition(null) + return + } + + const atSymbolOffset = textBefore.lastIndexOf('@') + const matchText = textBefore.slice(atSymbolOffset) + const range = { from: selection.from - matchText.length, to: selection.from } + setActiveAtMention({ range, query }) + + const wrapper = wrapperRef.current + if (!wrapper) { + setAtAnchorPosition(null) + return + } + + const coords = editor.view.coordsAtPos(selection.from) + const wrapperRect = wrapper.getBoundingClientRect() + setAtAnchorPosition({ + left: coords.left - wrapperRect.left, + top: coords.bottom - wrapperRect.top, + }) + }, [editor]) + useEffect(() => { if (!editor || !wikiLinks) return editor.on('update', updateWikiLinkState) @@ -486,6 +907,42 @@ export function MarkdownEditor({ } }, [editor, wikiLinks, updateWikiLinkState]) + useEffect(() => { + if (!editor) return + editor.on('update', updateRowboatMentionState) + editor.on('selectionUpdate', updateRowboatMentionState) + return () => { + editor.off('update', updateRowboatMentionState) + editor.off('selectionUpdate', updateRowboatMentionState) + } + }, [editor, updateRowboatMentionState]) + + useEffect(() => { + if (!editor) return + editor.on('update', updateAtMentionState) + editor.on('selectionUpdate', updateAtMentionState) + return () => { + editor.off('update', updateAtMentionState) + editor.off('selectionUpdate', updateAtMentionState) + } + }, [editor, updateAtMentionState]) + + // When a tell-rowboat block is clicked, compute anchor and open popover + useEffect(() => { + if (!rowboatBlockEdit || !editor) return + const wrapper = wrapperRef.current + if (!wrapper) return + const coords = editor.view.coordsAtPos(rowboatBlockEdit.nodePos) + const wrapperRect = wrapper.getBoundingClientRect() + const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null + const pmRect = proseMirrorEl?.getBoundingClientRect() + setRowboatAnchorTop({ + top: coords.top - wrapperRect.top + wrapper.scrollTop, + left: pmRect ? pmRect.left - wrapperRect.left : 0, + width: pmRect ? pmRect.width : wrapperRect.width, + }) + }, [editor, rowboatBlockEdit]) + // Update editor content when prop changes (e.g., file selection changes) useEffect(() => { if (editor && content !== undefined) { @@ -576,9 +1033,190 @@ export function MarkdownEditor({ handleSelectWikiLinkRef.current = handleSelectWikiLink }, [handleSelectWikiLink]) + const handleRowboatAdd = useCallback(async (instruction: string) => { + if (!editor) return + + if (rowboatBlockEdit) { + // Editing existing taskBlock — update its data attribute + const { nodePos } = rowboatBlockEdit + const node = editor.state.doc.nodeAt(nodePos) + if (node && node.type.name === 'taskBlock') { + // Preserve existing schedule data + let updated: Record = { instruction } + try { + const existing = JSON.parse(node.attrs.data || '{}') + updated = { ...existing, instruction } + } catch { + // Invalid JSON — just write new + } + const tr = editor.state.tr.setNodeMarkup(nodePos, undefined, { data: JSON.stringify(updated) }) + editor.view.dispatch(tr) + } + setRowboatBlockEdit(null) + rowboatBlockEditRef.current = null + setRowboatAnchorTop(null) + return + } + + if (activeRowboatMention) { + // 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: insertFrom, to: insertTo }, + [ + { type: 'taskBlock', attrs: { data: JSON.stringify(blockData) } }, + { type: 'paragraph' }, + ], + ) + .run() + + 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, notePath]) + + const handleRowboatRemove = useCallback(() => { + if (!editor || !rowboatBlockEdit) return + const { nodePos } = rowboatBlockEdit + const node = editor.state.doc.nodeAt(nodePos) + if (node) { + editor + .chain() + .focus() + .deleteRange({ from: nodePos, to: nodePos + node.nodeSize }) + .run() + } + setRowboatBlockEdit(null) + rowboatBlockEditRef.current = null + setRowboatAnchorTop(null) + }, [editor, rowboatBlockEdit]) + const handleScroll = useCallback(() => { updateWikiLinkState() - }, [updateWikiLinkState]) + updateAtMentionState() + }, [updateWikiLinkState, updateAtMentionState]) const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition) const wikiOptions = useMemo(() => { @@ -606,6 +1244,63 @@ export function MarkdownEditor({ setWikiCommandValue((prev) => (wikiOptions.includes(prev) ? prev : wikiOptions[0])) }, [showWikiPopover, wikiOptions]) + // @ mention autocomplete options + const atMentionOptions = useMemo(() => [ + { value: 'rowboat', label: '@rowboat', description: 'Research, schedule, or run tasks with AI' }, + ], []) + + const filteredAtOptions = useMemo(() => { + if (!activeAtMention) return [] + const q = activeAtMention.query.toLowerCase() + if (!q) return atMentionOptions + return atMentionOptions.filter((opt) => opt.value.toLowerCase().startsWith(q)) + }, [activeAtMention, atMentionOptions]) + + const atOptionValues = useMemo(() => filteredAtOptions.map((o) => o.value), [filteredAtOptions]) + const showAtPopover = Boolean(activeAtMention && atAnchorPosition && filteredAtOptions.length > 0) + + useEffect(() => { + atKeyStateRef.current = { open: showAtPopover, options: atOptionValues, value: atCommandValue } + }, [showAtPopover, atOptionValues, atCommandValue]) + + // Keep @ cmdk selection in sync + useEffect(() => { + if (!showAtPopover) { + setAtCommandValue('') + return + } + if (atOptionValues.length === 0) { + setAtCommandValue('') + return + } + setAtCommandValue((prev) => (atOptionValues.includes(prev) ? prev : atOptionValues[0])) + }, [showAtPopover, atOptionValues]) + + // @ mention selection handler + const handleSelectAtMention = useCallback((value: string) => { + if (!editor || !activeAtMention) return + + if (value === 'rowboat') { + // Replace "@" with "@rowboat" — this triggers updateRowboatMentionState + editor + .chain() + .focus() + .insertContentAt( + { from: activeAtMention.range.from, to: activeAtMention.range.to }, + '@rowboat' + ) + .run() + } + + setActiveAtMention(null) + setAtAnchorPosition(null) + setAtCommandValue('') + }, [editor, activeAtMention]) + + useEffect(() => { + handleSelectAtMentionRef.current = handleSelectAtMention + }, [handleSelectAtMention]) + // Handle keyboard shortcuts const handleKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.key === 's' && (event.metaKey || event.ctrlKey)) { @@ -626,7 +1321,16 @@ export function MarkdownEditor({ editor={editor} onSelectionHighlight={setSelectionHighlight} onImageUpload={handleImageUploadWithPlaceholder} + onExport={onExport} /> + {(frontmatter !== undefined) && onFrontmatterChange && ( + + )} +
    {wikiLinks ? ( @@ -683,6 +1387,64 @@ export function MarkdownEditor({ ) : null} + {/* @ mention autocomplete popover */} + { + if (!open) { + setActiveAtMention(null) + setAtAnchorPosition(null) + setAtCommandValue('') + } + }} + > + + + + event.preventDefault()} + > + + + {filteredAtOptions.map((opt) => ( + handleSelectAtMention(opt.value)} + > +
    + {opt.label} + {opt.description} +
    +
    + ))} +
    +
    +
    +
    + { + setActiveRowboatMention(null) + setRowboatBlockEdit(null) + rowboatBlockEditRef.current = null + setRowboatAnchorTop(null) + }} + />
    ) diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 9398f2fe..82064205 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -2,8 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { Loader2, Mic, Mail, CheckCircle2 } from "lucide-react" -// import { MessageSquare } from "lucide-react" +import { Loader2, Mic, Mail, Calendar, CheckCircle2, ArrowLeft, MessageSquare } from "lucide-react" import { Dialog, @@ -23,10 +22,10 @@ import { SelectValue, } from "@/components/ui/select" import { cn } from "@/lib/utils" -import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" 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 @@ -39,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" @@ -51,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") @@ -80,11 +82,31 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [granolaLoading, setGranolaLoading] = useState(true) const [showMoreProviders, setShowMoreProviders] = useState(false) - // Composio/Slack state + // Composio API key state const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) - const [slackConnected, setSlackConnected] = useState(false) - // const [slackLoading, setSlackLoading] = useState(true) - const [slackConnecting, setSlackConnecting] = useState(false) + const [, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') + + // Slack state (agent-slack CLI) + const [slackEnabled, setSlackEnabled] = useState(false) + const [slackLoading, setSlackLoading] = useState(true) + const [slackWorkspaces, setSlackWorkspaces] = useState>([]) + const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState>([]) + const [slackSelectedUrls, setSlackSelectedUrls] = useState>(new Set()) + const [slackPickerOpen, setSlackPickerOpen] = useState(false) + const [slackDiscovering, setSlackDiscovering] = useState(false) + const [slackDiscoverError, setSlackDiscoverError] = useState(null) + + // Composio/Gmail state + const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) + const [gmailConnected, setGmailConnected] = useState(false) + const [gmailLoading, setGmailLoading] = useState(true) + const [gmailConnecting, setGmailConnecting] = useState(false) + + // Composio/Google Calendar state + const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) + const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) + const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) const updateProviderConfig = useCallback( (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => { @@ -113,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 @@ -129,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 @@ -212,49 +252,127 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, []) - // Load Slack connection status - const refreshSlackStatus = useCallback(async () => { + // Load Slack config + const refreshSlackConfig = useCallback(async () => { try { - // setSlackLoading(true) - const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' }) - setSlackConnected(result.isConnected) + 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 status:', error) - setSlackConnected(false) + console.error('Failed to load Slack config:', error) + setSlackEnabled(false) + setSlackWorkspaces([]) } finally { - // setSlackLoading(false) + setSlackLoading(false) } }, []) - // Start Slack connection - const startSlackConnect = useCallback(async () => { + // Enable Slack: discover workspaces + const handleSlackEnable = useCallback(async () => { + setSlackDiscovering(true) + setSlackDiscoverError(null) try { - setSlackConnecting(true) - const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' }) - if (!result.success) { - toast.error(result.error || 'Failed to connect to Slack') - setSlackConnecting(false) + 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) } - // Success will be handled by composio:didConnect event } catch (error) { - console.error('Failed to connect to Slack:', error) - toast.error('Failed to connect to Slack') - setSlackConnecting(false) + console.error('Failed to discover Slack workspaces:', error) + setSlackDiscoverError('Failed to discover Slack workspaces') + setSlackPickerOpen(true) + } finally { + setSlackDiscovering(false) } }, []) - // Connect to Slack via Composio (checks if configured first) - /* - const handleConnectSlack = useCallback(async () => { - // Check if Composio is configured + // 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 startSlackConnect() - }, [startSlackConnect]) - */ + 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) => { @@ -262,20 +380,72 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { await window.ipc.invoke('composio:set-api-key', { apiKey }) setComposioApiKeyOpen(false) toast.success('Composio API key saved') - // Now start the Slack connection - await startSlackConnect() + await startGmailConnect() } catch (error) { console.error('Failed to save Composio API key:', error) toast.error('Failed to save API key') } - }, [startSlackConnect]) + }, [startGmailConnect]) + + // Save selected Slack workspaces + const handleSlackSaveWorkspaces = useCallback(async () => { + const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url)) + try { + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected }) + setSlackEnabled(true) + setSlackWorkspaces(selected) + setSlackPickerOpen(false) + toast.success('Slack enabled') + } catch (error) { + console.error('Failed to save Slack config:', error) + toast.error('Failed to save Slack settings') + } finally { + setSlackLoading(false) + } + }, [slackAvailableWorkspaces, slackSelectedUrls]) + + // Disable Slack + const handleSlackDisable = useCallback(async () => { + try { + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] }) + setSlackEnabled(false) + setSlackWorkspaces([]) + setSlackPickerOpen(false) + toast.success('Slack disabled') + } catch (error) { + console.error('Failed to update Slack config:', error) + toast.error('Failed to update Slack settings') + } finally { + setSlackLoading(false) + } + }, []) 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() } @@ -319,8 +489,18 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Refresh Granola refreshGranolaConfig() - // Refresh Slack status - refreshSlackStatus() + // 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 @@ -349,7 +529,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshSlackStatus]) + }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar]) // Refresh statuses when modal opens or providers list changes useEffect(() => { @@ -358,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, @@ -371,38 +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 }, []) - // Listen for Composio connection events + // Auto-advance from Rowboat sign-in step when OAuth completes + useEffect(() => { + if (onboardingPath !== 'rowboat' || currentStep !== 0) return + + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + if (event.provider === 'rowboat' && event.success) { + setCurrentStep(3 as Step) + } + }) + + return cleanup + }, [onboardingPath, currentStep]) + + // Listen for Composio connection events (state updates only — toasts handled by ConnectorsPopover) useEffect(() => { const cleanup = window.ipc.on('composio:didConnect', (event) => { - const { toolkitSlug, success, error } = event + const { toolkitSlug, success } = event - if (toolkitSlug === 'slack') { - setSlackConnected(success) - setSlackConnecting(false) + if (toolkitSlug === 'gmail') { + setGmailConnected(success) + setGmailConnecting(false) + } - if (success) { - toast.success('Connected to Slack') - } else { - toast.error(error || 'Failed to connect to Slack') - } + if (toolkitSlug === 'googlecalendar') { + setGoogleCalendarConnected(success) + setGoogleCalendarConnecting(false) } }) return cleanup }, []) + const startConnect = useCallback(async (provider: string, clientId?: string) => { setProviderStates(prev => ({ ...prev, @@ -450,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) => { @@ -545,29 +741,28 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
    ) - // Render Slack row - /* - const renderSlackRow = () => ( + // Render Gmail Composio row + const renderGmailRow = () => (
    - +
    - Slack - {slackLoading ? ( + Gmail + {gmailLoading ? ( Checking... ) : ( - Send messages and view channels + Sync emails )}
    - {slackLoading ? ( + {gmailLoading ? ( - ) : slackConnected ? ( + ) : gmailConnected ? (
    Connected @@ -576,10 +771,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
    ) - */ - // Step 0: LLM Setup + // Render Google Calendar Composio row + const renderGoogleCalendarRow = () => ( +
    +
    +
    + +
    +
    + Google Calendar + {googleCalendarLoading ? ( + Checking... + ) : ( + + Sync calendar events + + )} +
    +
    +
    + {googleCalendarLoading ? ( + + ) : googleCalendarConnected ? ( +
    + + Connected +
    + ) : ( + + )} +
    +
    + ) + + // Render Slack row + const renderSlackRow = () => ( +
    +
    +
    +
    + +
    +
    + Slack + {slackEnabled && slackWorkspaces.length > 0 ? ( + + {slackWorkspaces.map(w => w.name).join(', ')} + + ) : ( + + Send messages and view channels + + )} +
    +
    +
    + {(slackLoading || slackDiscovering) && ( + + )} + {slackEnabled ? ( + handleSlackDisable()} + disabled={slackLoading} + /> + ) : ( + + )} +
    +
    + {slackPickerOpen && ( +
    + {slackDiscoverError ? ( +

    {slackDiscoverError}

    + ) : ( + <> + {slackAvailableWorkspaces.map(w => ( + + ))} + + + )} +
    + )} +
    + ) + + // 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" }, @@ -767,10 +1202,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
    )} -
    +
    +
    + {/* Team Communication Section */} +
    +
    + Team Communication +
    + {renderSlackRow()} +
    )}
    @@ -828,16 +1279,22 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { - +
    + + +
    ) - // Step 2: Completion + // Step 4: Completion const renderCompletionStep = () => { - const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected + const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected || googleCalendarConnected return (
    @@ -860,6 +1317,18 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {

    Connected accounts:

    + {gmailConnected && ( +
    + + Gmail (Email) +
    + )} + {googleCalendarConnected && ( +
    + + Google Calendar +
    + )} {connectedProviders.includes('google') && (
    @@ -878,7 +1347,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { Granola (Local meeting notes)
    )} - {slackConnected && ( + {slackEnabled && (
    Slack (Team communication) @@ -908,7 +1377,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { open={composioApiKeyOpen} onOpenChange={setComposioApiKeyOpen} onSubmit={handleComposioApiKeySubmit} - isSubmitting={slackConnecting} + isSubmitting={gmailConnecting} /> {}}> 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..2b32309a --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx @@ -0,0 +1,132 @@ +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, gmailConnected, googleCalendarConnected, handleComplete } = state + const hasConnections = connectedProviders.length > 0 || 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) + + )} +
    +
    + )} + + {/* 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..ea8335de --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx @@ -0,0 +1,213 @@ +import { Loader2, CheckCircle2, ArrowLeft, Calendar, FileText } from "lucide-react" +import { motion } from "motion/react" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { GmailIcon, FirefliesIcon } 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, + useComposioForGoogle, gmailConnected, gmailLoading, gmailConnecting, handleConnectGmail, + useComposioForGoogleCalendar, googleCalendarConnected, googleCalendarLoading, googleCalendarConnecting, handleConnectGoogleCalendar, + handleNext, handleBack, + } = state + + let cardIndex = 0 + + return ( +
    + {/* Title */} +

    + Connect Your Accounts +

    +

    + Rowboat gets smarter the more it knows about your work. Connect your accounts to get started. You can find more tools in Settings. +

    + + {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 + + +
    +
    + +
    +
    +
    Rowboat Meeting Notes
    +
    Built in. Ready to use.
    +
    +
    +
    +
    + +
    +
    +
    + {providers.includes('fireflies-ai') && ( + } + iconBg="bg-amber-500/10" + iconColor="text-amber-500" + providerState={providerStates['fireflies-ai']} + onConnect={() => handleConnect('fireflies-ai')} + index={cardIndex++} + /> + )} +
    +
    + )} + + {/* 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..a9956245 --- /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 leading 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/rowboat-mention-popover.tsx b/apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx new file mode 100644 index 00000000..a5a63bc7 --- /dev/null +++ b/apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx @@ -0,0 +1,109 @@ +import { useState, useRef, useEffect } from 'react' +import { Loader2 } from 'lucide-react' + +interface RowboatMentionPopoverProps { + open: boolean + anchor: { top: number; left: number; width: number } | null + initialText?: string + onAdd: (instruction: string) => void | Promise + onRemove?: () => void + onClose: () => void +} + +export function RowboatMentionPopover({ open, anchor, initialText = '', onAdd, onRemove, onClose }: RowboatMentionPopoverProps) { + const [text, setText] = useState('') + const [loading, setLoading] = useState(false) + const textareaRef = useRef(null) + const containerRef = useRef(null) + + useEffect(() => { + if (open) { + setText(initialText) + setLoading(false) + requestAnimationFrame(() => { + textareaRef.current?.focus() + }) + } + }, [open, initialText]) + + // Close on outside click + useEffect(() => { + if (!open) return + const handleMouseDown = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + onClose() + } + } + document.addEventListener('mousedown', handleMouseDown) + return () => document.removeEventListener('mousedown', handleMouseDown) + }, [open, onClose]) + + if (!open || !anchor) return null + + const handleSubmit = async () => { + const trimmed = text.trim() + if (!trimmed || loading) return + setLoading(true) + try { + await onAdd(trimmed) + } finally { + setLoading(false) + } + setText('') + } + + return ( +
    +
    +
    + @rowboat +