Merge pull request #467 from rowboatlabs/dev

Dev changes
This commit is contained in:
Ramnique Singh 2026-04-07 22:21:05 +05:30 committed by GitHub
commit 598aeb59cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
151 changed files with 22567 additions and 5462 deletions

View file

@ -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": "<key>"
}
```
### 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.

View file

@ -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
}
]
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.screen-capture</key>
<true/>
</dict>
</plist>

View file

@ -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,

View file

@ -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",

View file

@ -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<string, {
toolkitSlug: string;
connectedAccountId: string;
authConfigId: string;
server: import('http').Server;
timeout: NodeJS.Timeout;
}>();
/**
@ -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<string, unknown>
): 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,
};
}

View file

@ -0,0 +1,7 @@
declare module 'html-to-docx' {
export default function htmlToDocx(
htmlString: string,
headerHTMLString?: string,
options?: Record<string, unknown>,
): Promise<ArrayBuffer>;
}

View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Headings (must come before other processing)
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>')
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>')
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>')
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>')
// Bold and italic
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
// Horizontal rules
html = html.replace(/^---$/gm, '<hr>')
// Unordered lists
html = html.replace(/^[-*]\s+(.+)$/gm, '<li>$1</li>')
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
// Blockquotes
html = html.replace(/^&gt;\s+(.+)$/gm, '<blockquote>$1</blockquote>')
// Paragraphs: wrap remaining lines that aren't already wrapped in HTML tags
html = html.replace(/^(?!<[a-z/])((?!^\s*$).+)$/gm, '<p>$1</p>')
// Clean up consecutive list items into lists
html = html.replace(/(<li>.*<\/li>\n?)+/g, (match) => `<ul>${match}</ul>`)
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>${title}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 700px; margin: 40px auto; padding: 0 20px; color: #1a1a1a; line-height: 1.6; font-size: 14px; }
h1 { font-size: 1.8em; margin-top: 1em; } h2 { font-size: 1.4em; margin-top: 1em; } h3 { font-size: 1.2em; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
blockquote { border-left: 3px solid #ddd; margin: 1em 0; padding: 0.5em 1em; color: #555; }
hr { border: none; border-top: 1px solid #ddd; margin: 2em 0; }
ul { padding-left: 1.5em; }
a { color: #0066cc; }
</style></head><body>${html}</body></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<IGranolaConfigRepo>('granolaConfigRepo');
const config = await repo.getConfig();
@ -409,6 +504,30 @@ export function setupIpcHandlers() {
return { success: true };
},
'slack:getConfig': async () => {
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
const config = await repo.getConfig();
return { enabled: config.enabled, workspaces: config.workspaces };
},
'slack:setConfig': async (_event, args) => {
const repo = container.resolve<ISlackConfigRepo>('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<string, Electron.FileFilter[]> = {
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();
},
});
}

View file

@ -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();

View file

@ -75,7 +75,7 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
* Get or create OAuth configuration for a provider
*/
async function getProviderConfiguration(provider: string, clientIdOverride?: string): Promise<Configuration> {
const config = getProviderConfig(provider);
const config = await getProviderConfig(provider);
const resolveClientId = async (): Promise<string> => {
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<string, string>) => {
// 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);

View file

@ -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",

View file

@ -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%);
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<string, React.ReactNode> = {
"open-note": <FileTextIcon className="size-4" />,
"open-view": <NetworkIcon className="size-4" />,
"update-base-view": <FilterIcon className="size-4" />,
"create-base": <PlusCircleIcon className="size-4" />,
};
export function AppActionCard({ data, status }: AppActionCardProps) {
const isRunning = status === "pending" || status === "running";
const isError = status === "error";
return (
<div className="not-prose mb-4 flex items-center gap-2 rounded-md border px-3 py-2">
<span className="text-muted-foreground">
{actionIcons[data.action] || <LayoutGridIcon className="size-4" />}
</span>
<span className="text-sm flex-1">{data.label}</span>
{isRunning ? (
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
) : isError ? (
<span className="text-xs text-destructive">Failed</span>
) : (
<CheckCircleIcon className="size-3.5 text-green-600" />
)}
</div>
);
}

View file

@ -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<string | null>(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 (
<div className="not-prose mb-4 flex items-center gap-3 rounded-lg border px-3 py-2.5">
{/* Toolkit initial */}
<div className="size-7 rounded bg-muted flex items-center justify-center flex-shrink-0">
<span className="text-xs font-bold text-muted-foreground">
{displayName.charAt(0).toUpperCase()}
</span>
</div>
{/* Name & status */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">{displayName}</span>
{connectionState === "connected" && (
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
Connected
</span>
)}
</div>
{connectionState === "error" && errorMessage && (
<p className="text-xs text-destructive truncate">{errorMessage}</p>
)}
{connectionState === "idle" && isToolRunning && (
<p className="text-xs text-muted-foreground">Waiting to connect...</p>
)}
</div>
{/* Action area */}
{connectionState === "connected" ? (
<CheckCircleIcon className="size-4 text-green-600 flex-shrink-0" />
) : connectionState === "connecting" ? (
<Button size="sm" disabled className="text-xs h-7 flex-shrink-0">
<LoaderIcon className="size-3 animate-spin mr-1" />
Connecting...
</Button>
) : connectionState === "error" ? (
<div className="flex items-center gap-1.5 flex-shrink-0">
<XCircleIcon className="size-3.5 text-destructive" />
<Button size="sm" variant="outline" onClick={handleConnect} className="text-xs h-7">
Retry
</Button>
</div>
) : isToolRunning ? (
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground flex-shrink-0" />
) : (
<Button size="sm" onClick={handleConnect} className="text-xs h-7 flex-shrink-0">
<Link2Icon className="size-3 mr-1" />
Connect
</Button>
)}
</div>
);
}

View file

@ -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<HTMLDivElement | null>;
isAtBottom: boolean;
scrollRef: RefObject<HTMLDivElement | null>;
scrollToBottom: () => void;
}
const ScrollPreservationContext = createContext<ScrollPreservationContextValue | null>(null);
const ConversationContext = createContext<ConversationContextValue | null>(null);
export type ConversationProps = ComponentProps<typeof StickToBottom> & {
export type ConversationProps = ComponentProps<"div"> & {
anchorMessageId?: string | null;
anchorRequestKey?: number;
children?: ReactNode;
};
export const Conversation = ({ className, children, ...props }: ConversationProps) => {
const [scrollContainer, setScrollContainer] = useState<HTMLElement | null>(null);
const isUserEngagedRef = useRef(false);
const savedScrollTopRef = useRef<number>(0);
const lastScrollHeightRef = useRef<number>(0);
export const Conversation = ({
anchorMessageId = null,
anchorRequestKey,
children,
className,
...props
}: ConversationProps) => {
const contentRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const spacerRef = useRef<HTMLDivElement | null>(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<HTMLElement>(
`[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<ConversationContextValue>(
() => ({
contentRef,
isAtBottom,
scrollRef,
scrollToBottom,
}),
[isAtBottom, scrollToBottom]
);
return (
<ScrollPreservationContext.Provider value={contextValue}>
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
<ConversationContext.Provider value={contextValue}>
<div
className={cn("relative flex-1 overflow-hidden", className)}
role="log"
{...props}
>
{children}
</StickToBottom>
</ScrollPreservationContext.Provider>
<div
className="h-full w-full overflow-y-auto [scrollbar-gutter:stable]"
ref={scrollRef}
>
{children}
<div ref={spacerRef} aria-hidden="true" />
</div>
</div>
</ConversationContext.Provider>
);
};
/**
* 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) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
}: ConversationContentProps) => {
const { contentRef } = useConversationContext();
return (
<div
className={cn("flex flex-col gap-8 p-4", className)}
ref={contentRef}
{...props}
/>
);
};
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) => (
<div
@ -183,13 +274,15 @@ export const ConversationEmptyState = ({
</div>
);
export const ScrollPositionPreserver = () => null;
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
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 && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
"absolute bottom-6 left-[50%] z-10 h-12 w-12 translate-x-[-50%] rounded-full border border-border/70 bg-background/95 text-foreground shadow-lg backdrop-blur-sm transition hover:bg-background",
className
)}
aria-label="Scroll to latest message"
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
variant="ghost"
{...props}
>
<ArrowDownIcon className="size-4" />
<ArrowDownIcon className="size-6" strokeWidth={1.75} />
</Button>
)
);

View file

@ -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 = ({
}) => (
<pre
className={cn(
"whitespace-pre-wrap text-xs font-mono",
"whitespace-pre-wrap text-xs font-mono break-all",
className
)}
>
@ -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) => (
<div className={cn("space-y-2 overflow-hidden p-4", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md border bg-muted/50 p-4 text-foreground">
<ToolCode code={formatToolValue(input ?? {})} />
</div>
</div>
);
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 = <div>{output as ReactNode}</div>;
if (typeof output === "object" && !isValidElement(output)) {
Output = <ToolCode code={formatToolValue(output ?? null)} />;
} else if (typeof output === "string") {
Output = <ToolCode code={formatToolValue(output)} />;
let OutputNode: ReactNode = null;
if (errorText) {
OutputNode = <ToolCode code={errorText} className="text-destructive" />;
} else if (output != null) {
if (typeof output === "object" && !isValidElement(output)) {
OutputNode = <ToolCode code={formatToolValue(output)} />;
} else if (typeof output === "string") {
OutputNode = <ToolCode code={output} />;
} else {
OutputNode = <div>{output as ReactNode}</div>;
}
}
return (
<div className={cn("space-y-2 p-4", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md border p-4 text-xs [&_table]:w-full",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
)}
>
{errorText && (
<div className="mb-2 font-sans text-xs text-destructive">
{errorText}
<div className="border-t">
{/* Tabs */}
<div className="flex">
<button
type="button"
className={cn(
"px-4 py-2 text-xs font-medium transition-colors border-b-2",
activeTab === "parameters"
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("parameters")}
>
Parameters
</button>
<button
type="button"
className={cn(
"px-4 py-2 text-xs font-medium transition-colors border-b-2",
activeTab === "result"
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("result")}
>
Result
</button>
</div>
{/* Tab content */}
<div className="p-3">
{activeTab === "parameters" && (
<div className="rounded-md border bg-muted/50 p-3 max-h-64 overflow-auto">
<ToolCode code={formatToolValue(input ?? {})} />
</div>
)}
{activeTab === "result" && (
<div
className={cn(
"rounded-md border p-3 max-h-64 overflow-auto",
errorText ? "bg-destructive/10" : "bg-muted/50"
)}
>
{hasOutput ? (
<div className={cn(errorText && "text-destructive")}>
{OutputNode}
</div>
) : (
<span className="text-xs text-muted-foreground">(pending...)</span>
)}
</div>
)}
{Output}
</div>
</div>
);
};

View file

@ -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<string, string | string[]>
mtimeMs: number
}
type SortDir = 'asc' | 'desc'
type ActiveFilter = { category: string; value: string }
export type BaseConfig = {
name: string
visibleColumns: string[]
columnWidths: Record<string, number>
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<BuiltinColumn, string> = {
name: 'Name',
folder: 'Folder',
mtimeMs: 'Last Modified',
}
/** Default pixel widths for columns */
const DEFAULT_WIDTHS: Record<string, number> = {
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<void>
remove: (path: string) => Promise<void>
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<NoteEntry[]>(() => {
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<Map<string, Record<string, string | string[]>>>(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<string, string | string[]> }
}
}),
)
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<NoteEntry[]>(() => {
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<string[]>(() => {
const keys = new Set<string>()
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<string[]>(() => {
return ['folder', ...allPropertyKeys]
}, [allPropertyKeys])
// All unique values per category, across all enriched notes
const valuesByCategory = useMemo<Record<string, string[]>>(() => {
const result: Record<string, Set<string>> = {}
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<string, string[]> = {}
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<HTMLInputElement>(null)
const [filterCategory, setFilterCategory] = useState<string | null>(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<Set<string> | null>(null)
const searchInputRef = useRef<HTMLInputElement>(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<string, string[]>()
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'
? <ArrowUp className="size-3 inline ml-1" />
: <ArrowDown className="size-3 inline ml-1" />
}
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Toolbar */}
<div className="shrink-0 border-b border-border px-4 py-2 flex items-center gap-3">
<Popover>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground">
<ListFilter className="size-3.5" />
Properties
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-0">
<Command>
<CommandInput placeholder="Search properties..." />
<CommandList>
<CommandEmpty>No properties found.</CommandEmpty>
<CommandGroup heading="Built-in">
{BUILTIN_COLUMNS.map((col) => (
<CommandItem key={col} onSelect={() => toggleColumn(col)}>
<Check className={cn('size-3.5 mr-2', visibleColumns.includes(col) ? 'opacity-100' : 'opacity-0')} />
{BUILTIN_LABELS[col]}
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Frontmatter">
{allPropertyKeys.map((key) => (
<CommandItem key={key} onSelect={() => toggleColumn(key)}>
<Check className={cn('size-3.5 mr-2', visibleColumns.includes(key) ? 'opacity-100' : 'opacity-0')} />
{toTitleCase(key)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Popover onOpenChange={(open) => { if (!open) setFilterCategory(null) }}>
<PopoverTrigger asChild>
<button className={cn(
'inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground',
filters.length > 0 && 'text-foreground',
)}>
<Filter className="size-3.5" />
Filter
{filters.length > 0 && (
<span className="rounded-full bg-primary text-primary-foreground px-1.5 text-[10px] font-medium leading-tight">
{filters.length}
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className={cn('p-0', filterCategory ? 'w-[420px]' : 'w-[200px]')}>
<div className="flex h-[300px]">
{/* Left: categories */}
<div className={cn('overflow-auto', filterCategory ? 'w-[160px] border-r border-border' : 'flex-1')}>
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Attributes</span>
{filters.length > 0 && (
<button
onClick={clearFilters}
className="text-[10px] text-muted-foreground hover:text-foreground"
>
Reset
</button>
)}
</div>
{filterCategories.map((cat) => {
const activeCount = filters.filter((f) => f.category === cat).length
const isSelected = filterCategory === cat
return (
<button
key={cat}
onClick={() => setFilterCategory(cat)}
className={cn(
'w-full flex items-center gap-1.5 px-2 py-1.5 text-xs text-left hover:bg-accent transition-colors',
isSelected && 'bg-accent text-foreground',
!isSelected && 'text-muted-foreground',
)}
>
<span className="flex-1 truncate">{toTitleCase(cat)}</span>
{activeCount > 0 && (
<span className="rounded-full bg-primary text-primary-foreground px-1.5 text-[10px] font-medium leading-tight shrink-0">
{activeCount}
</span>
)}
</button>
)
})}
</div>
{/* Right: values for selected category */}
{filterCategory && (
<div className="flex-1 min-w-0 flex flex-col">
<Command className="flex-1 flex flex-col">
<CommandInput placeholder={`Search ${toTitleCase(filterCategory).toLowerCase()}...`} />
<CommandList className="flex-1 overflow-auto max-h-none">
<CommandEmpty>No values found.</CommandEmpty>
<CommandGroup>
{(valuesByCategory[filterCategory] ?? []).map((val) => {
const active = hasFilter(filters, { category: filterCategory, value: val })
return (
<CommandItem key={val} onSelect={() => toggleFilter(filterCategory, val)}>
<Check className={cn('size-3.5 mr-2 shrink-0', active ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">{val}</span>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</div>
)}
</div>
</PopoverContent>
</Popover>
<button
onClick={toggleSearch}
className={cn(
'inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground shrink-0',
searchOpen && 'text-foreground',
)}
>
<Search className="size-3.5" />
Search
</button>
{searchOpen && (
<div className="flex items-center gap-2 flex-1 min-w-0">
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => 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 && (
<span className="text-[10px] text-muted-foreground shrink-0">
{searchMatchPaths ? `${searchMatchPaths.size} matches` : '...'}
</span>
)}
<button
onClick={toggleSearch}
className="text-muted-foreground hover:text-foreground shrink-0"
>
<X className="size-3" />
</button>
</div>
)}
<div className="flex-1" />
<button
onClick={handleSaveClick}
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground shrink-0"
>
<Save className="size-3.5" />
{isDefaultBase ? 'Save As' : 'Save'}
</button>
</div>
{/* Filter bar */}
{filters.length > 0 && (
<div className="shrink-0 border-b border-border px-4 py-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground shrink-0">
{sortedNotes.length} of {enrichedNotes.length} notes
</span>
{filters.map((f) => (
<button
key={`${f.category}:${f.value}`}
onClick={() => toggleFilter(f.category, f.value)}
className="inline-flex items-center gap-1 rounded-full bg-primary text-primary-foreground px-2 py-0.5 text-[11px] font-medium"
>
<span className="text-primary-foreground/60">{f.category}:</span>
{f.value}
<X className="size-3" />
</button>
))}
<button onClick={clearFilters} className="text-xs text-muted-foreground hover:text-foreground">
Clear all
</button>
</div>
</div>
)}
{/* Table */}
<div className="flex-1 overflow-auto">
<table className="w-full text-sm" style={{ tableLayout: 'fixed' }}>
<colgroup>
{visibleColumns.map((col) => (
<col key={col} style={{ width: getColWidth(col) }} />
))}
</colgroup>
<thead className="sticky top-0 bg-background border-b border-border z-10">
<tr>
{visibleColumns.map((col) => (
<th
key={col}
className="relative text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none group"
onClick={() => handleSort(col)}
>
<span className="truncate block">{toTitleCase(col)}<SortIcon field={col} /></span>
{/* Resize handle */}
<div
className="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize opacity-0 group-hover:opacity-100 hover:!opacity-100 bg-border/60"
onMouseDown={(e) => onResizeStart(col, e)}
onClick={(e) => e.stopPropagation()}
/>
</th>
))}
</tr>
</thead>
<tbody>
{pageNotes.map((note) => (
<NoteRow
key={note.path}
note={note}
visibleColumns={visibleColumns}
filters={filters}
toggleFilter={toggleFilter}
onSelectNote={onSelectNote}
actions={actions}
/>
))}
{pageNotes.length === 0 && (
<tr>
<td colSpan={visibleColumns.length} className="px-4 py-8 text-center text-muted-foreground">
No notes found
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="shrink-0 border-t border-border px-4 py-2 flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{sortedNotes.length === 0
? '0 notes'
: `${clampedPage * PAGE_SIZE + 1}\u2013${Math.min((clampedPage + 1) * PAGE_SIZE, sortedNotes.length)} of ${sortedNotes.length}`}
</span>
{totalPages > 1 && (
<div className="flex items-center gap-1">
<button
disabled={clampedPage === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronLeft className="size-4" />
</button>
<span className="text-xs text-muted-foreground px-2">
Page {clampedPage + 1} of {totalPages}
</span>
<button
disabled={clampedPage >= totalPages - 1}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronRight className="size-4" />
</button>
</div>
)}
</div>
{/* Save As dialog */}
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
<DialogContent className="sm:max-w-[360px]">
<DialogHeader>
<DialogTitle>Save Base</DialogTitle>
<DialogDescription>Choose a name for this base view.</DialogDescription>
</DialogHeader>
<input
ref={saveInputRef}
type="text"
value={saveName}
onChange={(e) => 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
/>
<DialogFooter>
<button
onClick={() => setSaveDialogOpen(false)}
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
>
Cancel
</button>
<button
onClick={handleSaveConfirm}
disabled={!saveName.trim()}
className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Save
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
/** 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 <span className="font-medium truncate block">{note.name}</span>
}
if (column === 'folder') {
return <span className="text-muted-foreground truncate block">{note.folder}</span>
}
if (column === 'mtimeMs') {
return <span className="text-muted-foreground whitespace-nowrap truncate block">{formatDate(note.mtimeMs)}</span>
}
// 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 <span className="text-muted-foreground whitespace-nowrap truncate block">{formatDate(ms)}</span>
}
return <span className="text-muted-foreground whitespace-nowrap truncate block">{value}</span>
}
// Frontmatter column
const value = note.fields[column]
if (!value) return null
if (Array.isArray(value)) {
return (
<div className="flex items-center gap-1 flex-wrap">
{value.map((v) => (
<CategoryBadge
key={v}
category={column}
value={v}
active={hasFilter(filters, { category: column, value: v })}
onClick={toggleFilter}
/>
))}
</div>
)
}
// Single string value — render as badge for filterability
return (
<CategoryBadge
category={column}
value={value}
active={hasFilter(filters, { category: column, value })}
onClick={toggleFilter}
/>
)
}
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<HTMLInputElement>(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 = (
<tr
className="border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => onSelectNote(note.path)}
>
{visibleColumns.map((col) => (
<td key={col} className="px-4 py-2 overflow-hidden">
{col === 'name' && isRenaming ? (
<input
ref={inputRef}
type="text"
value={newName}
onChange={(e) => 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"
/>
) : (
<CellRenderer
note={note}
column={col}
filters={filters}
toggleFilter={toggleFilter}
/>
)}
</td>
))}
</tr>
)
if (!actions) return row
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{row}
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem onClick={handleCopyPath}>
<Copy className="mr-2 size-4" />
Copy Path
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}>
<Pencil className="mr-2 size-4" />
Rename
</ContextMenuItem>
<ContextMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="mr-2 size-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
function CategoryBadge({
category,
value,
active,
onClick,
}: {
category: string
value: string
active: boolean
onClick: (category: string, value: string) => void
}) {
return (
<Badge
variant={active ? 'default' : 'secondary'}
className={cn(
'text-[10px] px-1.5 py-0 cursor-pointer',
!active && 'hover:bg-primary hover:text-primary-foreground',
)}
onClick={(e) => {
e.stopPropagation()
onClick(category, value)
}}
>
{value}
</Badge>
)
}

View file

@ -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<string, string> = {
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<string, string>
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<HTMLInputElement>(null)
const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing
const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([])
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<string, unknown>
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<string, string>) || 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({
})}
</div>
)}
<div className="flex items-center gap-2 px-4 py-4">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
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 = ''
}}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
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 ── */
<div className="flex items-center gap-3 px-4 py-3">
<button
type="button"
onClick={onCancelRecording}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Cancel recording"
>
<X className="h-4 w-4" />
</button>
<div className="flex flex-1 items-center gap-2 overflow-hidden">
<VoiceWaveform />
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{recordingState === 'connecting' ? 'Connecting...' : recordingText || 'Listening...'}
</span>
</div>
<Button
size="icon"
onClick={onSubmitRecording}
disabled={!recordingText?.trim()}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
recordingText?.trim()
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-muted text-muted-foreground'
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
</div>
) : (
/* ── Normal input ── */
<>
<div className="px-4 pt-4 pb-2">
<PromptInputTextarea
placeholder="Type your message..."
onKeyDown={handleKeyDown}
autoFocus={isActive}
focusTrigger={isActive ? `${runId ?? 'new'}:${focusNonce}` : undefined}
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
/>
</div>
<div className="flex items-center gap-2 px-4 pb-3">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
@ -265,13 +532,114 @@ function ChatInputInner({
>
<Plus className="h-4 w-4" />
</button>
<PromptInputTextarea
placeholder="Type your message..."
onKeyDown={handleKeyDown}
autoFocus={isActive}
focusTrigger={isActive ? `${runId ?? 'new'}:${focusNonce}` : undefined}
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
/>
{searchAvailable && (
searchEnabled ? (
<button
type="button"
onClick={() => setSearchEnabled(false)}
className="flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-2.5 text-blue-600 transition-colors hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-400 dark:hover:bg-blue-900"
>
<Globe className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Search</span>
<X className="h-3 w-3" />
</button>
) : (
<button
type="button"
onClick={() => setSearchEnabled(true)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Search"
>
<Globe className="h-4 w-4" />
</button>
)
)}
<div className="flex-1" />
{configuredModels.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<span className="max-w-[150px] truncate">
{configuredModels.find((m) => `${m.flavor}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model'}
</span>
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={activeModelKey} onValueChange={handleModelChange}>
{configuredModels.map((m) => {
const key = `${m.flavor}/${m.model}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m.model}</span>
<span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.flavor] || m.flavor}</span>
</DropdownMenuRadioItem>
)
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
{onToggleTts && ttsAvailable && (
<div className="flex shrink-0 items-center">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onToggleTts}
className={cn(
'relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors',
ttsEnabled
? 'text-foreground hover:bg-muted'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
aria-label={ttsEnabled ? 'Disable voice output' : 'Enable voice output'}
>
<Headphones className="h-4 w-4" />
{!ttsEnabled && (
<span className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="block h-[1.5px] w-5 -rotate-45 rounded-full bg-muted-foreground" />
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="top">
{ttsEnabled ? 'Voice output on' : 'Voice output off'}
</TooltipContent>
</Tooltip>
{ttsEnabled && onTtsModeChange && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 w-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={ttsMode ?? 'summary'} onValueChange={(v) => onTtsModeChange(v as 'summary' | 'full')}>
<DropdownMenuRadioItem value="summary">Speak summary</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="full">Speak full response</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
{voiceAvailable && onStartRecording && (
<button
type="button"
onClick={onStartRecording}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Voice input"
>
<Mic className="h-4 w-4" />
</button>
)}
{isProcessing ? (
<Button
size="icon"
@ -306,6 +674,31 @@ function ChatInputInner({
</Button>
)}
</div>
</>
)}
</div>
)
}
/** Animated waveform bars for the recording indicator */
function VoiceWaveform() {
return (
<div className="flex items-center gap-[3px] h-5">
{[0, 1, 2, 3, 4].map((i) => (
<span
key={i}
className="w-[3px] rounded-full bg-primary"
style={{
animation: `voice-wave 1.2s ease-in-out ${i * 0.15}s infinite`,
}}
/>
))}
<style>{`
@keyframes voice-wave {
0%, 100% { height: 4px; }
50% { height: 16px; }
}
`}</style>
</div>
)
}
@ -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 (
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
@ -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}
/>
</PromptInputProvider>
)

View file

@ -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<string | null>(null)
useEffect(() => {
window.ipc.invoke('account:getRowboat', null)
.then((account: any) => setAppUrl(account.config?.appUrl ?? null))
.catch(() => {})
}, [])
if (!appUrl) return null
return (
<button
onClick={() => window.open(`${appUrl}?intent=upgrade`)}
className="mt-1 rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-medium text-amber-100 transition-colors hover:bg-amber-500/30"
>
{label}
</button>
)
}
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<string, ChatTabViewState>
viewportAnchors?: Record<string, ChatViewportAnchorState>
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 (
<Message key={item.id} from={item.role}>
<Message key={item.id} from={item.role} data-message-id={item.id}>
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
<ChatMessageAttachments attachments={item.attachments} />
</MessageContent>
@ -271,7 +352,7 @@ export function ChatSidebar({
}
const { message, files } = parseAttachedFiles(item.content)
return (
<Message key={item.id} from={item.role}>
<Message key={item.id} from={item.role} data-message-id={item.id}>
<MessageContent>
{files.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
@ -291,7 +372,7 @@ export function ChatSidebar({
)
}
return (
<Message key={item.id} from={item.role}>
<Message key={item.id} from={item.role} data-message-id={item.id}>
<MessageContent>
<MessageResponse components={streamdownComponents}>{item.content}</MessageResponse>
</MessageContent>
@ -312,6 +393,21 @@ export function ChatSidebar({
/>
)
}
const composioConnectData = getComposioConnectCardData(item)
if (composioConnectData) {
if (composioConnectData.hidden) return null
return (
<ComposioConnectCard
key={item.id}
toolkitSlug={composioConnectData.toolkitSlug}
toolkitDisplayName={composioConnectData.toolkitDisplayName}
status={item.status}
alreadyConnected={composioConnectData.alreadyConnected}
onConnected={onComposioConnected}
/>
)
}
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)}
>
<ToolHeader title={item.name} type={`tool-${item.name}`} state={toToolState(item.status)} />
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
<ToolContent>
<ToolInput input={input} />
{output !== null ? <ToolOutput output={output} errorText={errorText} /> : null}
<ToolTabbedContent input={input} output={output} errorText={errorText} />
</ToolContent>
</Tool>
)
}
if (isErrorMessage(item)) {
const billingError = matchBillingError(item.message)
if (billingError) {
return (
<Message key={item.id} from="assistant" data-message-id={item.id}>
<MessageContent className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3">
<div className="space-y-2">
<p className="text-sm font-medium text-amber-200">{billingError.title}</p>
<p className="text-xs text-amber-300/80">{billingError.subtitle}</p>
<BillingErrorCTA label={billingError.cta} />
</div>
</MessageContent>
</Message>
)
}
return (
<Message key={item.id} from="assistant">
<Message key={item.id} from="assistant" data-message-id={item.id}>
<MessageContent className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-destructive">
<pre className="whitespace-pre-wrap font-mono text-xs">{item.message}</pre>
</MessageContent>
@ -441,9 +550,12 @@ export function ChatSidebar({
)}
data-chat-tab-panel={tab.id}
aria-hidden={!isActive}
>
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
<ScrollPositionPreserver />
>
<Conversation
anchorMessageId={viewportAnchors[tab.id]?.messageId}
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
className="relative flex-1"
>
<ConversationContent className={tabHasConversation ? 'mx-auto w-full max-w-4xl px-3 pb-28' : 'mx-auto w-full max-w-4xl min-h-full items-center justify-center px-3 pb-0'}>
{!tabHasConversation ? (
<ConversationEmptyState className="h-auto">
@ -501,10 +613,11 @@ export function ChatSidebar({
</Message>
)}
</>
)}
</ConversationContent>
</Conversation>
</div>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
</div>
)
})}
</div>
@ -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}
/>
</div>
)

View file

@ -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<string[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
const [providerStatus, setProviderStatus] = useState<Record<string, ProviderStatus>>({})
const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false)
const [googleClientIdDescription, setGoogleClientIdDescription] = useState<string | undefined>(undefined)
// Granola state
const [granolaEnabled, setGranolaEnabled] = useState(false)
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<string, ProviderState> = {}
try {
const result = await window.ipc.invoke('oauth:getState', null)
const config = result.config || {}
const statusMap: Record<string, ProviderStatus> = {}
for (const provider of providers) {
const providerConfig = config[provider]
newStates[provider] = {
isConnected: providerConfig?.connected ?? false,
isLoading: false,
isConnecting: false,
}
if (providerConfig?.error) {
statusMap[provider] = { error: providerConfig.error }
}
}
setProviderStatus(statusMap)
} catch (error) {
console.error('Failed to check connection statuses:', error)
for (const provider of providers) {
newStates[provider] = {
isConnected: false,
isLoading: false,
isConnecting: false,
}
}
setProviderStatus({})
}
setProviderStates(newStates)
}, [providers, refreshGranolaConfig, refreshSlackStatus])
// 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 (
<div
@ -426,13 +81,13 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
size="sm"
onClick={() => {
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
<Button
variant="outline"
size="sm"
onClick={() => handleDisconnect(provider)}
onClick={() => c.handleDisconnect(provider)}
className="h-7 px-2 text-xs"
>
Disconnect
{provider === 'rowboat' ? 'Log Out' : 'Disconnect'}
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={() => handleConnect(provider)}
onClick={() => c.handleConnect(provider)}
disabled={state.isConnecting}
className="h-7 px-2 text-xs"
>
{state.isConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Connect"
provider === 'rowboat' ? 'Log In' : 'Connect'
)}
</Button>
)}
@ -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 (
<>
<GoogleClientIdModal
open={googleClientIdOpen}
open={c.googleClientIdOpen}
onOpenChange={(nextOpen) => {
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}
/>
<Popover open={open} onOpenChange={setOpen}>
{tooltip ? (
@ -506,129 +194,179 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
>
<div className="p-4 border-b">
<h4 className="font-semibold text-sm flex items-center gap-1.5">
Connected accounts
{hasProviderError && (
{isUnconnectedMode ? "Connect Accounts" : "Connected accounts"}
{!isUnconnectedMode && c.hasProviderError && (
<AlertTriangle className="size-3 text-amber-500/80 animate-pulse" />
)}
</h4>
<p className="text-xs text-muted-foreground mt-1">
Connect accounts to sync data
{isUnconnectedMode ? "Add new account connections" : "Connect accounts to sync data"}
</p>
</div>
<div className="p-2">
{providersLoading ? (
{c.providersLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : allConnected ? (
<div className="flex flex-col items-center py-6 px-4 gap-2">
<p className="text-sm text-muted-foreground text-center">All accounts connected</p>
<p className="text-xs text-muted-foreground text-center">
Manage your connections in Settings
</p>
</div>
) : (
<>
{/* 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 (
<>
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Account</span>
</div>
{renderOAuthProvider('rowboat', 'Rowboat', <User className="size-4" />, 'Log in to your Rowboat account')}
<Separator className="my-2" />
</>
)
})()}
{/* Email & Calendar Section */}
{(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && hasUnconnectedEmailCalendar && (
<>
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Email & Calendar</span>
<span className="text-xs font-medium text-muted-foreground">
Email & Calendar
</span>
</div>
{renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')}
{c.useComposioForGoogle ? (
// In unconnected mode, only show if not connected
(!isUnconnectedMode || isGmailUnconnected) ? (
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Mail className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Gmail</span>
{c.gmailLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Sync emails
</span>
)}
</div>
</div>
<div className="shrink-0">
{c.gmailLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : c.gmailConnected ? (
<Button
variant="outline"
size="sm"
onClick={c.handleDisconnectGmail}
className="h-7 px-2 text-xs"
>
Disconnect
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={c.handleConnectGmail}
disabled={c.gmailConnecting}
className="h-7 px-2 text-xs"
>
{c.gmailConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
) : null
) : (
renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
)}
{c.useComposioForGoogleCalendar && (!isUnconnectedMode || isGoogleCalendarUnconnected) && (
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Calendar className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Google Calendar</span>
{c.googleCalendarLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Sync calendar events
</span>
)}
</div>
</div>
<div className="shrink-0">
{c.googleCalendarLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : c.googleCalendarConnected ? (
<Button
variant="outline"
size="sm"
onClick={c.handleDisconnectGoogleCalendar}
className="h-7 px-2 text-xs"
>
Disconnect
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={c.handleConnectGoogleCalendar}
disabled={c.googleCalendarConnecting}
className="h-7 px-2 text-xs"
>
{c.googleCalendarConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
)}
<Separator className="my-2" />
</>
)}
{/* Meeting Notes Section - Granola & Fireflies */}
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Meeting Notes</span>
</div>
{/* Granola */}
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Mic className="size-4" />
{/* Meeting Notes Section */}
{hasUnconnectedMeetingNotes && (
<>
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Meeting Notes</span>
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Granola</span>
<span className="text-xs text-muted-foreground truncate">
Local meeting notes
</span>
</div>
</div>
<div className="shrink-0 flex items-center gap-2">
{granolaLoading && (
<Loader2 className="size-3 animate-spin" />
)}
<Switch
checked={granolaEnabled}
onCheckedChange={handleGranolaToggle}
disabled={granolaLoading}
/>
</div>
</div>
{/* Fireflies */}
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
{/* Fireflies */}
{c.providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
<Separator className="my-2" />
{/* Team Communication Section - Slack */}
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Team Communication</span>
</div>
{/* Slack */}
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{slackLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
)}
</div>
</div>
<div className="shrink-0">
{slackLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : slackConnected ? (
<Button
variant="outline"
size="sm"
onClick={handleDisconnectSlack}
className="h-7 px-2 text-xs"
>
Disconnect
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={handleConnectSlack}
disabled={slackConnecting}
className="h-7 px-2 text-xs"
>
{slackConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
<Separator className="my-2" />
</>
)}
</>
)}
</div>
</PopoverContent>
</Popover>
<ComposioApiKeyModal
open={composioApiKeyOpen}
onOpenChange={setComposioApiKeyOpen}
onSubmit={handleComposioApiKeySubmit}
isSubmitting={slackConnecting}
open={c.composioApiKeyOpen}
onOpenChange={c.setComposioApiKeyOpen}
onSubmit={c.handleComposioApiKeySubmit}
isSubmitting={c.gmailConnecting}
/>
</>
)

View file

@ -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> | 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({
</Button>
</>
)}
{/* Export */}
{onExport && (
<>
<div className="separator" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
title="Export"
>
<DownloadIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onExport('md')}>
<FileTextIcon className="size-4 mr-2" />
Markdown (.md)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onExport('pdf')}>
<FileIcon className="size-4 mr-2" />
PDF (.pdf)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onExport('docx')}>
<FileTypeIcon className="size-4 mr-2" />
Word (.docx)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
)
}

View file

@ -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<string, string | string[]> = {}
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<FieldEntry[]>(() => fieldsFromRaw(raw))
const [editingNewKey, setEditingNewKey] = useState(false)
const newKeyRef = useRef<HTMLInputElement>(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 (
<div className="frontmatter-properties">
<button
className="frontmatter-toggle"
onClick={() => setExpanded(!expanded)}
type="button"
>
<ChevronRight
size={14}
className={`frontmatter-chevron ${expanded ? 'expanded' : ''}`}
/>
<span className="frontmatter-label">
Properties{count > 0 ? ` (${count})` : ''}
</span>
</button>
{expanded && (
<div className="frontmatter-fields">
{fields.map((field, index) => (
<div key={`${field.key}-${index}`} className="frontmatter-row">
<span className="frontmatter-key" title={field.key}>
{field.key}
</span>
<div className="frontmatter-value-area">
{Array.isArray(field.value) ? (
<ArrayField
value={field.value}
editable={editable}
onChange={(v) => updateAndCommit(prev => {
const next = [...prev]
next[index] = { ...next[index], value: v }
return next
})}
/>
) : (
<input
className="frontmatter-input"
value={field.value}
readOnly={!editable}
onChange={(e) => updateLocalValue(index, e.target.value)}
onBlur={() => commitField(index)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
}
}}
/>
)}
</div>
{editable && (
<button
className="frontmatter-remove"
onClick={() => removeField(index)}
type="button"
title="Remove property"
>
<X size={12} />
</button>
)}
</div>
))}
{editable && (
editingNewKey ? (
<div className="frontmatter-row frontmatter-new-row">
<input
ref={newKeyRef}
className="frontmatter-input frontmatter-new-key-input"
placeholder="Property name"
onKeyDown={(e) => {
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)
}
}}
/>
</div>
) : (
<button
className="frontmatter-add"
onClick={() => setEditingNewKey(true)}
type="button"
>
<Plus size={12} />
<span>Add property</span>
</button>
)
)}
</div>
)}
</div>
)
}
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 (
<div className="frontmatter-array">
{value.map((item, i) => (
<span key={i} className="frontmatter-chip">
<span className="frontmatter-chip-text">{item}</span>
{editable && (
<button
className="frontmatter-chip-remove"
onClick={() => removeItem(i)}
type="button"
>
<X size={10} />
</button>
)}
</span>
))}
{editable && (
<input
className="frontmatter-chip-input"
placeholder="Add..."
onKeyDown={(e) => {
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 = ''
}
}}
/>
)}
</div>
)
}

View file

@ -47,19 +47,37 @@ export function GoogleClientIdModal({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Enter Google Client ID</DialogTitle>
<DialogDescription>
{description ?? "Enter the client ID for your Google OAuth app to continue."}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground" htmlFor="google-client-id">
Client ID
</label>
<div className="text-xs text-muted-foreground">
Need help setting this up?{" "}
<DialogContent className="w-[min(28rem,calc(100%-2rem))] max-w-md p-0 gap-0 overflow-hidden rounded-xl">
<div className="p-6 pb-0">
<DialogHeader className="space-y-1.5">
<DialogTitle className="text-lg font-semibold">Google Client ID</DialogTitle>
<DialogDescription className="text-sm">
{description ?? "Enter the client ID for your Google OAuth app to connect."}
</DialogDescription>
</DialogHeader>
</div>
<div className="px-6 py-4 space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block" htmlFor="google-client-id">
Client ID
</label>
<Input
id="google-client-id"
placeholder="xxxxxxxxxxxx-xxxx.apps.googleusercontent.com"
value={clientId}
onChange={(event) => setClientId(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
handleSubmit()
}
}}
className="font-mono text-xs"
autoFocus
/>
</div>
<p className="text-xs text-muted-foreground">
Need help?{" "}
<a
className="text-primary underline underline-offset-4 hover:text-primary/80"
href={GOOGLE_CLIENT_ID_SETUP_GUIDE_URL}
@ -68,31 +86,18 @@ export function GoogleClientIdModal({
>
Read the setup guide
</a>
.
</div>
<Input
id="google-client-id"
placeholder="xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com"
value={clientId}
onChange={(event) => setClientId(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
handleSubmit()
}
}}
autoFocus
/>
</p>
</div>
<div className="mt-4 flex justify-end gap-2">
<div className="flex justify-end gap-2 px-6 py-4 border-t bg-muted/30">
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
<Button size="sm" onClick={handleSubmit} disabled={!isValid || isSubmitting}>
Continue
</Button>
</div>

View file

@ -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<HTMLDivElement>(null)
const positionsRef = useRef<Map<string, NodePosition>>(new Map())
const motionSeedsRef = useRef<Map<string, { phase: number; amplitude: number; speed: number }>>(new Map())
@ -456,22 +456,13 @@ export function GraphView({ nodes, edges, isLoading, error, onSelectNode }: Grap
return (
<div ref={containerRef} className="graph-view relative h-full w-full">
{isLoading ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/70 backdrop-blur-sm">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Building graph</span>
</div>
</div>
) : null}
{error ? (
{error ? (
<div className="absolute inset-0 z-10 flex items-center justify-center text-sm text-destructive">
{error}
</div>
) : null}
{!isLoading && !error && nodes.length === 0 ? (
{!error && nodes.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
No notes found.
</div>

View file

@ -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<unknown>; attrs?: Record<string, unknown> }>
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<string, unknown> }> }>; attrs?: Record<string, unknown> }>
itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }, 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<Record<string, unknown>>; attrs?: Record<string, unknown> },
indent: number
): string[] => {
const lines: string[] = []
const items = (listNode.content || []) as Array<{ content?: Array<Record<string, unknown>>; attrs?: Record<string, unknown> }>
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<string, unknown> }> }>; attrs?: Record<string, unknown> }>
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<unknown>; attrs?: Record<string, unknown> }>
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<string, unknown> }> }>; attrs?: Record<string, unknown> }>
itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }, 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<void>
}
// --- 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<HTMLDivElement>(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 (
<div className="meeting-event-banner" ref={ref}>
<button
className="meeting-event-pill"
onClick={() => setOpen(!open)}
>
<Calendar size={13} />
{formatEventDate(cal.start)}
<ChevronDown size={12} className={`meeting-event-chevron ${open ? 'meeting-event-chevron-open' : ''}`} />
</button>
{open && (
<div className="meeting-event-dropdown">
<div className="meeting-event-dropdown-header">
<span className="meeting-event-dropdown-dot" />
<div className="meeting-event-dropdown-info">
<div className="meeting-event-dropdown-title">{cal.summary || 'Meeting'}</div>
<div className="meeting-event-dropdown-time">{formatEventTime(cal.start, cal.end)}</div>
</div>
</div>
{cal.htmlLink && (
<button
className="meeting-event-dropdown-link"
onClick={() => window.open(cal.htmlLink, '_blank')}
>
<ExternalLink size={14} />
Open in Google Calendar
</button>
)}
</div>
)}
</div>
)
}
// --- Editor ---
interface MarkdownEditorProps {
content: string
onChange: (markdown: string) => void
onPrimaryHeadingCommit?: () => void
preserveUntitledTitleHeading?: boolean
placeholder?: string
wikiLinks?: WikiLinkConfig
onImageUpload?: (file: File) => Promise<string | null>
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<HTMLDivElement>(null)
@ -292,8 +459,20 @@ export function MarkdownEditor({
const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
const selectionHighlightRef = useRef<SelectionHighlightRange>(null)
const [wikiCommandValue, setWikiCommandValue] = useState<string>('')
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<RowboatMentionMatch | null>(null)
const [rowboatBlockEdit, setRowboatBlockEdit] = useState<RowboatBlockEdit | null>(null)
const [rowboatAnchorTop, setRowboatAnchorTop] = useState<{ top: number; left: number; width: number } | null>(null)
const rowboatBlockEditRef = useRef<RowboatBlockEdit | null>(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<string>('')
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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = {
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 = `<!--task-target:${targetId}-->`
const closeTag = `<!--/task-target:${targetId}-->`
// 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 "@<partial>" 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 && (
<FrontmatterProperties
raw={frontmatter}
onRawChange={onFrontmatterChange}
editable={editable}
/>
)}
<MeetingEventBanner frontmatter={frontmatter} />
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
<EditorContent editor={editor} />
{wikiLinks ? (
@ -683,6 +1387,64 @@ export function MarkdownEditor({
</PopoverContent>
</Popover>
) : null}
{/* @ mention autocomplete popover */}
<Popover
open={showAtPopover}
onOpenChange={(open) => {
if (!open) {
setActiveAtMention(null)
setAtAnchorPosition(null)
setAtCommandValue('')
}
}}
>
<PopoverAnchor asChild>
<span
className="wiki-link-anchor"
style={
atAnchorPosition
? { left: atAnchorPosition.left, top: atAnchorPosition.top }
: undefined
}
/>
</PopoverAnchor>
<PopoverContent
className="w-72 p-1"
align="start"
side="bottom"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<Command shouldFilter={false} value={atCommandValue} onValueChange={setAtCommandValue}>
<CommandList>
{filteredAtOptions.map((opt) => (
<CommandItem
key={opt.value}
value={opt.value}
onSelect={() => handleSelectAtMention(opt.value)}
>
<div className="flex flex-col">
<span className="font-medium">{opt.label}</span>
<span className="text-xs text-muted-foreground">{opt.description}</span>
</div>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<RowboatMentionPopover
open={Boolean((activeRowboatMention || rowboatBlockEdit) && rowboatAnchorTop)}
anchor={rowboatAnchorTop}
initialText={rowboatBlockEdit?.existingText ?? ''}
onAdd={handleRowboatAdd}
onRemove={rowboatBlockEdit ? handleRowboatRemove : undefined}
onClose={() => {
setActiveRowboatMention(null)
setRowboatBlockEdit(null)
rowboatBlockEditRef.current = null
setRowboatAnchorTop(null)
}}
/>
</div>
</div>
)

View file

@ -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<Step>(0)
const [onboardingPath, setOnboardingPath] = useState<OnboardingPath>(null)
// LLM setup state
const [llmProvider, setLlmProvider] = useState<LlmProviderFlavor>("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<Array<{ url: string; name: string }>>([])
const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState<Array<{ url: string; name: string }>>([])
const [slackSelectedUrls, setSlackSelectedUrls] = useState<Set<string>>(new Set())
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(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 = () => (
<div className="flex gap-2 justify-center mb-6">
{[0, 1, 2].map((step) => (
<div
key={step}
className={cn(
"w-2 h-2 rounded-full transition-colors",
currentStep >= step ? "bg-primary" : "bg-muted"
)}
/>
))}
</div>
)
// 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 (
<div className="flex gap-2 justify-center mb-6">
{steps.map((_, i) => (
<div
key={i}
className={cn(
"w-2 h-2 rounded-full transition-colors",
currentIndex >= i ? "bg-primary" : "bg-muted"
)}
/>
))}
</div>
)
}
// 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) {
</div>
)
// Render Slack row
/*
const renderSlackRow = () => (
// Render Gmail Composio row
const renderGmailRow = () => (
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-5" />
<Mail className="size-5" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{slackLoading ? (
<span className="text-sm font-medium truncate">Gmail</span>
{gmailLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
Sync emails
</span>
)}
</div>
</div>
<div className="shrink-0">
{slackLoading ? (
{gmailLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : slackConnected ? (
) : gmailConnected ? (
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
<span>Connected</span>
@ -576,10 +771,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
<Button
variant="default"
size="sm"
onClick={handleConnectSlack}
disabled={slackConnecting}
onClick={handleConnectGmail}
disabled={gmailConnecting}
>
{slackConnecting ? (
{gmailConnecting ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Connect"
@ -589,9 +784,249 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
</div>
</div>
)
*/
// Step 0: LLM Setup
// Render Google Calendar Composio row
const renderGoogleCalendarRow = () => (
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
<Calendar className="size-5" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Google Calendar</span>
{googleCalendarLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Sync calendar events
</span>
)}
</div>
</div>
<div className="shrink-0">
{googleCalendarLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : googleCalendarConnected ? (
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
<span>Connected</span>
</div>
) : (
<Button
variant="default"
size="sm"
onClick={handleConnectGoogleCalendar}
disabled={googleCalendarConnecting}
>
{googleCalendarConnecting ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
)
// Render Slack row
const renderSlackRow = () => (
<div className="rounded-md px-3 py-3 hover:bg-accent">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-5" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{slackEnabled && slackWorkspaces.length > 0 ? (
<span className="text-xs text-muted-foreground truncate">
{slackWorkspaces.map(w => w.name).join(', ')}
</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
)}
</div>
</div>
<div className="shrink-0 flex items-center gap-2">
{(slackLoading || slackDiscovering) && (
<Loader2 className="size-3 animate-spin" />
)}
{slackEnabled ? (
<Switch
checked={true}
onCheckedChange={() => handleSlackDisable()}
disabled={slackLoading}
/>
) : (
<Button
variant="default"
size="sm"
onClick={handleSlackEnable}
disabled={slackLoading || slackDiscovering}
>
Enable
</Button>
)}
</div>
</div>
{slackPickerOpen && (
<div className="mt-2 ml-13 space-y-2">
{slackDiscoverError ? (
<p className="text-xs text-muted-foreground">{slackDiscoverError}</p>
) : (
<>
{slackAvailableWorkspaces.map(w => (
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={slackSelectedUrls.has(w.url)}
onChange={(e) => {
setSlackSelectedUrls(prev => {
const next = new Set(prev)
if (e.target.checked) next.add(w.url)
else next.delete(w.url)
return next
})
}}
className="rounded border-border"
/>
<span className="truncate">{w.name}</span>
</label>
))}
<Button
size="sm"
onClick={handleSlackSaveWorkspaces}
disabled={slackSelectedUrls.size === 0 || slackLoading}
>
Save
</Button>
</>
)}
</div>
)}
</div>
)
// Step 0: Sign in to Rowboat (with BYOK option)
const renderSignInStep = () => {
const rowboatState = providerStates['rowboat'] || { isConnected: false, isLoading: false, isConnecting: false }
return (
<div className="flex flex-col items-center text-center">
<div className="flex items-center justify-center gap-3 mb-3">
<span className="text-lg font-medium text-muted-foreground">Your AI coworker, with memory</span>
</div>
<DialogHeader className="space-y-3 mb-8">
<DialogTitle className="text-2xl">Sign in to Rowboat</DialogTitle>
<DialogDescription className="text-base max-w-md mx-auto">
Connect your Rowboat account for instant access to all models through our gateway no API keys needed.
</DialogDescription>
</DialogHeader>
{rowboatState.isConnected ? (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="size-5" />
<span className="text-sm font-medium">Connected to Rowboat</span>
</div>
<Button onClick={() => setCurrentStep(3 as Step)} size="lg" className="w-full max-w-xs">
Continue
</Button>
</div>
) : (
<div className="flex flex-col items-center gap-4 w-full max-w-xs">
<Button
onClick={() => {
setOnboardingPath('rowboat')
startConnect('rowboat')
}}
size="lg"
className="w-full"
disabled={rowboatState.isConnecting}
>
{rowboatState.isConnecting ? (
<><Loader2 className="size-4 animate-spin mr-2" />Waiting for sign in...</>
) : (
"Sign in with Rowboat"
)}
</Button>
{rowboatState.isConnecting && (
<p className="text-xs text-muted-foreground">
Complete sign in in your browser, then return here.
</p>
)}
</div>
)}
<div className="w-full flex justify-end mt-8">
<button
onClick={() => {
setOnboardingPath('byok')
setCurrentStep(1 as Step)
}}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Bring your own key
</button>
</div>
</div>
)
}
// Step 1: BYOK upsell — explain benefits of Rowboat before continuing with BYOK
const renderByokUpsellStep = () => (
<div className="flex flex-col">
<DialogHeader className="text-center mb-6">
<DialogTitle className="text-2xl">Before you continue</DialogTitle>
<DialogDescription className="text-base max-w-md mx-auto">
With a Rowboat account, you get:
</DialogDescription>
</DialogHeader>
<div className="space-y-3 mb-8">
<div className="flex items-start gap-3 rounded-md border px-4 py-3">
<CheckCircle2 className="size-5 text-green-600 mt-0.5 shrink-0" />
<div>
<div className="text-sm font-medium">Instant access to all models</div>
<div className="text-xs text-muted-foreground">GPT, Claude, Gemini, and more no separate API keys needed</div>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border px-4 py-3">
<CheckCircle2 className="size-5 text-green-600 mt-0.5 shrink-0" />
<div>
<div className="text-sm font-medium">Simplified billing</div>
<div className="text-xs text-muted-foreground">One account for everything no juggling multiple provider subscriptions</div>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border px-4 py-3">
<CheckCircle2 className="size-5 text-green-600 mt-0.5 shrink-0" />
<div>
<div className="text-sm font-medium">Automatic updates</div>
<div className="text-xs text-muted-foreground">New models are available as soon as they launch, with no configuration changes</div>
</div>
</div>
</div>
<p className="text-sm text-muted-foreground text-center mb-6">
By continuing, you'll set up your own API keys instead of using Rowboat's managed gateway.
</p>
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={handleBack} className="gap-1">
<ArrowLeft className="size-4" />
Back
</Button>
<Button onClick={handleNext}>
I understand
</Button>
</div>
</div>
)
// 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) {
</div>
)}
<div className="flex flex-col gap-3 mt-4">
<div className="flex items-center justify-between mt-4">
<Button variant="ghost" onClick={handleBack} className="gap-1">
<ArrowLeft className="size-4" />
Back
</Button>
<Button
onClick={handleTestAndSaveLlmConfig}
size="lg"
disabled={!canTest || testState.status === "testing"}
>
{testState.status === "testing" ? (
@ -784,7 +1222,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)
}
// Step 1: Connect Accounts
// Step 3: Connect Accounts
const renderAccountConnectionStep = () => (
<div className="flex flex-col">
<DialogHeader className="text-center mb-6">
@ -801,13 +1239,19 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
</div>
) : (
<>
{/* Email & Calendar Section */}
{providers.includes('google') && (
{/* Email / Email & Calendar Section */}
{(useComposioForGoogle || useComposioForGoogleCalendar || providers.includes('google')) && (
<div className="space-y-2">
<div className="px-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Email & Calendar</span>
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{(useComposioForGoogle || useComposioForGoogleCalendar) ? 'Email & Calendar' : 'Email & Calendar'}
</span>
</div>
{renderOAuthProvider('google', 'Google', <Mail className="size-5" />, 'Sync emails and calendar events')}
{useComposioForGoogle
? renderGmailRow()
: renderOAuthProvider('google', 'Google', <Mail className="size-5" />, 'Sync emails and calendar events')
}
{useComposioForGoogleCalendar && renderGoogleCalendarRow()}
</div>
)}
@ -820,6 +1264,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-5" />, 'AI meeting transcripts')}
</div>
{/* Team Communication Section */}
<div className="space-y-2">
<div className="px-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Team Communication</span>
</div>
{renderSlackRow()}
</div>
</>
)}
</div>
@ -828,16 +1279,22 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
<Button onClick={handleNext} size="lg">
Continue
</Button>
<Button variant="ghost" onClick={handleNext} className="text-muted-foreground">
Skip for now
</Button>
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={handleBack} className="gap-1">
<ArrowLeft className="size-4" />
Back
</Button>
<Button variant="ghost" onClick={handleNext} className="text-muted-foreground">
Skip for now
</Button>
</div>
</div>
</div>
)
// 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 (
<div className="flex flex-col items-center text-center">
@ -860,6 +1317,18 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
<div className="rounded-lg border bg-muted/50 p-4">
<p className="text-sm font-medium mb-2">Connected accounts:</p>
<div className="space-y-1">
{gmailConnected && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="size-4 text-green-600" />
<span>Gmail (Email)</span>
</div>
)}
{googleCalendarConnected && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="size-4 text-green-600" />
<span>Google Calendar</span>
</div>
)}
{connectedProviders.includes('google') && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="size-4 text-green-600" />
@ -878,7 +1347,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
<span>Granola (Local meeting notes)</span>
</div>
)}
{slackConnected && (
{slackEnabled && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="size-4 text-green-600" />
<span>Slack (Team communication)</span>
@ -908,7 +1377,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
open={composioApiKeyOpen}
onOpenChange={setComposioApiKeyOpen}
onSubmit={handleComposioApiKeySubmit}
isSubmitting={slackConnecting}
isSubmitting={gmailConnecting}
/>
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
@ -918,9 +1387,11 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
onEscapeKeyDown={(e) => e.preventDefault()}
>
{renderStepIndicator()}
{currentStep === 0 && renderLlmSetupStep()}
{currentStep === 1 && renderAccountConnectionStep()}
{currentStep === 2 && renderCompletionStep()}
{currentStep === 0 && renderSignInStep()}
{currentStep === 1 && renderByokUpsellStep()}
{currentStep === 2 && renderLlmSetupStep()}
{currentStep === 3 && renderAccountConnectionStep()}
{currentStep === 4 && renderCompletionStep()}
</DialogContent>
</Dialog>
</>

View file

@ -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 <WelcomeStep state={state} />
case 1:
return <LlmSetupStep state={state} />
case 2:
return <ConnectAccountsStep state={state} />
case 3:
return <CompletionStep state={state} />
}
}, [state.currentStep, state])
return (
<>
<GoogleClientIdModal
open={state.googleClientIdOpen}
onOpenChange={state.setGoogleClientIdOpen}
onSubmit={state.handleGoogleClientIdSubmit}
isSubmitting={state.providerStates.google?.isConnecting ?? false}
/>
<ComposioApiKeyModal
open={state.composioApiKeyOpen}
onOpenChange={state.setComposioApiKeyOpen}
onSubmit={state.handleComposioApiKeySubmit}
isSubmitting={state.gmailConnecting}
/>
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
className="w-[90vw] max-w-2xl max-h-[85vh] p-0 overflow-hidden"
showCloseButton={false}
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<div className="flex flex-col h-full max-h-[85vh] overflow-y-auto p-8 md:p-10">
<StepIndicator
currentStep={state.currentStep}
path={state.onboardingPath}
/>
<AnimatePresence mode="wait">
<motion.div
key={state.currentStep}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="flex-1 flex flex-col"
>
{stepContent}
</motion.div>
</AnimatePresence>
</div>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -0,0 +1,107 @@
import { cn } from "@/lib/utils"
interface IconProps {
className?: string
}
export function OpenAIIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z" />
</svg>
)
}
export function AnthropicIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<path d="M17.304 3.541h-3.483l6.15 16.918h3.483zm-10.61 0L.545 20.459H4.15l1.278-3.554h6.539l1.278 3.554h3.604L10.698 3.541zm.49 10.537 2.065-5.728h.054l2.065 5.728z" />
</svg>
)
}
export function GoogleIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
)
}
export function OllamaIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-2-11a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm4 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm-5.07 5.14a.5.5 0 0 1 .71-.07A4.97 4.97 0 0 0 12 15.5c.93 0 1.8-.26 2.53-.7a.5.5 0 1 1 .51.86A5.97 5.97 0 0 1 12 16.5a5.97 5.97 0 0 1-3.14-.88.5.5 0 0 1 .07-.48z" />
</svg>
)
}
export function OpenRouterIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<path d="M4 4h7v7H4zm9 0h7v7h-7zm-9 9h7v7H4zm9 0h7v7h-7z" opacity="0.8" />
<path d="M6 6h3v3H6zm9 0h3v3h-3zM6 15h3v3H6zm9 0h3v3h-3z" />
</svg>
)
}
export function VercelIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<path d="M12 1L24 22H0z" />
</svg>
)
}
export function GmailIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
<path d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z" fill="#EA4335" />
</svg>
)
}
export function SlackIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z" fill="#E01E5A" />
<path d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z" fill="#36C5F0" />
<path d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.271 0a2.527 2.527 0 0 1-2.521 2.521 2.527 2.527 0 0 1-2.521-2.521V2.522A2.527 2.527 0 0 1 15.164 0a2.528 2.528 0 0 1 2.521 2.522v6.312z" fill="#2EB67D" />
<path d="M15.164 18.956a2.528 2.528 0 0 1 2.521 2.522A2.528 2.528 0 0 1 15.164 24a2.527 2.527 0 0 1-2.521-2.522v-2.522h2.521zm0-1.271a2.527 2.527 0 0 1-2.521-2.521 2.527 2.527 0 0 1 2.521-2.521h6.314A2.528 2.528 0 0 1 24 15.164a2.528 2.528 0 0 1-2.522 2.521h-6.314z" fill="#ECB22E" />
</svg>
)
}
export function FirefliesIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<circle cx="12" cy="6" r="2" opacity="0.9" />
<circle cx="7" cy="9" r="1.5" opacity="0.7" />
<circle cx="17" cy="9" r="1.5" opacity="0.7" />
<circle cx="5" cy="13" r="1" opacity="0.5" />
<circle cx="19" cy="13" r="1" opacity="0.5" />
<circle cx="8" cy="16" r="1.5" opacity="0.6" />
<circle cx="16" cy="16" r="1.5" opacity="0.6" />
<circle cx="12" cy="19" r="2" opacity="0.8" />
</svg>
)
}
export function GranolaIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<path d="M12 2a2 2 0 0 1 2 2v1h3a2 2 0 0 1 2 2v2h1a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-1v2a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-2H4a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2h1V7a2 2 0 0 1 2-2h3V4a2 2 0 0 1 2-2zm0 2h-2v1h4V4h-2zm5 3H7v2h10V7zM4 11v6h16v-6H4zm3 10h10v-2H7v2z" opacity="0.85" />
</svg>
)
}
export function GenericApiIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 13h8v2H8v-2zm0 4h5v2H8v-2z" opacity="0.8" />
</svg>
)
}

View file

@ -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 (
<div className="flex items-center gap-2 mb-8 px-4">
{steps.map((s, i) => (
<React.Fragment key={s.step}>
{i > 0 && (
<div
className={cn(
"h-px flex-1 transition-colors duration-500",
i <= currentIndex ? "bg-primary" : "bg-border"
)}
/>
)}
<div className="flex flex-col items-center gap-1.5">
<div
className={cn(
"size-8 rounded-full flex items-center justify-center text-xs font-medium transition-all duration-300",
i < currentIndex && "bg-primary text-primary-foreground",
i === currentIndex && "bg-primary text-primary-foreground ring-4 ring-primary/20",
i > currentIndex && "bg-muted text-muted-foreground"
)}
>
{i < currentIndex ? (
<CheckCircle2 className="size-4" />
) : (
i + 1
)}
</div>
<span
className={cn(
"text-[11px] font-medium transition-colors duration-300",
i <= currentIndex ? "text-foreground" : "text-muted-foreground"
)}
>
{s.label}
</span>
</div>
</React.Fragment>
))}
</div>
)
}

View file

@ -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 (
<div className="flex flex-col items-center justify-center text-center flex-1">
{/* Animated checkmark */}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 20, delay: 0.1 }}
className="relative mb-8"
>
{/* Pulsing ring */}
<motion.div
initial={{ scale: 0.8, opacity: 0.6 }}
animate={{ scale: 1.5, opacity: 0 }}
transition={{ duration: 1.2, repeat: 2, ease: "easeOut" }}
className="absolute inset-0 rounded-full bg-green-500/20"
/>
<div className="relative size-20 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<CheckCircle2 className="size-10 text-green-600 dark:text-green-400" />
</div>
</motion.div>
{/* Title */}
<motion.h2
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
className="text-3xl font-bold tracking-tight mb-3"
>
You're All Set!
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.35 }}
className="text-base text-muted-foreground leading-relaxed max-w-sm mb-8"
>
{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.</>
)}
</motion.p>
{/* Connected accounts summary */}
{hasConnections && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.45 }}
className="w-full max-w-sm rounded-xl border bg-muted/30 p-4 mb-8"
>
<p className="text-sm font-semibold mb-3 text-left">Connected</p>
<div className="space-y-2">
{gmailConnected && (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.5 }}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
<span>Gmail (Email)</span>
</motion.div>
)}
{googleCalendarConnected && (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.52 }}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
<span>Google Calendar</span>
</motion.div>
)}
{connectedProviders.includes('google') && (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.5 }}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
<span>Google (Email & Calendar)</span>
</motion.div>
)}
{connectedProviders.includes('fireflies-ai') && (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.55 }}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
<span>Fireflies (Meeting transcripts)</span>
</motion.div>
)}
</div>
</motion.div>
)}
{/* CTA */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
>
<Button
onClick={handleComplete}
size="lg"
className="w-full max-w-xs h-12 text-base font-medium"
>
Start Using Rowboat
</Button>
</motion.div>
</div>
)
}

View file

@ -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 (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.06 }}
className={cn(
"flex items-center justify-between gap-4 rounded-xl border p-4 transition-colors",
isConnected
? "border-green-200 bg-green-50/50 dark:border-green-800/50 dark:bg-green-900/10"
: "hover:bg-muted/50"
)}
>
<div className="flex items-center gap-3 min-w-0">
<div className={cn("size-10 rounded-lg flex items-center justify-center shrink-0", iconBg)}>
<span className={iconColor}>{icon}</span>
</div>
<div className="min-w-0">
<div className="text-sm font-semibold">{name}</div>
<div className="text-xs text-muted-foreground truncate">{description}</div>
</div>
</div>
<div className="shrink-0">
{rightSlot ?? (
providerState?.isLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : isConnected ? (
<div className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 className="size-4" />
<span className="font-medium">Connected</span>
</div>
) : (
<Button
size="sm"
onClick={onConnect}
disabled={providerState?.isConnecting}
>
{providerState?.isConnecting ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Connect"
)}
</Button>
)
)}
</div>
</motion.div>
)
}
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 (
<div className="flex flex-col flex-1">
{/* Title */}
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
Connect Your Accounts
</h2>
<p className="text-base text-muted-foreground text-center leading-relaxed mb-8">
Rowboat gets smarter the more it knows about your work. Connect your accounts to get started. You can find more tools in Settings.
</p>
{providersLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-6">
{/* Email & Calendar */}
{(useComposioForGoogle || useComposioForGoogleCalendar || providers.includes('google')) && (
<div className="space-y-3">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Email & Calendar
</span>
{useComposioForGoogle ? (
<ProviderCard
name="Gmail"
description="Read emails for context and drafts."
icon={<GmailIcon />}
iconBg="bg-red-500/10"
iconColor="text-red-500"
providerState={{ isConnected: gmailConnected, isLoading: gmailLoading, isConnecting: gmailConnecting }}
onConnect={handleConnectGmail}
index={cardIndex++}
/>
) : (
<ProviderCard
name="Google"
description="Rowboat uses your email and calendar to provide personalized, context-aware assistance"
icon={<GmailIcon />}
iconBg="bg-red-500/10"
iconColor="text-red-500"
providerState={providerStates['google']}
onConnect={() => handleConnect('google')}
index={cardIndex++}
/>
)}
{useComposioForGoogleCalendar && (
<ProviderCard
name="Google Calendar"
description="Read meetings and your schedule."
icon={<Calendar className="size-5" />}
iconBg="bg-blue-500/10"
iconColor="text-blue-500"
providerState={{ isConnected: googleCalendarConnected, isLoading: googleCalendarLoading, isConnecting: googleCalendarConnecting }}
onConnect={handleConnectGoogleCalendar}
index={cardIndex++}
/>
)}
</div>
)}
{/* Meeting Notes */}
<div className="space-y-3">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Meeting Notes
</span>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: cardIndex++ * 0.06 }}
className="flex items-center justify-between gap-4 rounded-xl border border-green-200 bg-green-50/50 dark:border-green-800/50 dark:bg-green-900/10 p-4"
>
<div className="flex items-center gap-3 min-w-0">
<div className="size-10 rounded-lg flex items-center justify-center shrink-0 bg-green-500/10">
<span className="text-green-500"><FileText className="size-5" /></span>
</div>
<div className="min-w-0">
<div className="text-sm font-semibold">Rowboat Meeting Notes</div>
<div className="text-xs text-muted-foreground truncate">Built in. Ready to use.</div>
</div>
</div>
<div className="shrink-0">
<div className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 className="size-4" />
</div>
</div>
</motion.div>
{providers.includes('fireflies-ai') && (
<ProviderCard
name="Fireflies"
description="Import existing notes."
icon={<FirefliesIcon />}
iconBg="bg-amber-500/10"
iconColor="text-amber-500"
providerState={providerStates['fireflies-ai']}
onConnect={() => handleConnect('fireflies-ai')}
index={cardIndex++}
/>
)}
</div>
</div>
)}
{/* Footer */}
<div className="flex flex-col gap-3 mt-8 pt-4 border-t">
<Button onClick={handleNext} size="lg" className="h-12 text-base font-medium">
Continue
</Button>
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={handleBack} className="gap-1">
<ArrowLeft className="size-4" />
Back
</Button>
<Button variant="ghost" onClick={handleNext} className="text-muted-foreground">
Skip for now
</Button>
</div>
</div>
</div>
)
}

View file

@ -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: <OpenAIIcon /> },
{ id: "anthropic", name: "Anthropic", description: "Claude models", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400", icon: <AnthropicIcon /> },
{ id: "google", name: "Gemini", description: "Google AI Studio", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400", icon: <GoogleIcon /> },
{ id: "ollama", name: "Ollama", description: "Local models", color: "bg-purple-500/10 text-purple-600 dark:text-purple-400", icon: <OllamaIcon /> },
]
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: <OpenRouterIcon /> },
{ id: "aigateway", name: "AI Gateway", description: "Vercel AI Gateway", color: "bg-sky-500/10 text-sky-600 dark:text-sky-400", icon: <VercelIcon /> },
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom endpoint", color: "bg-gray-500/10 text-gray-600 dark:text-gray-400", icon: <GenericApiIcon /> },
]
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 (
<motion.button
key={provider.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
onClick={() => {
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"
)}
>
<div className="flex items-center gap-3">
<div className={cn("size-10 rounded-lg flex items-center justify-center shrink-0", provider.color)}>
{provider.icon}
</div>
<div>
<div className="text-sm font-semibold">{provider.name}</div>
<div className="text-xs text-muted-foreground">{provider.description}</div>
</div>
</div>
</motion.button>
)
}
return (
<div className="flex flex-col flex-1">
{/* Title */}
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
Choose your model
</h2>
<p className="text-base text-muted-foreground text-center mb-6">
Select a provider and configure your API key
</p>
{/* Inline Rowboat upsell callout */}
{!upsellDismissed && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, height: 0 }}
className="rounded-xl bg-primary/5 border border-primary/20 p-4 mb-6 flex items-start gap-3"
>
<Lightbulb className="size-5 text-primary shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground">
<span className="font-medium">Tip:</span> Sign in with Rowboat for instant access to leading models. No API keys needed.
</p>
<button
onClick={handleSwitchToRowboat}
className="text-sm text-primary font-medium hover:underline mt-1 inline-block"
>
Sign in instead
</button>
</div>
<button
onClick={() => setUpsellDismissed(true)}
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<X className="size-4" />
</button>
</motion.div>
)}
{/* Provider selection */}
<div className="space-y-3 mb-4">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Provider</span>
<div className="grid gap-2 sm:grid-cols-2">
{primaryProviders.map((p, i) => renderProviderCard(p, i))}
</div>
{(showMoreProviders || isMoreProvider) ? (
<div className="grid gap-2 sm:grid-cols-2 mt-2">
{moreProviders.map((p, i) => renderProviderCard(p, i + 4))}
</div>
) : (
<button
onClick={() => setShowMoreProviders(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
>
More providers...
</button>
)}
</div>
{/* Separator */}
<div className="h-px bg-border my-4" />
{/* Model configuration */}
<div className="space-y-4">
<h3 className="text-sm font-semibold">Model Configuration</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2 min-w-0">
<label className="text-xs font-medium text-muted-foreground">
Assistant Model
</label>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.model}
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
placeholder="Enter model"
/>
) : (
<Select
value={activeConfig.model}
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
>
<SelectTrigger className="w-full truncate">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{modelsForProvider.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{modelsError && (
<div className="text-xs text-destructive">{modelsError}</div>
)}
</div>
<div className="space-y-2 min-w-0">
<label className="text-xs font-medium text-muted-foreground">
Knowledge Graph Model
</label>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.knowledgeGraphModel}
onChange={(e) => updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })}
placeholder={activeConfig.model || "Enter model"}
/>
) : (
<Select
value={activeConfig.knowledgeGraphModel || "__same__"}
onValueChange={(value) => updateProviderConfig(llmProvider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
>
<SelectTrigger className="w-full truncate">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{modelsForProvider.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
{showApiKey && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
API Key {!state.requiresApiKey && "(optional)"}
</label>
<Input
type="password"
value={activeConfig.apiKey}
onChange={(e) => updateProviderConfig(llmProvider, { apiKey: e.target.value })}
placeholder="Paste your API key"
className="font-mono"
/>
</div>
)}
{showBaseURL && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Base URL
</label>
<Input
value={activeConfig.baseURL}
onChange={(e) => 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"
/>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between mt-6 pt-4 border-t">
<Button variant="ghost" onClick={handleBack} className="gap-1">
<ArrowLeft className="size-4" />
Back
</Button>
<div className="flex items-center gap-3">
{testState.status === "success" && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400"
>
<CheckCircle2 className="size-4" />
Connected
</motion.div>
)}
{testState.status === "error" && (
<span className="text-sm text-destructive max-w-[200px] truncate">
{testState.error}
</span>
)}
<Button
onClick={handleTestAndSaveLlmConfig}
disabled={!canTest || testState.status === "testing"}
className="min-w-[140px]"
>
{testState.status === "testing" ? (
<><Loader2 className="size-4 animate-spin mr-2" />Testing...</>
) : (
"Test & Continue"
)}
</Button>
</div>
</div>
</div>
)
}

View file

@ -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 (
<div className="flex flex-col items-center justify-center text-center flex-1">
{/* Logo with ambient glow */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="relative mb-8"
>
<div className="absolute inset-0 size-16 rounded-2xl bg-primary/10 blur-xl scale-[2.5]" />
<img src="/logo-only.png" alt="Rowboat" className="relative size-16" />
</motion.div>
{/* Tagline badge */}
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="inline-flex items-center gap-2 rounded-full border bg-muted/50 px-3.5 py-1.5 text-xs font-medium text-muted-foreground mb-6"
>
<span className="size-1.5 rounded-full bg-green-500 animate-pulse" />
Your AI coworker, with memory
</motion.div>
{/* Main heading */}
<motion.h1
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="text-3xl font-bold tracking-tight mb-3"
>
Welcome to Rowboat
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="text-base text-muted-foreground leading-relaxed max-w-sm mb-10"
>
Rowboat connects to your work, builds a knowledge graph, and uses that context to help you get things done. Private and on your machine.
</motion.p>
{/* Sign in / connected state */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="w-full max-w-xs"
>
{rowboatState.isConnected ? (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
<CheckCircle2 className="size-5" />
<span className="text-sm font-medium">Connected to Rowboat</span>
</div>
<Button
onClick={() => {
state.setOnboardingPath('rowboat')
state.setCurrentStep(2)
}}
size="lg"
className="w-full h-12 text-base font-medium"
>
Continue
</Button>
</div>
) : (
<div className="flex flex-col items-center gap-4">
<Button
onClick={() => {
state.setOnboardingPath('rowboat')
state.startConnect('rowboat')
}}
size="lg"
className="w-full h-12 text-base font-medium"
disabled={rowboatState.isConnecting}
>
{rowboatState.isConnecting ? (
<><Loader2 className="size-5 animate-spin mr-2" />Waiting for sign in...</>
) : (
"Sign in with Rowboat"
)}
</Button>
{rowboatState.isConnecting && (
<p className="text-xs text-muted-foreground animate-pulse">
Complete sign in in your browser, then return here.
</p>
)}
</div>
)}
</motion.div>
{/* BYOK link */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="mt-8"
>
<button
onClick={() => {
state.setOnboardingPath('byok')
state.setCurrentStep(1)
}}
className="text-sm text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4 decoration-muted-foreground/30 hover:decoration-foreground/50"
>
I want to bring my own API key
</button>
</motion.div>
</div>
)
}

View file

@ -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<Step>(0)
const [onboardingPath, setOnboardingPath] = useState<OnboardingPath>(null)
// LLM setup state
const [llmProvider, setLlmProvider] = useState<LlmProviderFlavor>("openai")
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
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<string[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
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<Array<{ url: string; name: string }>>([])
const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState<Array<{ url: string; name: string }>>([])
const [slackSelectedUrls, setSlackSelectedUrls] = useState<Set<string>>(new Set())
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(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<string, LlmModelOption[]> = {}
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<Record<LlmProviderFlavor, string>> = {
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<string, ProviderState> = {}
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<typeof useOnboardingState>

View file

@ -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<void>
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<HTMLTextAreaElement>(null)
const containerRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
className="absolute z-50"
style={{
top: anchor.top,
left: anchor.left,
width: anchor.width,
}}
>
<div className="relative border border-input rounded-md bg-popover shadow-sm">
<div className="flex items-start gap-1.5 px-3 pt-2 pb-8">
<span className="text-sm text-muted-foreground select-none shrink-0 leading-[1.5]">@rowboat</span>
<textarea
ref={textareaRef}
className="flex-1 bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none resize-none leading-[1.5]"
placeholder=""
rows={2}
value={text}
disabled={loading}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey || e.shiftKey)) {
e.preventDefault()
void handleSubmit()
}
if (e.key === 'Escape') {
e.preventDefault()
onClose()
}
}}
/>
</div>
<div className="absolute bottom-1.5 right-1.5 flex items-center gap-1.5">
{onRemove && (
<button
className="inline-flex items-center justify-center rounded px-2.5 py-1 text-xs font-medium text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={onRemove}
disabled={loading}
>
Remove
</button>
)}
<button
className="inline-flex items-center justify-center rounded bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={!text.trim() || loading}
onClick={() => void handleSubmit()}
>
{loading ? <Loader2 className="size-3 animate-spin" /> : 'Add'}
</button>
</div>
</div>
</div>
)
}

View file

@ -1,4 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import posthog from 'posthog-js'
import * as analytics from '@/lib/analytics'
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
import {
CommandDialog,
@ -68,6 +70,8 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }:
.then((res) => {
if (!cancelled) {
setResults(res.results)
analytics.searchExecuted(types)
posthog.people.set_once({ has_used_search: true })
}
})
.catch((err) => {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,258 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { Loader2, User, CreditCard, LogOut, ExternalLink } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Separator } from "@/components/ui/separator"
import { useBilling } from "@/hooks/useBilling"
import { toast } from "sonner"
interface AccountSettingsProps {
dialogOpen: boolean
}
export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
const [connectionLoading, setConnectionLoading] = useState(true)
const [disconnecting, setDisconnecting] = useState(false)
const [connecting, setConnecting] = useState(false)
const [appUrl, setAppUrl] = useState<string | null>(null)
const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected)
const checkConnection = useCallback(async () => {
try {
setConnectionLoading(true)
const result = await window.ipc.invoke('oauth:getState', null)
const connected = result.config?.rowboat?.connected ?? false
setIsRowboatConnected(connected)
} catch {
setIsRowboatConnected(false)
} finally {
setConnectionLoading(false)
}
}, [])
useEffect(() => {
if (dialogOpen) {
checkConnection()
}
}, [dialogOpen, checkConnection])
useEffect(() => {
if (isRowboatConnected) {
window.ipc.invoke('account:getRowboat', null)
.then((account) => setAppUrl(account.config?.appUrl ?? null))
.catch(() => {})
}
}, [isRowboatConnected])
useEffect(() => {
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
if (event.provider === 'rowboat') {
setIsRowboatConnected(event.success)
setConnecting(false)
if (event.success) {
toast.success('Logged in to Rowboat')
}
}
})
return cleanup
}, [])
const handleConnect = useCallback(async () => {
try {
setConnecting(true)
const result = await window.ipc.invoke('oauth:connect', { provider: 'rowboat' })
if (!result.success) {
toast.error(result.error || 'Failed to log in to Rowboat')
setConnecting(false)
}
} catch {
toast.error('Failed to log in to Rowboat')
setConnecting(false)
}
}, [])
const handleDisconnect = useCallback(async () => {
try {
setDisconnecting(true)
const result = await window.ipc.invoke('oauth:disconnect', { provider: 'rowboat' })
if (result.success) {
setIsRowboatConnected(false)
toast.success('Logged out of Rowboat')
} else {
toast.error('Failed to log out of Rowboat')
}
} catch {
toast.error('Failed to log out of Rowboat')
} finally {
setDisconnecting(false)
}
}, [])
if (connectionLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)
}
if (!isRowboatConnected) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-4">
<div className="flex size-14 items-center justify-center rounded-full bg-muted">
<User className="size-7 text-muted-foreground" />
</div>
<div className="text-center space-y-1">
<p className="text-sm font-medium">Not logged in</p>
<p className="text-xs text-muted-foreground">Log in to your Rowboat account to access premium features</p>
</div>
<Button onClick={handleConnect} disabled={connecting}>
{connecting ? <Loader2 className="size-4 animate-spin mr-2" /> : null}
Log in to Rowboat
</Button>
</div>
)
}
return (
<div className="space-y-6">
{/* Profile Section */}
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="flex size-12 items-center justify-center rounded-full bg-primary/10">
<User className="size-6 text-primary" />
</div>
<div className="space-y-0.5">
<p className="text-sm font-medium">
{billing?.userEmail ?? 'Loading...'}
</p>
<p className="text-xs text-muted-foreground">Rowboat Account</p>
</div>
</div>
</div>
<Separator />
{/* Plan Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<CreditCard className="size-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Plan</h4>
</div>
{billingLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Loading plan details...
</div>
) : billing ? (
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium capitalize">
{billing.subscriptionPlan ? `${billing.subscriptionPlan} Plan` : 'No Plan'}
</p>
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
return (
<p className="text-xs text-muted-foreground">
Trial · {days === 0 ? 'expires today' : days === 1 ? '1 day left' : `${days} days left`}
</p>
)
})() : billing.subscriptionStatus ? (
<p className="text-xs text-muted-foreground capitalize">{billing.subscriptionStatus}</p>
) : null}
{!billing.subscriptionPlan && (
<p className="text-xs text-muted-foreground">Subscribe to access AI features</p>
)}
</div>
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
{!billing.subscriptionPlan ? 'Subscribe' : 'Change plan'}
</Button>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">Unable to load plan details</p>
)}
</div>
<Separator />
{/* Payment Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<CreditCard className="size-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Payment</h4>
</div>
<p className="text-xs text-muted-foreground">
Manage invoices, payment methods, and billing details.
</p>
<Button
variant="outline"
size="sm"
disabled={!billing?.subscriptionPlan}
onClick={() => appUrl && window.open(appUrl)}
className="gap-1.5"
>
<ExternalLink className="size-3" />
Manage in Stripe
</Button>
{!billing?.subscriptionPlan && (
<p className="text-[11px] text-muted-foreground">Subscribe to a plan first</p>
)}
</div>
<Separator />
{/* Log Out Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<LogOut className="size-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Log Out</h4>
</div>
<p className="text-xs text-muted-foreground">
Logging out will remove access to synced data and Rowboat-provided models.
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive">
Log Out
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Log out of your Rowboat account?</AlertDialogTitle>
<AlertDialogDescription>
This will remove access to synced data and Rowboat-provided models. You can log back in at any time.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDisconnect}
disabled={disconnecting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{disconnecting ? <Loader2 className="size-4 animate-spin mr-2" /> : null}
Log Out
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)
}

View file

@ -0,0 +1,252 @@
"use client"
import * as React from "react"
import { Loader2, Mic, Mail, Calendar } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
import { useConnectors } from "@/hooks/useConnectors"
interface ConnectedAccountsSettingsProps {
dialogOpen: boolean
}
export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSettingsProps) {
const c = useConnectors(dialogOpen)
const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => {
const state = c.providerStates[provider] || {
isConnected: false,
isLoading: true,
isConnecting: false,
}
const needsReconnect = Boolean(c.providerStatus[provider]?.error)
return (
<div
key={provider}
className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
{icon}
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">{displayName}</span>
{state.isLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : needsReconnect ? (
<span className="text-xs text-amber-600">Needs reconnect</span>
) : state.isConnected ? (
<span className="text-xs text-emerald-600">Connected</span>
) : (
<span className="text-xs text-muted-foreground truncate">{description}</span>
)}
</div>
</div>
<div className="shrink-0">
{state.isLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : needsReconnect ? (
<Button
variant="default"
size="sm"
onClick={() => {
if (provider === 'google') {
c.setGoogleClientIdDescription(
"To keep your Google account connected, please re-enter your client ID. You only need to do this once."
)
c.setGoogleClientIdOpen(true)
return
}
c.startConnect(provider)
}}
className="h-7 px-3 text-xs"
>
Reconnect
</Button>
) : state.isConnected ? (
<Button
variant="outline"
size="sm"
onClick={() => c.handleDisconnect(provider)}
className="h-7 px-3 text-xs"
>
Disconnect
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={() => c.handleConnect(provider)}
disabled={state.isConnecting}
className="h-7 px-3 text-xs"
>
{state.isConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
)
}
if (c.providersLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)
}
return (
<>
<GoogleClientIdModal
open={c.googleClientIdOpen}
onOpenChange={(nextOpen) => {
c.setGoogleClientIdOpen(nextOpen)
if (!nextOpen) {
c.setGoogleClientIdDescription(undefined)
}
}}
onSubmit={c.handleGoogleClientIdSubmit}
isSubmitting={c.providerStates.google?.isConnecting ?? false}
description={c.googleClientIdDescription}
/>
<ComposioApiKeyModal
open={c.composioApiKeyOpen}
onOpenChange={c.setComposioApiKeyOpen}
onSubmit={c.handleComposioApiKeySubmit}
isSubmitting={c.gmailConnecting}
/>
<div className="space-y-1">
{/* Email & Calendar Section */}
{(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && (
<>
<div className="px-4 py-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Email & Calendar
</span>
</div>
{c.useComposioForGoogle ? (
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<Mail className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Gmail</span>
{c.gmailLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : c.gmailConnected ? (
<span className="text-xs text-emerald-600">Connected</span>
) : (
<span className="text-xs text-muted-foreground truncate">Sync emails</span>
)}
</div>
</div>
<div className="shrink-0">
{c.gmailLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : c.gmailConnected ? (
<Button
variant="outline"
size="sm"
onClick={c.handleDisconnectGmail}
className="h-7 px-3 text-xs"
>
Disconnect
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={c.handleConnectGmail}
disabled={c.gmailConnecting}
className="h-7 px-3 text-xs"
>
{c.gmailConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
) : (
c.providers.includes('google') && renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
)}
{c.useComposioForGoogleCalendar && (
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<Calendar className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Google Calendar</span>
{c.googleCalendarLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : c.googleCalendarConnected ? (
<span className="text-xs text-emerald-600">Connected</span>
) : (
<span className="text-xs text-muted-foreground truncate">Sync calendar events</span>
)}
</div>
</div>
<div className="shrink-0">
{c.googleCalendarLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : c.googleCalendarConnected ? (
<Button
variant="outline"
size="sm"
onClick={c.handleDisconnectGoogleCalendar}
className="h-7 px-3 text-xs"
>
Disconnect
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={c.handleConnectGoogleCalendar}
disabled={c.googleCalendarConnecting}
className="h-7 px-3 text-xs"
>
{c.googleCalendarConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
)}
<Separator className="my-3" />
</>
)}
{/* Meeting Notes Section */}
{c.providers.includes('fireflies-ai') && (
<>
<div className="px-4 py-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Meeting Notes
</span>
</div>
{/* Fireflies */}
{renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
</>
)}
</div>
</>
)
}

View file

@ -1,7 +1,7 @@
"use client"
import * as React from "react"
import { useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import {
Bot,
ChevronRight,
@ -10,12 +10,14 @@ import {
Copy,
ExternalLink,
FilePlus,
Folder,
FolderPlus,
AlertTriangle,
HelpCircle,
Mic,
Network,
Pencil,
Table2,
Plug,
LoaderIcon,
Settings,
@ -86,6 +88,7 @@ import { ConnectorsPopover } from "@/components/connectors-popover"
import { HelpPopover } from "@/components/help-popover"
import { SettingsDialog } from "@/components/settings-dialog"
import { toast } from "@/lib/toast"
import { useBilling } from "@/hooks/useBilling"
import { ServiceEvent } from "@x/shared/src/service-events.js"
import z from "zod"
@ -101,6 +104,7 @@ type KnowledgeActions = {
createNote: (parentPath?: string) => void
createFolder: (parentPath?: string) => void
openGraph: () => void
openBases: () => void
expandAll: () => void
collapseAll: () => void
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
@ -399,6 +403,22 @@ export function SidebarContentPanel({
const [connectorsOpen, setConnectorsOpen] = useState(false)
const [openConnectorsAfterClose, setOpenConnectorsAfterClose] = useState(false)
const connectorsButtonRef = useRef<HTMLButtonElement | null>(null)
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
const [loggingIn, setLoggingIn] = useState(false)
const [appUrl, setAppUrl] = useState<string | null>(null)
const { billing } = useBilling(isRowboatConnected)
const handleRowboatLogin = useCallback(async () => {
try {
setLoggingIn(true)
const result = await window.ipc.invoke('oauth:connect', { provider: 'rowboat' })
if (!result.success) {
setLoggingIn(false)
}
} catch {
setLoggingIn(false)
}
}, [])
useEffect(() => {
let mounted = true
@ -408,16 +428,25 @@ export function SidebarContentPanel({
const result = await window.ipc.invoke('oauth:getState', null)
const config = result.config || {}
const hasError = Object.values(config).some((entry) => Boolean(entry?.error))
const connected = config['rowboat']?.connected ?? false
if (mounted) {
setHasOauthError(hasError)
setIsRowboatConnected(connected)
if (!hasError) {
setShowOauthAlert(true)
}
}
if (connected && mounted) {
try {
const account = await window.ipc.invoke('account:getRowboat', null)
if (mounted) setAppUrl(account.config?.appUrl ?? null)
} catch { /* ignore */ }
}
} catch (error) {
console.error('Failed to fetch OAuth state:', error)
if (mounted) {
setHasOauthError(false)
setIsRowboatConnected(false)
setShowOauthAlert(true)
}
}
@ -426,6 +455,7 @@ export function SidebarContentPanel({
refreshOauthError()
const cleanup = window.ipc.on('oauth:didConnect', () => {
refreshOauthError()
setLoggingIn(false)
})
return () => {
@ -481,17 +511,55 @@ export function SidebarContentPanel({
/>
)}
</SidebarContent>
{/* Billing / upgrade CTA or Log in CTA */}
{isRowboatConnected && billing ? (
<div className="px-3 py-2">
<div className="flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2">
<div className="min-w-0">
<span className="text-xs font-medium capitalize text-sidebar-foreground">
{billing.subscriptionPlan ? `${billing.subscriptionPlan} plan` : 'No plan'}
</span>
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => {
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
return (
<p className="text-[10px] text-sidebar-foreground/60">
{days === 0 ? 'Trial expires today' : days === 1 ? '1 day left' : `${days} days left`}
</p>
)
})()}
</div>
<button
onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}
className="shrink-0 rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20"
>
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'starter' ? 'Upgrade' : 'Manage'}
</button>
</div>
</div>
) : null}
{/* Sign in CTA */}
{!isRowboatConnected && (
<div className="px-3 py-2">
<button
onClick={handleRowboatLogin}
disabled={loggingIn}
className="flex w-full items-center justify-center rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2.5 text-xs font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-accent/40 disabled:opacity-50"
>
{loggingIn ? 'Signing in…' : 'Sign in to Rowboat'}
</button>
</div>
)}
{/* Bottom actions */}
<div className="border-t border-sidebar-border px-2 py-2">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ConnectorsPopover open={connectorsOpen} onOpenChange={setConnectorsOpen}>
<ConnectorsPopover open={connectorsOpen} onOpenChange={setConnectorsOpen} mode="unconnected">
<button
ref={connectorsButtonRef}
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
<Plug className="size-4" />
<span>Connected accounts</span>
<span>Connect Accounts</span>
</button>
</ConnectorsPopover>
{hasOauthError && (
@ -606,6 +674,9 @@ function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) =>
const notePathRef = React.useRef<string | null>(null)
const timestampRef = React.useRef<string | null>(null)
const relativePathRef = React.useRef<string | null>(null)
// Keep a ref to always call the latest onNoteCreated (avoids stale closure in recorder.onstop)
const onNoteCreatedRef = React.useRef(onNoteCreated)
React.useEffect(() => { onNoteCreatedRef.current = onNoteCreated }, [onNoteCreated])
React.useEffect(() => {
window.ipc.invoke('workspace:readFile', {
@ -640,11 +711,12 @@ function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) =>
recursive: true,
})
const initialContent = `# Voice Memo
**Type:** voice memo
**Recorded:** ${now.toLocaleString()}
**Path:** ${relativePath}
const initialContent = `---
type: voice memo
recorded: "${now.toISOString()}"
path: ${relativePath}
---
# Voice Memo
## Transcript
@ -657,7 +729,7 @@ function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) =>
})
// Select the note so the user can see it
onNoteCreated?.(notePath)
onNoteCreatedRef.current?.(notePath)
// Start actual recording
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
@ -705,11 +777,12 @@ function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) =>
const currentNotePath = notePathRef.current
const currentRelativePath = relativePathRef.current
if (currentNotePath && currentRelativePath) {
const transcribingContent = `# Voice Memo
**Type:** voice memo
**Recorded:** ${new Date().toLocaleString()}
**Path:** ${currentRelativePath}
const transcribingContent = `---
type: voice memo
recorded: "${new Date().toISOString()}"
path: ${currentRelativePath}
---
# Voice Memo
## Transcript
@ -726,21 +799,23 @@ function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) =>
const transcript = await transcribeWithDeepgram(blob)
if (currentNotePath && currentRelativePath) {
const finalContent = transcript
? `# Voice Memo
**Type:** voice memo
**Recorded:** ${new Date().toLocaleString()}
**Path:** ${currentRelativePath}
? `---
type: voice memo
recorded: "${new Date().toISOString()}"
path: ${currentRelativePath}
---
# Voice Memo
## Transcript
${transcript}
`
: `# Voice Memo
**Type:** voice memo
**Recorded:** ${new Date().toLocaleString()}
**Path:** ${currentRelativePath}
: `---
type: voice memo
recorded: "${new Date().toISOString()}"
path: ${currentRelativePath}
---
# Voice Memo
## Transcript
@ -753,7 +828,7 @@ ${transcript}
})
// Re-select to trigger refresh
onNoteCreated?.(currentNotePath)
onNoteCreatedRef.current?.(currentNotePath)
if (transcript) {
toast('Voice note transcribed', 'success')
@ -855,6 +930,7 @@ function KnowledgeSection({
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
{ icon: Network, label: "Graph View", action: () => actions.openGraph() },
{ icon: Table2, label: "Bases", action: () => actions.openBases() },
]
return (
@ -926,6 +1002,16 @@ function KnowledgeSection({
)
}
function countFiles(node: TreeNode): number {
if (node.kind === 'file') return 1
return (node.children ?? []).reduce((sum, child) => sum + countFiles(child), 0)
}
/** Display name overrides for top-level knowledge folders */
const FOLDER_DISPLAY_NAMES: Record<string, string> = {
Notes: 'My Notes',
}
// Tree component for file browser
function Tree({
item,
@ -945,6 +1031,7 @@ function Tree({
const isSelected = selectedPath === item.path
const [isRenaming, setIsRenaming] = useState(false)
const isSubmittingRef = React.useRef(false)
const displayName = (isDir && FOLDER_DISPLAY_NAMES[item.name]) || item.name
// For files, strip .md extension for editing
const baseName = !isDir && item.name.endsWith('.md')
@ -1073,6 +1160,29 @@ function Tree({
)
}
// Top-level knowledge folders (except Notes) open bases view — render as flat items
const parts = item.path.split('/')
const isBasesFolder = isDir && parts.length === 2 && parts[0] === 'knowledge' && parts[1] !== 'Notes'
if (isBasesFolder) {
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<SidebarMenuItem>
<SidebarMenuButton onClick={() => onSelect(item.path, item.kind)}>
<Folder className="size-4 shrink-0" />
<div className="flex w-full items-center gap-1 min-w-0">
<span className="min-w-0 flex-1 truncate">{displayName}</span>
<span className="text-xs text-sidebar-foreground/50 tabular-nums shrink-0">{countFiles(item)}</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</ContextMenuTrigger>
{contextMenuContent}
</ContextMenu>
)
}
if (!isDir) {
return (
<ContextMenu>
@ -1115,7 +1225,10 @@ function Tree({
<CollapsibleTrigger asChild>
<SidebarMenuButton>
<ChevronRight className="transition-transform size-4" />
<span>{item.name}</span>
<div className="flex w-full items-center gap-1 min-w-0">
<span className="min-w-0 flex-1 truncate">{displayName}</span>
<span className="text-xs text-sidebar-foreground/50 tabular-nums shrink-0">{countFiles(item)}</span>
</div>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
@ -1232,9 +1345,6 @@ function TasksSection({
}}
>
<div className="flex w-full items-center gap-2 min-w-0">
{processingRunIds?.has(run.id) ? (
<span className="size-2 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
) : null}
<span className="min-w-0 flex-1 truncate text-sm">{run.title || '(Untitled chat)'}</span>
{run.createdAt ? (
<span className="shrink-0 text-[10px] text-muted-foreground">

View file

@ -29,7 +29,6 @@ export function TabBar<T>({
activeTabId,
getTabTitle,
getTabId,
isProcessing,
onSwitchTab,
onCloseTab,
layout = 'fill',
@ -47,7 +46,6 @@ export function TabBar<T>({
{tabs.map((tab, index) => {
const tabId = getTabId(tab)
const isActive = tabId === activeTabId
const processing = isProcessing?.(tab) ?? false
const title = getTabTitle(tab)
return (
@ -67,9 +65,6 @@ export function TabBar<T>({
)}
style={layout === 'scroll' ? { flex: '0 0 auto' } : { flex: '1 1 0px' }}
>
{processing && (
<span className="size-1.5 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
)}
<span className="truncate flex-1 text-left">{title}</span>
{(allowSingleTabClose || tabs.length > 1) && (
<span

View file

@ -0,0 +1,378 @@
import { mergeAttributes, Node as TiptapNode } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, Calendar, Video, ChevronDown, Mic } from 'lucide-react'
import { blocks } from '@x/shared'
import { useState, useEffect, useRef } from 'react'
function formatTime(dateStr: string): string {
const d = new Date(dateStr)
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
}
function getDateParts(dateStr: string): { day: number; month: string; weekday: string; isToday: boolean } {
const d = new Date(dateStr)
const now = new Date()
const isToday = d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear()
return {
day: d.getDate(),
month: d.toLocaleDateString([], { month: 'short' }).toUpperCase(),
weekday: d.toLocaleDateString([], { weekday: 'short' }).toUpperCase(),
isToday,
}
}
function getEventDate(event: blocks.CalendarEvent): string {
return event.start?.dateTime || event.start?.date || ''
}
function isAllDay(event: blocks.CalendarEvent): boolean {
return !event.start?.dateTime && !!event.start?.date
}
function getTimeRange(event: blocks.CalendarEvent): string {
if (isAllDay(event)) return 'All day'
const start = event.start?.dateTime
const end = event.end?.dateTime
if (!start) return ''
const startTime = formatTime(start)
if (!end) return startTime
const endTime = formatTime(end)
return `${startTime} \u2013 ${endTime}`
}
/**
* Extract a video conference link from raw Google Calendar event JSON.
* Checks conferenceData.entryPoints (video type), hangoutLink, then falls back
* to conferenceLink if already set.
*/
function extractConferenceLink(raw: Record<string, unknown>): string | undefined {
// Check conferenceData.entryPoints for video entry
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined
if (confData?.entryPoints) {
const video = confData.entryPoints.find(ep => ep.entryPointType === 'video')
if (video?.uri) return video.uri
}
// Check hangoutLink (Google Meet shortcut)
if (typeof raw.hangoutLink === 'string') return raw.hangoutLink
// Fall back to conferenceLink if present
if (typeof raw.conferenceLink === 'string') return raw.conferenceLink
return undefined
}
interface ResolvedEvent {
event: blocks.CalendarEvent
loaded: blocks.CalendarEvent | null
conferenceLink?: string
}
const GCAL_EVENT_COLOR = '#039be5'
const GCAL_TODAY_COLOR = '#1a73e8'
function JoinMeetingSplitButton({ onJoinAndNotes, onNotesOnly }: {
onJoinAndNotes: () => void
onNotesOnly: () => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
const target = e.target
if (ref.current && target instanceof globalThis.Node && !ref.current.contains(target)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
return (
<div className="calendar-block-split-btn" ref={ref}>
<button
className="calendar-block-split-main"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); onJoinAndNotes() }}
>
<Video size={13} />
Join meeting & take notes
</button>
<div className="calendar-block-split-chevron-wrap">
<button
className={`calendar-block-split-chevron ${open ? 'calendar-block-split-chevron-open' : ''}`}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
>
<ChevronDown size={12} />
</button>
{open && (
<div className="calendar-block-split-dropdown">
<button
className="calendar-block-split-option"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setOpen(false); onNotesOnly() }}
>
<Mic size={13} />
Take notes only
</button>
</div>
)}
</div>
</div>
)
}
// Shared global to pass calendar event data to App.tsx when joining a meeting.
// Set before dispatching the custom event, read by the handler in App.tsx.
declare global {
interface Window {
__pendingCalendarEvent?: {
summary?: string
start?: { dateTime?: string; date?: string }
end?: { dateTime?: string; date?: string }
location?: string
htmlLink?: string
conferenceLink?: string
source?: string
}
}
}
function CalendarBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
const raw = node.attrs.data as string
let config: blocks.CalendarBlock | null = null
try {
config = blocks.CalendarBlockSchema.parse(JSON.parse(raw))
} catch {
// fallback below
}
const [resolvedEvents, setResolvedEvents] = useState<ResolvedEvent[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!config) return
const eventsWithSources = config.events.filter(e => e.source)
if (eventsWithSources.length === 0) {
setResolvedEvents(config.events.map(e => ({ event: e, loaded: null })))
return
}
setLoading(true)
const ipc = (window as unknown as { ipc: { invoke: (channel: string, args: Record<string, string>) => Promise<{ data: string }> } }).ipc
Promise.all(
config.events.map(async (event): Promise<ResolvedEvent> => {
if (!event.source) return { event, loaded: null }
try {
const result = await ipc.invoke('workspace:readFile', { path: event.source, encoding: 'utf8' })
const content = typeof result === 'string' ? result : result.data
const rawEvent = JSON.parse(content) as Record<string, unknown>
const parsed = blocks.CalendarEventSchema.parse(rawEvent)
const conferenceLink = extractConferenceLink(rawEvent)
return { event, loaded: parsed, conferenceLink }
} catch {
return { event, loaded: null }
}
})
).then(results => {
setResolvedEvents(results)
setLoading(false)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [raw])
if (!config) {
return (
<NodeViewWrapper className="calendar-block-wrapper" data-type="calendar-block">
<div className="calendar-block-card calendar-block-error">
<Calendar size={16} />
<span>Invalid calendar block</span>
</div>
</NodeViewWrapper>
)
}
const showJoinButton = config.showJoinButton === true
const events = resolvedEvents.map(r => {
const e = r.loaded || r.event
return {
...e,
htmlLink: e.htmlLink || r.event.htmlLink,
conferenceLink: r.conferenceLink || e.conferenceLink || r.event.conferenceLink,
}
})
// Group events by date
const dateGroups: { dateKey: string; dateStr: string; events: (blocks.CalendarEvent & { _idx: number; conferenceLink?: string })[] }[] = []
let globalIdx = 0
for (const event of events) {
const dateStr = getEventDate(event)
const dateKey = dateStr ? new Date(dateStr).toDateString() : 'Unknown'
let group = dateGroups.find(g => g.dateKey === dateKey)
if (!group) {
group = { dateKey, dateStr, events: [] }
dateGroups.push(group)
}
group.events.push({ ...event, _idx: globalIdx++ })
}
const handleEventClick = (event: blocks.CalendarEvent) => {
if (event.htmlLink) {
window.open(event.htmlLink, '_blank')
}
}
const handleJoinMeeting = (event: blocks.CalendarEvent & { conferenceLink?: string }, resolvedIdx: number, joinCall: boolean) => {
if (joinCall) {
const meetingUrl = event.conferenceLink
if (meetingUrl) {
window.open(meetingUrl, '_blank')
}
}
// Find the original source path from config
const originalEvent = config!.events[resolvedIdx]
// Set calendar event data on window so App.tsx handler can read it
window.__pendingCalendarEvent = {
summary: event.summary,
start: event.start,
end: event.end,
location: event.location,
htmlLink: event.htmlLink,
conferenceLink: event.conferenceLink,
source: originalEvent?.source,
}
// Dispatch custom event so App.tsx can start meeting transcription
window.dispatchEvent(new Event('calendar-block:join-meeting'))
}
return (
<NodeViewWrapper className="calendar-block-wrapper" data-type="calendar-block">
<div className="calendar-block-card">
<button
className="calendar-block-delete"
onClick={deleteNode}
aria-label="Delete calendar block"
>
<X size={14} />
</button>
{config.title && <div className="calendar-block-title">{config.title}</div>}
{loading ? (
<div className="calendar-block-loading">Loading events...</div>
) : events.length === 0 ? (
<div className="calendar-block-empty">No events</div>
) : (
<div className="calendar-block-list">
{dateGroups.map((group, groupIdx) => {
const parts = group.dateStr ? getDateParts(group.dateStr) : null
return (
<div key={group.dateKey} className="calendar-block-date-group">
{groupIdx > 0 && <div className="calendar-block-separator" />}
<div className="calendar-block-date-row">
<div className="calendar-block-date-left">
{parts ? (
<>
<span className="calendar-block-weekday" style={parts.isToday ? { color: GCAL_TODAY_COLOR } : undefined}>{parts.weekday}</span>
<span className={`calendar-block-day${parts.isToday ? ' calendar-block-day-today' : ''}`}>{parts.day}</span>
</>
) : (
<span className="calendar-block-day">?</span>
)}
</div>
<div className="calendar-block-events">
{group.events.map(event => (
<div
key={event._idx}
className={`calendar-block-event ${event.htmlLink ? 'calendar-block-event-clickable' : ''}`}
style={{ backgroundColor: GCAL_EVENT_COLOR }}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); handleEventClick(event) }}
>
<div className="calendar-block-event-content">
<div className="calendar-block-event-title">
{event.summary || '(No title)'}
</div>
<div className="calendar-block-event-time">
{getTimeRange(event)}
</div>
{showJoinButton && event.conferenceLink && (
<JoinMeetingSplitButton
onJoinAndNotes={() => handleJoinMeeting(event, event._idx, true)}
onNotesOnly={() => handleJoinMeeting(event, event._idx, false)}
/>
)}
</div>
</div>
))}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</NodeViewWrapper>
)
}
export const CalendarBlockExtension = TiptapNode.create({
name: 'calendarBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
data: {
default: '{}',
},
}
},
parseHTML() {
return [
{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-calendar')) {
return { data: code.textContent || '{}' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'calendar-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(CalendarBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```calendar\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

View file

@ -0,0 +1,173 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, BarChart3 } from 'lucide-react'
import { blocks } from '@x/shared'
import { useState, useEffect } from 'react'
import {
LineChart, Line,
BarChart, Bar,
PieChart, Pie, Cell,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
} from 'recharts'
const CHART_COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#0088fe', '#00c49f']
function ChartBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
const raw = node.attrs.data as string
let config: blocks.ChartBlock | null = null
try {
config = blocks.ChartBlockSchema.parse(JSON.parse(raw))
} catch {
// fallback below
}
const [fileData, setFileData] = useState<Record<string, unknown>[] | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!config?.source) return
setLoading(true)
setError(null)
;(window as unknown as { ipc: { invoke: (channel: string, args: Record<string, string>) => Promise<string> } })
.ipc.invoke('workspace:readFile', { path: config.source, encoding: 'utf-8' })
.then((content: string) => {
const parsed = JSON.parse(content)
if (Array.isArray(parsed)) {
setFileData(parsed)
} else {
setError('Source file must contain a JSON array')
}
})
.catch((err: Error) => {
setError(err.message || 'Failed to load data file')
})
.finally(() => setLoading(false))
}, [config?.source])
if (!config) {
return (
<NodeViewWrapper className="chart-block-wrapper" data-type="chart-block">
<div className="chart-block-card chart-block-error">
<BarChart3 size={16} />
<span>Invalid chart block</span>
</div>
</NodeViewWrapper>
)
}
const data = config.data || fileData
const renderChart = () => {
if (loading) return <div className="chart-block-loading">Loading data...</div>
if (error) return <div className="chart-block-error-msg">{error}</div>
if (!data || data.length === 0) return <div className="chart-block-empty">No data</div>
return (
<ResponsiveContainer width="100%" height={250}>
{config!.chart === 'line' ? (
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={config!.x} />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey={config!.y} stroke="#8884d8" />
</LineChart>
) : config!.chart === 'bar' ? (
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={config!.x} />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey={config!.y} fill="#8884d8" />
</BarChart>
) : (
<PieChart>
<Tooltip />
<Legend />
<Pie data={data} dataKey={config!.y} nameKey={config!.x} cx="50%" cy="50%" outerRadius={80} label>
{data.map((_, index) => (
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
</PieChart>
)}
</ResponsiveContainer>
)
}
return (
<NodeViewWrapper className="chart-block-wrapper" data-type="chart-block">
<div className="chart-block-card">
<button
className="chart-block-delete"
onClick={deleteNode}
aria-label="Delete chart block"
>
<X size={14} />
</button>
{config.title && <div className="chart-block-title">{config.title}</div>}
{renderChart()}
</div>
</NodeViewWrapper>
)
}
export const ChartBlockExtension = Node.create({
name: 'chartBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
data: {
default: '{}',
},
}
},
parseHTML() {
return [
{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-chart')) {
return { data: code.textContent || '{}' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'chart-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(ChartBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```chart\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

View file

@ -0,0 +1,286 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, Mail, ChevronDown, ExternalLink, Copy, Check, MessageSquare } from 'lucide-react'
import { blocks } from '@x/shared'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTheme } from '@/contexts/theme-context'
// --- Helpers ---
function formatEmailDate(dateStr: string): string {
try {
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) +
' ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
} catch {
return dateStr
}
}
/** Extract just the name part from "Name <email>" format */
function senderFirstName(from: string): string {
const name = from.replace(/<.*>/, '').trim()
return name.split(/\s+/)[0] || name
}
declare global {
interface Window {
__pendingEmailDraft?: { prompt: string }
}
}
// --- Email Block ---
function EmailBlockView({ node, deleteNode, updateAttributes }: {
node: { attrs: Record<string, unknown> }
deleteNode: () => void
updateAttributes: (attrs: Record<string, unknown>) => void
}) {
const raw = node.attrs.data as string
let config: blocks.EmailBlock | null = null
try {
config = blocks.EmailBlockSchema.parse(JSON.parse(raw))
} catch {
// fallback below
}
const hasDraft = !!config?.draft_response
const hasPastSummary = !!config?.past_summary
const { resolvedTheme } = useTheme()
// Local draft state for editing
const [draftBody, setDraftBody] = useState(config?.draft_response || '')
const [emailExpanded, setEmailExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const bodyRef = useRef<HTMLTextAreaElement>(null)
// Sync draft from external changes
useEffect(() => {
try {
const parsed = blocks.EmailBlockSchema.parse(JSON.parse(raw))
setDraftBody(parsed.draft_response || '')
} catch { /* ignore */ }
}, [raw])
// Auto-resize textarea
useEffect(() => {
if (bodyRef.current) {
bodyRef.current.style.height = 'auto'
bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px'
}
}, [draftBody])
const commitDraft = useCallback((newBody: string) => {
try {
const current = JSON.parse(raw) as Record<string, unknown>
updateAttributes({ data: JSON.stringify({ ...current, draft_response: newBody }) })
} catch { /* ignore */ }
}, [raw, updateAttributes])
const draftWithAssistant = useCallback(() => {
if (!config) return
let prompt = draftBody
? `Help me refine this draft response to an email`
: `Help me draft a response to this email`
if (config.threadId) {
prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context`
}
prompt += `.\n\n`
prompt += `**From:** ${config.from || 'Unknown'}\n`
prompt += `**Subject:** ${config.subject || 'No subject'}\n`
if (draftBody) {
prompt += `\n**Current draft:**\n${draftBody}\n`
}
window.__pendingEmailDraft = { prompt }
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
}, [config, draftBody])
if (!config) {
return (
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
<div className="email-block-card email-block-error">
<Mail size={16} />
<span>Invalid email block</span>
</div>
</NodeViewWrapper>
)
}
const gmailUrl = config.threadId
? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
: null
// Build summary: use explicit summary, or auto-generate from sender + subject
const summary = config.summary
|| (config.from && config.subject
? `${senderFirstName(config.from)} reached out about ${config.subject}`
: config.subject || 'New email')
return (
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
<div className="email-block-card email-block-card-gmail" onMouseDown={(e) => e.stopPropagation()}>
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block">
<X size={14} />
</button>
{/* Header: Email badge */}
<div className="email-block-badge">
<Mail size={13} />
Email
</div>
{/* Summary */}
<div className="email-block-summary">{summary}</div>
{/* Expandable email details */}
<button
className="email-block-expand-btn"
onClick={(e) => { e.stopPropagation(); setEmailExpanded(!emailExpanded) }}
onMouseDown={(e) => e.stopPropagation()}
>
<ChevronDown size={13} className={`email-block-toggle-chevron ${emailExpanded ? 'email-block-toggle-chevron-open' : ''}`} />
{emailExpanded ? 'Hide email' : 'Show email'}
{config.from && <span className="email-block-expand-meta">· From {senderFirstName(config.from)}</span>}
{config.date && <span className="email-block-expand-meta">· {formatEmailDate(config.date)}</span>}
</button>
{emailExpanded && (
<div className="email-block-email-details">
<div className="email-block-message">
<div className="email-block-message-header">
<div className="email-block-sender-info">
<div className="email-block-sender-row">
<div className="email-block-sender-name">{config.from || 'Unknown'}</div>
{config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>}
</div>
{config.subject && <div className="email-block-subject-line">Subject: {config.subject}</div>}
</div>
</div>
<div className="email-block-message-body">{config.latest_email}</div>
</div>
{hasPastSummary && (
<div className="email-block-context-section">
<div className="email-block-context-label">Earlier conversation</div>
<div className="email-block-context-summary">{config.past_summary}</div>
</div>
)}
</div>
)}
{/* Draft section */}
{hasDraft && (
<div className="email-block-draft-section">
<div className="email-block-draft-label">Draft reply</div>
<textarea
key={resolvedTheme}
ref={bodyRef}
className="email-draft-block-body-input"
value={draftBody}
onChange={(e) => setDraftBody(e.target.value)}
onBlur={() => commitDraft(draftBody)}
placeholder="Write your reply..."
rows={3}
/>
</div>
)}
{/* Action buttons */}
<div className="email-block-actions">
<button
className="email-block-gmail-btn email-block-gmail-btn-primary"
onClick={draftWithAssistant}
>
<MessageSquare size={13} />
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
</button>
{hasDraft && (
<button
className="email-block-gmail-btn email-block-gmail-btn-primary"
onClick={() => {
navigator.clipboard.writeText(draftBody).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}).catch(() => {
// Fallback for Electron contexts where clipboard API may fail
const textarea = document.createElement('textarea')
textarea.value = draftBody
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? 'Copied!' : 'Copy draft'}
</button>
)}
{gmailUrl && (
<button
className="email-block-gmail-btn"
onClick={() => window.open(gmailUrl, '_blank')}
>
<ExternalLink size={13} />
Open in Gmail
</button>
)}
</div>
</div>
</NodeViewWrapper>
)
}
export const EmailBlockExtension = Node.create({
name: 'emailBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
data: { default: '{}' },
}
},
parseHTML() {
return [{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-email') && !cls.includes('language-emailDraft')) {
return { data: code.textContent || '{}' }
}
return false
},
}]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'email-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(EmailBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```email\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {},
},
}
},
})

View file

@ -0,0 +1,143 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, ExternalLink } from 'lucide-react'
import { blocks } from '@x/shared'
function getEmbedUrl(provider: string, url: string): string | null {
if (provider === 'youtube') {
// Handle youtube.com/watch?v=X and youtu.be/X
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/)
if (match) return `https://www.youtube.com/embed/${match[1]}`
}
if (provider === 'figma') {
// Convert www.figma.com/design/:key/... → embed.figma.com/design/:key?embed-host=rowboat
const figmaMatch = url.match(/figma\.com\/(design|board|proto)\/([\w-]+)/)
if (figmaMatch) {
return `https://embed.figma.com/${figmaMatch[1]}/${figmaMatch[2]}?embed-host=rowboat`
}
// Legacy /file/ URLs
const legacyMatch = url.match(/figma\.com\/file\/([\w-]+)/)
if (legacyMatch) {
return `https://embed.figma.com/design/${legacyMatch[1]}?embed-host=rowboat`
}
}
return null
}
function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
const raw = node.attrs.data as string
let config: blocks.EmbedBlock | null = null
try {
config = blocks.EmbedBlockSchema.parse(JSON.parse(raw))
} catch {
// fallback below
}
if (!config) {
return (
<NodeViewWrapper className="embed-block-wrapper" data-type="embed-block">
<div className="embed-block-card embed-block-error">
<ExternalLink size={16} />
<span>Invalid embed block</span>
</div>
</NodeViewWrapper>
)
}
const embedUrl = getEmbedUrl(config.provider, config.url)
return (
<NodeViewWrapper className="embed-block-wrapper" data-type="embed-block">
<div className="embed-block-card">
<button
className="embed-block-delete"
onClick={deleteNode}
aria-label="Delete embed block"
>
<X size={14} />
</button>
{embedUrl ? (
<div className="embed-block-iframe-container">
<iframe
src={embedUrl}
className="embed-block-iframe"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
sandbox="allow-scripts allow-same-origin allow-popups"
/>
</div>
) : (
<a
href={config.url}
target="_blank"
rel="noopener noreferrer"
className="embed-block-link"
>
<ExternalLink size={14} />
{config.url}
</a>
)}
{config.caption && (
<div className="embed-block-caption">{config.caption}</div>
)}
</div>
</NodeViewWrapper>
)
}
export const EmbedBlockExtension = Node.create({
name: 'embedBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
data: {
default: '{}',
},
}
},
parseHTML() {
return [
{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-embed')) {
return { data: code.textContent || '{}' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'embed-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(EmbedBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```embed\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

View file

@ -0,0 +1,104 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, ImageIcon } from 'lucide-react'
import { blocks } from '@x/shared'
function ImageBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
const raw = node.attrs.data as string
let config: blocks.ImageBlock | null = null
try {
config = blocks.ImageBlockSchema.parse(JSON.parse(raw))
} catch {
// fallback below
}
if (!config) {
return (
<NodeViewWrapper className="image-block-wrapper" data-type="image-block">
<div className="image-block-card image-block-error">
<ImageIcon size={16} />
<span>Invalid image block</span>
</div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper className="image-block-wrapper" data-type="image-block">
<div className="image-block-card">
<button
className="image-block-delete"
onClick={deleteNode}
aria-label="Delete image block"
>
<X size={14} />
</button>
<img
src={config.src}
alt={config.alt || ''}
className="image-block-img"
/>
{config.caption && (
<div className="image-block-caption">{config.caption}</div>
)}
</div>
</NodeViewWrapper>
)
}
export const ImageBlockExtension = Node.create({
name: 'imageBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
data: {
default: '{}',
},
}
},
parseHTML() {
return [
{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-image')) {
return { data: code.textContent || '{}' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'image-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(ImageBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```image\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

View file

@ -0,0 +1,124 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, Table2 } from 'lucide-react'
import { blocks } from '@x/shared'
function TableBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
const raw = node.attrs.data as string
let config: blocks.TableBlock | null = null
try {
config = blocks.TableBlockSchema.parse(JSON.parse(raw))
} catch {
// fallback below
}
if (!config) {
return (
<NodeViewWrapper className="table-block-wrapper" data-type="table-block">
<div className="table-block-card table-block-error">
<Table2 size={16} />
<span>Invalid table block</span>
</div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper className="table-block-wrapper" data-type="table-block">
<div className="table-block-card">
<button
className="table-block-delete"
onClick={deleteNode}
aria-label="Delete table block"
>
<X size={14} />
</button>
{config.title && <div className="table-block-title">{config.title}</div>}
<div className="table-block-scroll">
<table className="table-block-table">
<thead>
<tr>
{config.columns.map((col) => (
<th key={col}>{col}</th>
))}
</tr>
</thead>
<tbody>
{config.data.map((row, i) => (
<tr key={i}>
{config!.columns.map((col) => (
<td key={col}>{String(row[col] ?? '')}</td>
))}
</tr>
))}
{config.data.length === 0 && (
<tr>
<td colSpan={config.columns.length} className="table-block-empty">
No data
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</NodeViewWrapper>
)
}
export const TableBlockExtension = Node.create({
name: 'tableBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
data: {
default: '{}',
},
}
},
parseHTML() {
return [
{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-table')) {
return { data: code.textContent || '{}' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'table-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(TableBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```table\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

View file

@ -0,0 +1,122 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { CalendarClock, Loader2, X } from 'lucide-react'
import { inlineTask } from '@x/shared'
function formatDateTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
}
function TaskBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
const raw = node.attrs.data as string
let instruction = ''
let scheduleLabel = ''
let processing = false
let lastRunAt = ''
try {
const parsed = inlineTask.InlineTaskBlockSchema.parse(JSON.parse(raw))
instruction = parsed.instruction
scheduleLabel = parsed['schedule-label'] ?? ''
processing = parsed.processing ?? false
lastRunAt = parsed.lastRunAt ?? ''
} catch {
// Fallback: show raw data
instruction = raw
}
const lastRunLabel = lastRunAt ? formatDateTime(lastRunAt) : ''
return (
<NodeViewWrapper className="task-block-wrapper" data-type="task-block">
<div className="task-block-card">
<button
className="task-block-delete"
onClick={deleteNode}
aria-label="Delete task block"
>
<X size={14} />
</button>
<div className="task-block-content">
<span className="task-block-instruction"><span className="task-block-prefix">@rowboat</span> {instruction}</span>
{processing && (
<span className="task-block-schedule">
<Loader2 size={12} className="animate-spin" />
processing
</span>
)}
{!processing && scheduleLabel && (
<span className="task-block-schedule">
<CalendarClock size={12} />
{scheduleLabel}
{lastRunLabel && <span className="task-block-last-run"> · last ran {lastRunLabel}</span>}
</span>
)}
</div>
</div>
</NodeViewWrapper>
)
}
export const TaskBlockExtension = Node.create({
name: 'taskBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
data: {
default: '{}',
},
}
},
parseHTML() {
return [
{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-task') || cls.includes('language-tell-rowboat')) {
return { data: code.textContent || '{}' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'task-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(TaskBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```task\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

View file

@ -0,0 +1,177 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { ChevronDown, FileText } from 'lucide-react'
import { blocks } from '@x/shared'
import { useState, useMemo } from 'react'
interface TranscriptEntry {
speaker: string
text: string
}
function parseTranscript(raw: string): TranscriptEntry[] {
const entries: TranscriptEntry[] = []
const lines = raw.split('\n')
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
// Match **Speaker Name:** text or **You:** text
const match = trimmed.match(/^\*\*(.+?):\*\*\s*(.*)$/)
if (match) {
entries.push({ speaker: match[1], text: match[2] })
} else if (entries.length > 0) {
// Continuation line — append to last entry
entries[entries.length - 1].text += ' ' + trimmed
}
}
return entries
}
function speakerColor(speaker: string): string {
// Simple hash to pick a consistent color per speaker
let hash = 0
for (let i = 0; i < speaker.length; i++) {
hash = speaker.charCodeAt(i) + ((hash << 5) - hash)
}
const colors = [
'#3b82f6', // blue
'#06b6d4', // cyan
'#6366f1', // indigo
'#8b5cf6', // purple
'#0ea5e9', // sky
'#2563eb', // blue darker
'#7c3aed', // violet
]
return colors[Math.abs(hash) % colors.length]
}
function TranscriptBlockView({ node, getPos, editor }: {
node: { attrs: Record<string, unknown> }
getPos: () => number | undefined
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editor: any
}) {
const raw = node.attrs.data as string
let config: blocks.TranscriptBlock | null = null
try {
config = blocks.TranscriptBlockSchema.parse(JSON.parse(raw))
} catch {
// fallback below
}
// Auto-detect: expand if this is the first real block (live recording),
// collapse if there's other content above (notes have been generated)
const isFirstBlock = useMemo(() => {
try {
const pos = getPos()
if (pos === undefined) return false
const firstChild = editor?.state?.doc?.firstChild
if (!firstChild) return true
// If the transcript block is right after the first node (heading), it's the main content
return pos <= (firstChild.nodeSize ?? 0) + 1
} catch {
return false
}
}, [getPos, editor])
const [expanded, setExpanded] = useState(isFirstBlock)
const entries = useMemo(() => {
if (!config) return []
return parseTranscript(config.transcript)
}, [config])
if (!config) {
return (
<NodeViewWrapper className="transcript-block-wrapper" data-type="transcript-block">
<div className="transcript-block-card transcript-block-error">
<FileText size={16} />
<span>Invalid transcript block</span>
</div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper className="transcript-block-wrapper" data-type="transcript-block">
<div className="transcript-block-card" onMouseDown={(e) => e.stopPropagation()}>
<button
className="transcript-block-toggle"
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded) }}
onMouseDown={(e) => e.stopPropagation()}
>
<ChevronDown size={14} className={`transcript-block-chevron ${expanded ? 'transcript-block-chevron-open' : ''}`} />
<FileText size={14} />
<span>Raw transcript</span>
</button>
{expanded && (
<div className="transcript-block-content">
{entries.length > 0 ? (
entries.map((entry, i) => (
<div key={i} className="transcript-entry">
<span className="transcript-speaker" style={{ color: speakerColor(entry.speaker) }}>
{entry.speaker}
</span>
<span className="transcript-text">{entry.text}</span>
</div>
))
) : (
<div className="transcript-raw">{config.transcript}</div>
)}
</div>
)}
</div>
</NodeViewWrapper>
)
}
export const TranscriptBlockExtension = Node.create({
name: 'transcriptBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
data: { default: '{}' },
}
},
parseHTML() {
return [{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-transcript')) {
return { data: code.textContent || '{}' }
}
return false
},
}]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'transcript-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(TranscriptBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```transcript\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {},
},
}
},
})

View file

@ -0,0 +1,74 @@
import { useEffect } from 'react'
import posthog from 'posthog-js'
/**
* Identifies the user in PostHog when signed into Rowboat,
* and sets user properties for connected OAuth providers.
* Call once at the App level.
*/
export function useAnalyticsIdentity() {
// On mount: check current OAuth state and identify if signed in
useEffect(() => {
async function init() {
try {
const result = await window.ipc.invoke('oauth:getState', null)
const config = result.config || {}
// Identify if Rowboat account is connected
const rowboat = config.rowboat
if (rowboat?.connected && rowboat?.userId) {
posthog.identify(rowboat.userId)
}
// Set provider connection flags
const providers = ['gmail', 'calendar', 'slack', 'rowboat']
const props: Record<string, boolean> = { signed_in: !!rowboat?.connected }
for (const p of providers) {
props[`${p}_connected`] = !!config[p]?.connected
}
posthog.people.set(props)
// Count notes for total_notes property
try {
const entries = await window.ipc.invoke('workspace:readdir', { path: '/' })
let totalNotes = 0
if (entries) {
for (const entry of entries) {
if (entry.kind === 'dir') {
try {
const sub = await window.ipc.invoke('workspace:readdir', { path: `/${entry.name}` })
totalNotes += sub?.length ?? 0
} catch {
// skip inaccessible dirs
}
}
}
}
posthog.people.set({ total_notes: totalNotes })
} catch {
// workspace may not be available
}
} catch {
// oauth state unavailable
}
}
init()
}, [])
// Listen for OAuth connect/disconnect events to update identity
useEffect(() => {
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
if (!event.success) return
// If Rowboat provider connected, identify user
if (event.provider === 'rowboat' && event.userId) {
posthog.identify(event.userId)
posthog.people.set({ signed_in: true })
}
posthog.people.set({ [`${event.provider}_connected`]: true })
})
return cleanup
}, [])
}

View file

@ -0,0 +1,39 @@
import { useState, useEffect, useCallback } from 'react'
interface BillingInfo {
userEmail: string | null
userId: string | null
subscriptionPlan: string | null
subscriptionStatus: string | null
trialExpiresAt: string | null
sanctionedCredits: number
availableCredits: number
}
export function useBilling(isRowboatConnected: boolean) {
const [billing, setBilling] = useState<BillingInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const fetchBilling = useCallback(async () => {
if (!isRowboatConnected) {
setBilling(null)
return
}
try {
setIsLoading(true)
const result = await window.ipc.invoke('billing:getInfo', null)
setBilling(result)
} catch (error) {
console.error('Failed to fetch billing info:', error)
setBilling(null)
} finally {
setIsLoading(false)
}
}, [isRowboatConnected])
useEffect(() => {
fetchBilling()
}, [fetchBilling])
return { billing, isLoading, refresh: fetchBilling }
}

View file

@ -0,0 +1,618 @@
import { useState, useEffect, useCallback } from "react"
import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store"
import { toast } from "sonner"
export interface ProviderState {
isConnected: boolean
isLoading: boolean
isConnecting: boolean
}
export interface ProviderStatus {
error?: string
}
export function useConnectors(active: boolean) {
const [providers, setProviders] = useState<string[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
const [providerStatus, setProviderStatus] = useState<Record<string, ProviderStatus>>({})
const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false)
const [googleClientIdDescription, setGoogleClientIdDescription] = useState<string | undefined>(undefined)
// Granola state
const [granolaEnabled, setGranolaEnabled] = useState(false)
const [granolaLoading, setGranolaLoading] = useState(true)
// Composio API key state
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail')
// Slack state
const [slackEnabled, setSlackEnabled] = useState(false)
const [slackLoading, setSlackLoading] = useState(true)
const [slackWorkspaces, setSlackWorkspaces] = useState<Array<{ url: string; name: string }>>([])
const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState<Array<{ url: string; name: string }>>([])
const [slackSelectedUrls, setSlackSelectedUrls] = useState<Set<string>>(new Set())
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
// Composio/Gmail state
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
const [gmailConnected, setGmailConnected] = useState(false)
const [gmailLoading, setGmailLoading] = useState(true)
const [gmailConnecting, setGmailConnecting] = useState(false)
// Composio/Google Calendar state
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
// Load available providers on mount
useEffect(() => {
async function loadProviders() {
try {
setProvidersLoading(true)
const result = await window.ipc.invoke('oauth:list-providers', null)
setProviders(result.providers || [])
} catch (error) {
console.error('Failed to get available providers:', error)
setProviders([])
} finally {
setProvidersLoading(false)
}
}
loadProviders()
}, [])
// Re-check composio-for-google flags when active
useEffect(() => {
if (!active) return
async function loadComposioForGoogleFlag() {
try {
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
setUseComposioForGoogle(result.enabled)
} catch (error) {
console.error('Failed to check composio-for-google flag:', error)
}
}
async function loadComposioForGoogleCalendarFlag() {
try {
const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
setUseComposioForGoogleCalendar(result.enabled)
} catch (error) {
console.error('Failed to check composio-for-google-calendar flag:', error)
}
}
loadComposioForGoogleFlag()
loadComposioForGoogleCalendarFlag()
}, [active])
// Load Granola config
const refreshGranolaConfig = useCallback(async () => {
try {
setGranolaLoading(true)
const result = await window.ipc.invoke('granola:getConfig', null)
setGranolaEnabled(result.enabled)
} catch (error) {
console.error('Failed to load Granola config:', error)
setGranolaEnabled(false)
} finally {
setGranolaLoading(false)
}
}, [])
const handleGranolaToggle = useCallback(async (enabled: boolean) => {
try {
setGranolaLoading(true)
await window.ipc.invoke('granola:setConfig', { enabled })
setGranolaEnabled(enabled)
toast.success(enabled ? 'Granola sync enabled' : 'Granola sync disabled')
} catch (error) {
console.error('Failed to update Granola config:', error)
toast.error('Failed to update Granola sync settings')
} finally {
setGranolaLoading(false)
}
}, [])
// Slack
const refreshSlackConfig = useCallback(async () => {
try {
setSlackLoading(true)
const result = await window.ipc.invoke('slack:getConfig', null)
setSlackEnabled(result.enabled)
setSlackWorkspaces(result.workspaces || [])
} catch (error) {
console.error('Failed to load Slack config:', error)
setSlackEnabled(false)
setSlackWorkspaces([])
} finally {
setSlackLoading(false)
}
}, [])
const handleSlackEnable = useCallback(async () => {
setSlackDiscovering(true)
setSlackDiscoverError(null)
try {
const result = await window.ipc.invoke('slack:listWorkspaces', null)
if (result.error || result.workspaces.length === 0) {
setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop')
setSlackAvailableWorkspaces([])
setSlackPickerOpen(true)
} else {
setSlackAvailableWorkspaces(result.workspaces)
setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url)))
setSlackPickerOpen(true)
}
} catch (error) {
console.error('Failed to discover Slack workspaces:', error)
setSlackDiscoverError('Failed to discover Slack workspaces')
setSlackPickerOpen(true)
} finally {
setSlackDiscovering(false)
}
}, [])
const handleSlackSaveWorkspaces = useCallback(async () => {
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
try {
setSlackLoading(true)
await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected })
setSlackEnabled(true)
setSlackWorkspaces(selected)
setSlackPickerOpen(false)
toast.success('Slack enabled')
} catch (error) {
console.error('Failed to save Slack config:', error)
toast.error('Failed to save Slack settings')
} finally {
setSlackLoading(false)
}
}, [slackAvailableWorkspaces, slackSelectedUrls])
const handleSlackDisable = useCallback(async () => {
try {
setSlackLoading(true)
await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] })
setSlackEnabled(false)
setSlackWorkspaces([])
setSlackPickerOpen(false)
toast.success('Slack disabled')
} catch (error) {
console.error('Failed to update Slack config:', error)
toast.error('Failed to update Slack settings')
} finally {
setSlackLoading(false)
}
}, [])
// Gmail (Composio)
const refreshGmailStatus = useCallback(async () => {
try {
setGmailLoading(true)
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' })
setGmailConnected(result.isConnected)
} catch (error) {
console.error('Failed to load Gmail status:', error)
setGmailConnected(false)
} finally {
setGmailLoading(false)
}
}, [])
const startGmailConnect = useCallback(async () => {
try {
setGmailConnecting(true)
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' })
if (!result.success) {
toast.error(result.error || 'Failed to connect to Gmail')
setGmailConnecting(false)
}
} catch (error) {
console.error('Failed to connect to Gmail:', error)
toast.error('Failed to connect to Gmail')
setGmailConnecting(false)
}
}, [])
const handleConnectGmail = useCallback(async () => {
const configResult = await window.ipc.invoke('composio:is-configured', null)
if (!configResult.configured) {
setComposioApiKeyTarget('gmail')
setComposioApiKeyOpen(true)
return
}
await startGmailConnect()
}, [startGmailConnect])
const handleDisconnectGmail = useCallback(async () => {
try {
setGmailLoading(true)
const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'gmail' })
if (result.success) {
setGmailConnected(false)
toast.success('Disconnected from Gmail')
} else {
toast.error('Failed to disconnect from Gmail')
}
} catch (error) {
console.error('Failed to disconnect from Gmail:', error)
toast.error('Failed to disconnect from Gmail')
} finally {
setGmailLoading(false)
}
}, [])
// Google Calendar (Composio)
const refreshGoogleCalendarStatus = useCallback(async () => {
try {
setGoogleCalendarLoading(true)
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' })
setGoogleCalendarConnected(result.isConnected)
} catch (error) {
console.error('Failed to load Google Calendar status:', error)
setGoogleCalendarConnected(false)
} finally {
setGoogleCalendarLoading(false)
}
}, [])
const startGoogleCalendarConnect = useCallback(async () => {
try {
setGoogleCalendarConnecting(true)
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'googlecalendar' })
if (!result.success) {
toast.error(result.error || 'Failed to connect to Google Calendar')
setGoogleCalendarConnecting(false)
}
} catch (error) {
console.error('Failed to connect to Google Calendar:', error)
toast.error('Failed to connect to Google Calendar')
setGoogleCalendarConnecting(false)
}
}, [])
const handleConnectGoogleCalendar = useCallback(async () => {
const configResult = await window.ipc.invoke('composio:is-configured', null)
if (!configResult.configured) {
setComposioApiKeyTarget('gmail')
setComposioApiKeyOpen(true)
return
}
await startGoogleCalendarConnect()
}, [startGoogleCalendarConnect])
const handleDisconnectGoogleCalendar = useCallback(async () => {
try {
setGoogleCalendarLoading(true)
const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'googlecalendar' })
if (result.success) {
setGoogleCalendarConnected(false)
toast.success('Disconnected from Google Calendar')
} else {
toast.error('Failed to disconnect from Google Calendar')
}
} catch (error) {
console.error('Failed to disconnect from Google Calendar:', error)
toast.error('Failed to disconnect from Google Calendar')
} finally {
setGoogleCalendarLoading(false)
}
}, [])
// Composio API key
const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
try {
await window.ipc.invoke('composio:set-api-key', { apiKey })
setComposioApiKeyOpen(false)
toast.success('Composio API key saved')
await startGmailConnect()
} catch (error) {
console.error('Failed to save Composio API key:', error)
toast.error('Failed to save API key')
}
}, [startGmailConnect])
// OAuth connect/disconnect
const startConnect = useCallback(async (provider: string, clientId?: string) => {
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isConnecting: true }
}))
try {
const result = await window.ipc.invoke('oauth:connect', { provider, clientId })
if (!result.success) {
toast.error(result.error || (provider === 'rowboat' ? 'Failed to log in to Rowboat' : `Failed to connect to ${provider}`))
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isConnecting: false }
}))
}
} catch (error) {
console.error('Failed to connect:', error)
toast.error(provider === 'rowboat' ? 'Failed to log in to Rowboat' : `Failed to connect to ${provider}`)
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isConnecting: false }
}))
}
}, [])
const handleConnect = useCallback(async (provider: string) => {
if (provider === 'google') {
setGoogleClientIdDescription(undefined)
const existingClientId = getGoogleClientId()
if (!existingClientId) {
setGoogleClientIdOpen(true)
return
}
await startConnect(provider, existingClientId)
return
}
await startConnect(provider)
}, [startConnect])
const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
setGoogleClientId(clientId)
setGoogleClientIdOpen(false)
setGoogleClientIdDescription(undefined)
startConnect('google', clientId)
}, [startConnect])
const handleDisconnect = useCallback(async (provider: string) => {
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isLoading: true }
}))
try {
const result = await window.ipc.invoke('oauth:disconnect', { provider })
if (result.success) {
if (provider === 'google') {
clearGoogleClientId()
}
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
toast.success(provider === 'rowboat' ? 'Logged out of Rowboat' : `Disconnected from ${displayName}`)
setProviderStates(prev => ({
...prev,
[provider]: {
isConnected: false,
isLoading: false,
isConnecting: false,
}
}))
} else {
toast.error(provider === 'rowboat' ? 'Failed to log out of Rowboat' : `Failed to disconnect from ${provider}`)
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isLoading: false }
}))
}
} catch (error) {
console.error('Failed to disconnect:', error)
toast.error(provider === 'rowboat' ? 'Failed to log out of Rowboat' : `Failed to disconnect from ${provider}`)
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isLoading: false }
}))
}
}, [])
// Refresh all statuses
const refreshAllStatuses = useCallback(async () => {
refreshGranolaConfig()
refreshSlackConfig()
if (useComposioForGoogle) {
refreshGmailStatus()
}
if (useComposioForGoogleCalendar) {
refreshGoogleCalendarStatus()
}
if (providers.length === 0) return
const newStates: Record<string, ProviderState> = {}
try {
const result = await window.ipc.invoke('oauth:getState', null)
const config = result.config || {}
const statusMap: Record<string, ProviderStatus> = {}
for (const provider of providers) {
const providerConfig = config[provider]
newStates[provider] = {
isConnected: providerConfig?.connected ?? false,
isLoading: false,
isConnecting: false,
}
if (providerConfig?.error) {
statusMap[provider] = { error: providerConfig.error }
}
}
setProviderStatus(statusMap)
} catch (error) {
console.error('Failed to check connection statuses:', error)
for (const provider of providers) {
newStates[provider] = {
isConnected: false,
isLoading: false,
isConnecting: false,
}
}
setProviderStatus({})
}
setProviderStates(newStates)
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
// Refresh when active or providers change
useEffect(() => {
if (active) {
refreshAllStatuses()
}
}, [active, providers, refreshAllStatuses])
// Listen for OAuth events
useEffect(() => {
const cleanup = window.ipc.on('oauth:didConnect', async (event) => {
const { provider, success } = event
setProviderStates(prev => ({
...prev,
[provider]: {
isConnected: success,
isLoading: false,
isConnecting: false,
}
}))
if (success) {
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
if (provider === 'rowboat') {
toast.success('Logged in to Rowboat')
} else if (provider === 'google' || provider === 'fireflies-ai') {
toast.success(`Connected to ${displayName}`, {
description: 'Syncing your data in the background. This may take a few minutes before changes appear.',
duration: 8000,
})
} else {
toast.success(`Connected to ${displayName}`)
}
if (provider === 'rowboat') {
try {
const [googleResult, calendarResult] = await Promise.all([
window.ipc.invoke('composio:use-composio-for-google', null),
window.ipc.invoke('composio:use-composio-for-google-calendar', null),
])
setUseComposioForGoogle(googleResult.enabled)
setUseComposioForGoogleCalendar(calendarResult.enabled)
} catch (err) {
console.error('Failed to re-check composio flags:', err)
}
}
refreshAllStatuses()
}
})
return cleanup
}, [refreshAllStatuses])
// Listen for Composio events
useEffect(() => {
const cleanup = window.ipc.on('composio:didConnect', (event) => {
const { toolkitSlug, success, error } = event
if (toolkitSlug === 'gmail') {
setGmailConnected(success)
setGmailConnecting(false)
if (success) {
toast.success('Connected to Gmail', {
description: 'Syncing your emails in the background. This may take a few minutes before changes appear.',
duration: 8000,
})
} else {
toast.error(error || 'Failed to connect to Gmail')
}
}
if (toolkitSlug === 'googlecalendar') {
setGoogleCalendarConnected(success)
setGoogleCalendarConnecting(false)
if (success) {
toast.success('Connected to Google Calendar', {
description: 'Syncing your calendar in the background. This may take a few minutes before changes appear.',
duration: 8000,
})
} else {
toast.error(error || 'Failed to connect to Google Calendar')
}
}
})
return cleanup
}, [])
const hasProviderError = Object.values(providerStatus).some(
(status) => Boolean(status?.error)
)
return {
// OAuth providers
providers,
providersLoading,
providerStates,
providerStatus,
hasProviderError,
handleConnect,
handleDisconnect,
startConnect,
// Google client ID modal
googleClientIdOpen,
setGoogleClientIdOpen,
googleClientIdDescription,
setGoogleClientIdDescription,
handleGoogleClientIdSubmit,
// Granola
granolaEnabled,
granolaLoading,
handleGranolaToggle,
// Composio API key modal
composioApiKeyOpen,
setComposioApiKeyOpen,
composioApiKeyTarget,
setComposioApiKeyTarget,
handleComposioApiKeySubmit,
// Slack
slackEnabled,
slackLoading,
slackWorkspaces,
slackAvailableWorkspaces,
slackSelectedUrls,
setSlackSelectedUrls,
slackPickerOpen,
setSlackPickerOpen,
slackDiscovering,
slackDiscoverError,
handleSlackEnable,
handleSlackSaveWorkspaces,
handleSlackDisable,
// Gmail (Composio)
useComposioForGoogle,
gmailConnected,
gmailLoading,
gmailConnecting,
handleConnectGmail,
handleDisconnectGmail,
// Google Calendar (Composio)
useComposioForGoogleCalendar,
googleCalendarConnected,
googleCalendarLoading,
googleCalendarConnecting,
handleConnectGoogleCalendar,
handleDisconnectGoogleCalendar,
// Refresh
refreshAllStatuses,
}
}

View file

@ -0,0 +1,417 @@
import { useCallback, useRef, useState } from 'react';
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
export type MeetingTranscriptionState = 'idle' | 'connecting' | 'recording' | 'stopping';
const DEEPGRAM_PARAMS = new URLSearchParams({
model: 'nova-3',
encoding: 'linear16',
sample_rate: '16000',
channels: '2',
multichannel: 'true',
diarize: 'true',
interim_results: 'true',
smart_format: 'true',
punctuate: 'true',
language: 'en',
});
const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS.toString()}`;
// RMS threshold: system audio above this = "active" (speakers playing)
const SYSTEM_AUDIO_GATE_THRESHOLD = 0.005;
// Auto-stop after 2 minutes of silence (no transcript from Deepgram)
const SILENCE_AUTO_STOP_MS = 2 * 60 * 1000;
// ---------------------------------------------------------------------------
// Headphone detection
// ---------------------------------------------------------------------------
async function detectHeadphones(): Promise<boolean> {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const outputs = devices.filter(d => d.kind === 'audiooutput');
const defaultOutput = outputs.find(d => d.deviceId === 'default');
const label = (defaultOutput?.label ?? '').toLowerCase();
// Heuristic: built-in speakers won't match these patterns
const headphonePatterns = ['headphone', 'airpod', 'earpod', 'earphone', 'earbud', 'bluetooth', 'bt_', 'jabra', 'bose', 'sony wh', 'sony wf'];
return headphonePatterns.some(p => label.includes(p));
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Transcript formatting
// ---------------------------------------------------------------------------
interface TranscriptEntry {
speaker: string;
text: string;
}
export interface CalendarEventMeta {
summary?: string
start?: { dateTime?: string; date?: string }
end?: { dateTime?: string; date?: string }
location?: string
htmlLink?: string
conferenceLink?: string
source?: string
}
function formatTranscript(entries: TranscriptEntry[], date: string, calendarEvent?: CalendarEventMeta): string {
const noteTitle = calendarEvent?.summary || 'Meeting Notes';
const lines = [
'---',
'type: meeting',
'source: rowboat',
`title: ${noteTitle}`,
`date: "${date}"`,
];
if (calendarEvent) {
// Serialize as a JSON string on one line — the frontmatter system
// only supports flat key: value pairs, not nested YAML objects.
const eventObj: Record<string, string> = {}
if (calendarEvent.summary) eventObj.summary = calendarEvent.summary
if (calendarEvent.start?.dateTime) eventObj.start = calendarEvent.start.dateTime
else if (calendarEvent.start?.date) eventObj.start = calendarEvent.start.date
if (calendarEvent.end?.dateTime) eventObj.end = calendarEvent.end.dateTime
else if (calendarEvent.end?.date) eventObj.end = calendarEvent.end.date
if (calendarEvent.location) eventObj.location = calendarEvent.location
if (calendarEvent.htmlLink) eventObj.htmlLink = calendarEvent.htmlLink
if (calendarEvent.conferenceLink) eventObj.conferenceLink = calendarEvent.conferenceLink
if (calendarEvent.source) eventObj.source = calendarEvent.source
lines.push(`calendar_event: '${JSON.stringify(eventObj).replace(/'/g, "''")}'`)
}
lines.push(
'---',
'',
`# ${noteTitle}`,
'',
);
// Build the raw transcript text
const transcriptLines: string[] = [];
for (let i = 0; i < entries.length; i++) {
if (i > 0 && entries[i].speaker !== entries[i - 1].speaker) {
transcriptLines.push('');
}
transcriptLines.push(`**${entries[i].speaker}:** ${entries[i].text}`);
transcriptLines.push('');
}
const transcriptText = transcriptLines.join('\n').trim();
const transcriptData = JSON.stringify({ transcript: transcriptText });
lines.push('```transcript', transcriptData, '```');
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useMeetingTranscription(onAutoStop?: () => void) {
const { refresh: refreshRowboatAccount } = useRowboatAccount();
const [state, setState] = useState<MeetingTranscriptionState>('idle');
const wsRef = useRef<WebSocket | null>(null);
const micStreamRef = useRef<MediaStream | null>(null);
const systemStreamRef = useRef<MediaStream | null>(null);
const processorRef = useRef<ScriptProcessorNode | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const transcriptRef = useRef<TranscriptEntry[]>([]);
const interimRef = useRef<Map<number, { speaker: string; text: string }>>(new Map());
const notePathRef = useRef<string>('');
const writeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onAutoStopRef = useRef(onAutoStop);
onAutoStopRef.current = onAutoStop;
const dateRef = useRef<string>('');
const calendarEventRef = useRef<CalendarEventMeta | undefined>(undefined);
const writeTranscriptToFile = useCallback(async () => {
if (!notePathRef.current) return;
const entries = [...transcriptRef.current];
for (const interim of interimRef.current.values()) {
if (!interim.text) continue;
if (entries.length > 0 && entries[entries.length - 1].speaker === interim.speaker) {
entries[entries.length - 1] = { speaker: interim.speaker, text: entries[entries.length - 1].text + ' ' + interim.text };
} else {
entries.push({ speaker: interim.speaker, text: interim.text });
}
}
if (entries.length === 0) return;
const content = formatTranscript(entries, dateRef.current, calendarEventRef.current);
try {
await window.ipc.invoke('workspace:writeFile', {
path: notePathRef.current,
data: content,
opts: { encoding: 'utf8' },
});
} catch (err) {
console.error('[meeting] Failed to write transcript:', err);
}
}, []);
const scheduleDebouncedWrite = useCallback(() => {
if (writeTimerRef.current) clearTimeout(writeTimerRef.current);
writeTimerRef.current = setTimeout(() => {
void writeTranscriptToFile();
}, 1000);
}, [writeTranscriptToFile]);
const cleanup = useCallback(() => {
if (writeTimerRef.current) {
clearTimeout(writeTimerRef.current);
writeTimerRef.current = null;
}
if (silenceTimerRef.current) {
clearTimeout(silenceTimerRef.current);
silenceTimerRef.current = null;
}
if (processorRef.current) {
processorRef.current.disconnect();
processorRef.current = null;
}
if (audioCtxRef.current) {
audioCtxRef.current.close();
audioCtxRef.current = null;
}
if (micStreamRef.current) {
micStreamRef.current.getTracks().forEach(t => t.stop());
micStreamRef.current = null;
}
if (systemStreamRef.current) {
systemStreamRef.current.getTracks().forEach(t => t.stop());
systemStreamRef.current = null;
}
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
}, []);
const start = useCallback(async (calendarEvent?: CalendarEventMeta): Promise<string | null> => {
if (state !== 'idle') return null;
setState('connecting');
// Run independent setup steps in parallel for faster startup
const [headphoneResult, wsResult, micResult, systemResult] = await Promise.allSettled([
// 1. Detect headphones vs speakers
detectHeadphones(),
// 2. Set up Deepgram WebSocket (account refresh + connect + wait for open)
(async () => {
const account = await refreshRowboatAccount();
let ws: WebSocket;
if (
account?.signedIn &&
account.accessToken &&
account.config?.websocketApiUrl
) {
const listenUrl = buildDeepgramListenUrl(account.config.websocketApiUrl, DEEPGRAM_PARAMS);
console.log('[meeting] Using Rowboat WebSocket');
ws = new WebSocket(listenUrl, ['bearer', account.accessToken]);
} else {
const config = await window.ipc.invoke('voice:getConfig', null);
if (!config?.deepgram) {
throw new Error('No Deepgram config available');
}
console.log('[meeting] Using Deepgram API key');
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', config.deepgram.apiKey]);
}
const ok = await new Promise<boolean>((resolve) => {
ws.onopen = () => resolve(true);
ws.onerror = () => resolve(false);
setTimeout(() => resolve(false), 5000);
});
if (!ok) throw new Error('WebSocket failed to connect');
console.log('[meeting] WebSocket connected');
return ws;
})(),
// 3. Get mic stream
navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
}),
// 4. Get system audio via getDisplayMedia (loopback)
(async () => {
const stream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true });
stream.getVideoTracks().forEach(t => t.stop());
if (stream.getAudioTracks().length === 0) {
stream.getTracks().forEach(t => t.stop());
throw new Error('No audio track from getDisplayMedia');
}
console.log('[meeting] System audio captured');
return stream;
})(),
]);
// Check for failures — clean up any successful resources if something failed
const failed = wsResult.status === 'rejected'
|| micResult.status === 'rejected'
|| systemResult.status === 'rejected';
if (failed) {
if (wsResult.status === 'rejected') console.error('[meeting] WebSocket setup failed:', wsResult.reason);
if (micResult.status === 'rejected') console.error('[meeting] Microphone access denied:', micResult.reason);
if (systemResult.status === 'rejected') console.error('[meeting] System audio access denied:', systemResult.reason);
// Clean up any resources that did succeed
if (wsResult.status === 'fulfilled') { wsResult.value.close(); }
if (micResult.status === 'fulfilled') { micResult.value.getTracks().forEach(t => t.stop()); }
if (systemResult.status === 'fulfilled') { systemResult.value.getTracks().forEach(t => t.stop()); }
cleanup();
setState('idle');
return null;
}
const usingHeadphones = headphoneResult.status === 'fulfilled' ? headphoneResult.value : false;
console.log(`[meeting] Audio output mode: ${usingHeadphones ? 'headphones' : 'speakers'}`);
const ws = wsResult.value;
wsRef.current = ws;
// Set up WS message handler
transcriptRef.current = [];
interimRef.current = new Map();
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (!data.channel?.alternatives?.[0]) return;
const transcript = data.channel.alternatives[0].transcript;
if (!transcript) return;
// Reset silence auto-stop timer on any transcript
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
silenceTimerRef.current = setTimeout(() => {
console.log('[meeting] 2 minutes of silence — auto-stopping');
onAutoStopRef.current?.();
}, SILENCE_AUTO_STOP_MS);
const channelIndex = data.channel_index?.[0] ?? 0;
const isMic = channelIndex === 0;
// Channel 0 = mic = "You", Channel 1 = system audio with diarization
let speaker: string;
if (isMic) {
speaker = 'You';
} else {
// Use Deepgram diarization speaker ID for system audio channel
const words = data.channel.alternatives[0].words;
const speakerId = words?.[0]?.speaker;
speaker = speakerId != null ? `Speaker ${speakerId}` : 'System audio';
}
if (data.is_final) {
interimRef.current.delete(channelIndex);
const entries = transcriptRef.current;
if (entries.length > 0 && entries[entries.length - 1].speaker === speaker) {
entries[entries.length - 1].text += ' ' + transcript;
} else {
entries.push({ speaker, text: transcript });
}
} else {
interimRef.current.set(channelIndex, { speaker, text: transcript });
}
scheduleDebouncedWrite();
};
ws.onclose = () => {
console.log('[meeting] WebSocket closed');
wsRef.current = null;
};
const micStream = micResult.value;
micStreamRef.current = micStream;
const systemStream = systemResult.value;
systemStreamRef.current = systemStream;
// ----- Audio pipeline -----
const audioCtx = new AudioContext({ sampleRate: 16000 });
audioCtxRef.current = audioCtx;
const micSource = audioCtx.createMediaStreamSource(micStream);
const systemSource = audioCtx.createMediaStreamSource(systemStream);
const merger = audioCtx.createChannelMerger(2);
micSource.connect(merger, 0, 0); // mic → channel 0
systemSource.connect(merger, 0, 1); // system audio → channel 1
const processor = audioCtx.createScriptProcessor(4096, 2, 2);
processorRef.current = processor;
processor.onaudioprocess = (e) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
const micRaw = e.inputBuffer.getChannelData(0);
const sysRaw = e.inputBuffer.getChannelData(1);
// Mode 1 (headphones): pass both streams through unmodified
// Mode 2 (speakers): gate/mute mic when system audio is active
let micOut: Float32Array;
if (usingHeadphones) {
micOut = micRaw;
} else {
// Compute system audio RMS to detect activity
let sysSum = 0;
for (let i = 0; i < sysRaw.length; i++) sysSum += sysRaw[i] * sysRaw[i];
const sysRms = Math.sqrt(sysSum / sysRaw.length);
if (sysRms > SYSTEM_AUDIO_GATE_THRESHOLD) {
// System audio is playing — mute mic to prevent bleed
micOut = new Float32Array(micRaw.length); // all zeros
} else {
// System audio is silent — pass mic through
micOut = micRaw;
}
}
// Interleave mic (ch0) + system audio (ch1) into stereo int16 PCM
const int16 = new Int16Array(micOut.length * 2);
for (let i = 0; i < micOut.length; i++) {
const s0 = Math.max(-1, Math.min(1, micOut[i]));
const s1 = Math.max(-1, Math.min(1, sysRaw[i]));
int16[i * 2] = s0 < 0 ? s0 * 0x8000 : s0 * 0x7fff;
int16[i * 2 + 1] = s1 < 0 ? s1 * 0x8000 : s1 * 0x7fff;
}
wsRef.current.send(int16.buffer);
};
merger.connect(processor);
processor.connect(audioCtx.destination);
// Create the note file, organized by date like voice memos
const now = new Date();
const dateStr = now.toISOString();
dateRef.current = dateStr;
const dateFolder = dateStr.split('T')[0]; // YYYY-MM-DD
const timestamp = dateStr.replace(/:/g, '-').replace(/\.\d+Z$/, '');
const filename = calendarEvent?.summary
? calendarEvent.summary.replace(/[\\/*?:"<>|]/g, '').replace(/\s+/g, '_').substring(0, 100).trim()
: `meeting-${timestamp}`;
const notePath = `knowledge/Meetings/rowboat/${dateFolder}/${filename}.md`;
notePathRef.current = notePath;
calendarEventRef.current = calendarEvent;
const initialContent = formatTranscript([], dateStr, calendarEvent);
await window.ipc.invoke('workspace:writeFile', {
path: notePath,
data: initialContent,
opts: { encoding: 'utf8', mkdirp: true },
});
setState('recording');
return notePath;
}, [state, cleanup, scheduleDebouncedWrite, refreshRowboatAccount]);
const stop = useCallback(async () => {
if (state !== 'recording') return;
setState('stopping');
cleanup();
interimRef.current = new Map();
await writeTranscriptToFile();
setState('idle');
}, [state, cleanup, writeTranscriptToFile]);
return { state, start, stop };
}

View file

@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { toast } from '@/lib/toast';
import posthog from 'posthog-js';
import * as analytics from '@/lib/analytics';
/**
* Hook for managing OAuth connection state for a specific provider
@ -40,6 +42,8 @@ export function useOAuth(provider: string) {
setIsLoading(false);
if (event.success) {
analytics.oauthConnected(provider);
posthog.people.set({ [`${provider}_connected`]: true });
toast(`Successfully connected to ${provider}`, 'success');
// Refresh connection status to ensure consistency
checkConnection();
@ -75,6 +79,8 @@ export function useOAuth(provider: string) {
setIsLoading(true);
const result = await window.ipc.invoke('oauth:disconnect', { provider });
if (result.success) {
analytics.oauthDisconnected(provider);
posthog.people.set({ [`${provider}_connected`]: false });
toast(`Disconnected from ${provider}`, 'success');
setIsConnected(false);
} else {

View file

@ -0,0 +1,65 @@
import { z } from 'zod';
import { useCallback, useEffect, useState } from 'react';
import { RowboatApiConfig } from '@x/shared/dist/rowboat-account.js';
interface RowboatAccountState {
signedIn: boolean;
accessToken: string | null;
config: z.infer<typeof RowboatApiConfig> | null;
}
export type RowboatAccountSnapshot = RowboatAccountState;
const DEFAULT_STATE: RowboatAccountState = {
signedIn: false,
accessToken: null,
config: null,
};
export function useRowboatAccount() {
const [state, setState] = useState<RowboatAccountState>(DEFAULT_STATE);
const [isLoading, setIsLoading] = useState<boolean>(true);
const refresh = useCallback(async (): Promise<RowboatAccountSnapshot | null> => {
try {
setIsLoading(true);
const result = await window.ipc.invoke('account:getRowboat', null);
const next: RowboatAccountSnapshot = {
signedIn: result.signedIn,
accessToken: result.accessToken,
config: result.config,
};
setState(next);
return next;
} catch (error) {
console.error('Failed to load Rowboat account state:', error);
setState(DEFAULT_STATE);
return null;
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
useEffect(() => {
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
if (event.provider !== 'rowboat') {
return;
}
refresh();
});
return cleanup;
}, [refresh]);
return {
signedIn: state.signedIn,
accessToken: state.accessToken,
config: state.config,
isLoading,
refresh,
};
}

View file

@ -0,0 +1,48 @@
import { useEffect, useRef, useState } from 'react'
/**
* Smoothly reveals streamed text by buffering incoming chunks and releasing
* them gradually via requestAnimationFrame, producing the fluid typing effect
* seen in apps like Claude and ChatGPT.
*/
export function useSmoothedText(targetText: string): string {
const [displayText, setDisplayText] = useState('')
const targetRef = useRef('')
const displayLenRef = useRef(0)
const rafRef = useRef<number>(0)
targetRef.current = targetText
useEffect(() => {
// Target cleared → immediately clear display
if (!targetText) {
displayLenRef.current = 0
setDisplayText('')
cancelAnimationFrame(rafRef.current)
return
}
const tick = () => {
const target = targetRef.current
if (!target) return
const currentLen = displayLenRef.current
if (currentLen < target.length) {
const remaining = target.length - currentLen
// Adaptive speed: reveal faster when buffer is large, slower when small
const step = Math.max(2, Math.ceil(remaining * 0.18))
displayLenRef.current = Math.min(currentLen + step, target.length)
setDisplayText(target.slice(0, displayLenRef.current))
rafRef.current = requestAnimationFrame(tick)
}
// When caught up, stop. New useEffect call restarts when more text arrives.
}
cancelAnimationFrame(rafRef.current)
rafRef.current = requestAnimationFrame(tick)
return () => cancelAnimationFrame(rafRef.current)
}, [targetText])
return displayText
}

View file

@ -0,0 +1,219 @@
import { useCallback, useRef, useState } from 'react';
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
import posthog from 'posthog-js';
import * as analytics from '@/lib/analytics';
export type VoiceState = 'idle' | 'connecting' | 'listening';
const DEEPGRAM_PARAMS = new URLSearchParams({
model: 'nova-3',
encoding: 'linear16',
sample_rate: '16000',
channels: '1',
interim_results: 'true',
smart_format: 'true',
punctuate: 'true',
language: 'en',
endpointing: '100',
no_delay: 'true',
});
const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS.toString()}`;
// Cache auth details so we don't need IPC round-trips on every mic click
let cachedAuth: { type: 'rowboat'; url: string; token: string } | { type: 'local'; apiKey: string } | null = null;
export function useVoiceMode() {
const { refresh: refreshRowboatAccount } = useRowboatAccount();
const [state, setState] = useState<VoiceState>('idle');
const [interimText, setInterimText] = useState('');
const wsRef = useRef<WebSocket | null>(null);
const mediaStreamRef = useRef<MediaStream | null>(null);
const processorRef = useRef<ScriptProcessorNode | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const transcriptBufferRef = useRef('');
const interimRef = useRef('');
// Buffer audio chunks captured before the WebSocket is ready
const audioBufferRef = useRef<ArrayBuffer[]>([]);
// Refresh cached auth details (called on warmup, not on mic click)
const refreshAuth = useCallback(async () => {
const account = await refreshRowboatAccount();
if (
account?.signedIn &&
account.accessToken &&
account.config?.websocketApiUrl
) {
cachedAuth = { type: 'rowboat', url: account.config.websocketApiUrl, token: account.accessToken };
} else {
const config = await window.ipc.invoke('voice:getConfig', null);
if (config?.deepgram) {
cachedAuth = { type: 'local', apiKey: config.deepgram.apiKey };
}
}
}, [refreshRowboatAccount]);
// Create and connect a Deepgram WebSocket using cached auth.
// Starts the connection and returns immediately (does not wait for open).
const connectWs = useCallback(async () => {
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) return;
// Refresh auth if we don't have it cached yet
if (!cachedAuth) {
await refreshAuth();
}
if (!cachedAuth) return;
let ws: WebSocket;
if (cachedAuth.type === 'rowboat') {
const listenUrl = buildDeepgramListenUrl(cachedAuth.url, DEEPGRAM_PARAMS);
ws = new WebSocket(listenUrl, ['bearer', cachedAuth.token]);
} else {
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', cachedAuth.apiKey]);
}
wsRef.current = ws;
ws.onopen = () => {
console.log('[voice] WebSocket connected');
// Flush any buffered audio captured while we were connecting
const buffered = audioBufferRef.current;
audioBufferRef.current = [];
for (const chunk of buffered) {
ws.send(chunk);
}
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (!data.channel?.alternatives?.[0]) return;
const transcript = data.channel.alternatives[0].transcript;
if (!transcript) return;
if (data.is_final) {
transcriptBufferRef.current += (transcriptBufferRef.current ? ' ' : '') + transcript;
interimRef.current = '';
setInterimText(transcriptBufferRef.current);
} else {
interimRef.current = transcript;
setInterimText(transcriptBufferRef.current + (transcriptBufferRef.current ? ' ' : '') + transcript);
}
};
ws.onerror = () => {
console.error('[voice] WebSocket error');
// Auth may be stale — clear cache so next attempt refreshes
cachedAuth = null;
};
ws.onclose = () => {
console.log('[voice] WebSocket closed');
wsRef.current = null;
};
}, [refreshAuth]);
// Stop audio capture and close WS
const stopAudioCapture = useCallback(() => {
if (processorRef.current) {
processorRef.current.disconnect();
processorRef.current = null;
}
if (audioCtxRef.current) {
audioCtxRef.current.close();
audioCtxRef.current = null;
}
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach(t => t.stop());
mediaStreamRef.current = null;
}
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
audioBufferRef.current = [];
setInterimText('');
transcriptBufferRef.current = '';
interimRef.current = '';
setState('idle');
}, []);
const start = useCallback(async () => {
if (state !== 'idle') return;
transcriptBufferRef.current = '';
interimRef.current = '';
setInterimText('');
audioBufferRef.current = [];
// Show listening immediately — don't wait for WebSocket
setState('listening');
analytics.voiceInputStarted();
posthog.people.set_once({ has_used_voice: true });
// Kick off mic + WebSocket in parallel, don't await WebSocket
const [stream] = await Promise.all([
navigator.mediaDevices.getUserMedia({ audio: true }).catch((err) => {
console.error('Microphone access denied:', err);
return null;
}),
connectWs(),
]);
if (!stream) {
setState('idle');
return;
}
mediaStreamRef.current = stream;
// Start audio capture immediately — buffer if WS isn't open yet
const audioCtx = new AudioContext({ sampleRate: 16000 });
audioCtxRef.current = audioCtx;
const source = audioCtx.createMediaStreamSource(stream);
const processor = audioCtx.createScriptProcessor(2048, 1, 1);
processorRef.current = processor;
processor.onaudioprocess = (e) => {
const float32 = e.inputBuffer.getChannelData(0);
const int16 = new Int16Array(float32.length);
for (let i = 0; i < float32.length; i++) {
const s = Math.max(-1, Math.min(1, float32[i]));
int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
const buffer = int16.buffer;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(buffer);
} else {
// WebSocket still connecting — buffer the audio
audioBufferRef.current.push(buffer);
}
};
source.connect(processor);
processor.connect(audioCtx.destination);
}, [state, connectWs]);
/** Stop recording and return the full transcript (finalized + any current interim) */
const submit = useCallback((): string => {
let text = transcriptBufferRef.current;
if (interimRef.current) {
text += (text ? ' ' : '') + interimRef.current;
}
text = text.trim();
stopAudioCapture();
return text;
}, [stopAudioCapture]);
/** Cancel recording without returning transcript */
const cancel = useCallback(() => {
stopAudioCapture();
}, [stopAudioCapture]);
/** Pre-cache auth details so mic click skips IPC round-trips */
const warmup = useCallback(() => {
refreshAuth().catch(() => {});
}, [refreshAuth]);
return { state, interimText, start, submit, cancel, warmup };
}

View file

@ -0,0 +1,108 @@
import { useCallback, useRef, useState } from 'react';
export type TTSState = 'idle' | 'synthesizing' | 'speaking';
interface SynthesizedAudio {
dataUrl: string;
}
function synthesize(text: string): Promise<SynthesizedAudio> {
return window.ipc.invoke('voice:synthesize', { text }).then(
(result: { audioBase64: string; mimeType: string }) => ({
dataUrl: `data:${result.mimeType};base64,${result.audioBase64}`,
})
);
}
function playAudio(dataUrl: string, audioRef: React.MutableRefObject<HTMLAudioElement | null>): Promise<void> {
return new Promise<void>((resolve, reject) => {
const audio = new Audio(dataUrl);
audioRef.current = audio;
audio.onended = () => {
console.log('[tts] audio ended');
resolve();
};
audio.onerror = (e) => {
console.error('[tts] audio error:', e);
reject(new Error('Audio playback failed'));
};
audio.play().then(() => {
console.log('[tts] audio playing');
}).catch((err) => {
console.error('[tts] play() rejected:', err);
reject(err);
});
});
}
export function useVoiceTTS() {
const [state, setState] = useState<TTSState>('idle');
const audioRef = useRef<HTMLAudioElement | null>(null);
const queueRef = useRef<string[]>([]);
const processingRef = useRef(false);
// Pre-fetched audio ready to play immediately
const prefetchedRef = useRef<Promise<SynthesizedAudio> | null>(null);
const processQueue = useCallback(async () => {
if (processingRef.current) return;
processingRef.current = true;
while (queueRef.current.length > 0) {
const text = queueRef.current.shift()!;
if (!text.trim()) continue;
try {
// Use pre-fetched result if available, otherwise synthesize now
let audioPromise: Promise<SynthesizedAudio>;
if (prefetchedRef.current) {
console.log('[tts] using pre-fetched audio');
audioPromise = prefetchedRef.current;
prefetchedRef.current = null;
} else {
setState('synthesizing');
console.log('[tts] synthesizing:', text.substring(0, 80));
audioPromise = synthesize(text);
}
const audio = await audioPromise;
setState('speaking');
// Kick off pre-fetch for next chunk while this one plays
const nextText = queueRef.current[0];
if (nextText?.trim()) {
console.log('[tts] pre-fetching next:', nextText.substring(0, 80));
prefetchedRef.current = synthesize(nextText);
}
await playAudio(audio.dataUrl, audioRef);
} catch (err) {
console.error('[tts] error:', err);
prefetchedRef.current = null;
}
}
audioRef.current = null;
prefetchedRef.current = null;
processingRef.current = false;
setState('idle');
}, []);
const speak = useCallback((text: string) => {
console.log('[tts] speak() called:', text.substring(0, 80));
queueRef.current.push(text);
processQueue();
}, [processQueue]);
const cancel = useCallback(() => {
queueRef.current = [];
prefetchedRef.current = null;
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
processingRef.current = false;
setState('idle');
}, []);
return { state, speak, cancel };
}

View file

@ -0,0 +1,37 @@
import posthog from 'posthog-js'
export function chatSessionCreated(runId: string) {
posthog.capture('chat_session_created', { run_id: runId })
}
export function chatMessageSent(props: {
voiceInput?: boolean
voiceOutput?: string
searchEnabled?: boolean
}) {
posthog.capture('chat_message_sent', {
voice_input: props.voiceInput ?? false,
voice_output: props.voiceOutput ?? false,
search_enabled: props.searchEnabled ?? false,
})
}
export function oauthConnected(provider: string) {
posthog.capture('oauth_connected', { provider })
}
export function oauthDisconnected(provider: string) {
posthog.capture('oauth_disconnected', { provider })
}
export function voiceInputStarted() {
posthog.capture('voice_input_started')
}
export function searchExecuted(types: string[]) {
posthog.capture('search_executed', { types })
}
export function noteExported(format: string) {
posthog.capture('note_exported', { format })
}

View file

@ -1,6 +1,7 @@
import type { ToolUIPart } from 'ai'
import z from 'zod'
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
export interface MessageAttachment {
path: string
@ -46,6 +47,11 @@ export type ChatTabViewState = {
permissionResponses: Map<string, PermissionResponse>
}
export type ChatViewportAnchorState = {
messageId: string | null
requestKey: number
}
export const createEmptyChatTabViewState = (): ChatTabViewState => ({
runId: null,
conversation: [],
@ -115,41 +121,116 @@ export type WebSearchCardData = {
export const getWebSearchCardData = (tool: ToolCall): WebSearchCardData | null => {
if (tool.name === 'web-search') {
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
return {
query: (input?.query as string) || '',
results: (result?.results as WebSearchCardResult[]) || [],
}
}
if (tool.name === 'research-search') {
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
const rawResults = (result?.results as Array<{
title: string
url: string
description?: string
highlights?: string[]
text?: string
}>) || []
const mapped = rawResults.map((entry) => ({
title: entry.title,
url: entry.url,
description: entry.highlights?.[0] || (entry.text ? entry.text.slice(0, 200) : ''),
description: entry.description || entry.highlights?.[0] || (entry.text ? entry.text.slice(0, 200) : ''),
}))
const category = input?.category as string | undefined
return {
query: (input?.query as string) || '',
results: mapped,
title: category
? `${category.charAt(0).toUpperCase() + category.slice(1)} search`
: 'Researched the web',
title: (!category || category === 'general')
? 'Web search'
: `${category.charAt(0).toUpperCase() + category.slice(1)} search`,
}
}
return null
}
// App navigation action card data
export type AppActionCardData = {
action: string
label: string
details?: Record<string, unknown>
}
const summarizeFilterUpdates = (updates: Record<string, unknown>): string => {
const filters = updates.filters as Record<string, unknown> | undefined
const parts: string[] = []
if (filters) {
if (filters.clear) parts.push('Cleared filters')
const set = filters.set as Array<{ category: string; value: string }> | undefined
if (set?.length) parts.push(`Set ${set.length} filter${set.length !== 1 ? 's' : ''}: ${set.map(f => `${f.category}=${f.value}`).join(', ')}`)
const add = filters.add as Array<{ category: string; value: string }> | undefined
if (add?.length) parts.push(`Added ${add.length} filter${add.length !== 1 ? 's' : ''}`)
const remove = filters.remove as Array<{ category: string; value: string }> | undefined
if (remove?.length) parts.push(`Removed ${remove.length} filter${remove.length !== 1 ? 's' : ''}`)
}
if (updates.sort) {
const sort = updates.sort as { field: string; dir: string }
parts.push(`Sorted by ${sort.field} ${sort.dir}`)
}
if (updates.search !== undefined) {
parts.push(updates.search ? `Searching "${updates.search}"` : 'Cleared search')
}
const columns = updates.columns as Record<string, unknown> | undefined
if (columns) {
const set = columns.set as string[] | undefined
if (set) parts.push(`Set ${set.length} column${set.length !== 1 ? 's' : ''}`)
const add = columns.add as string[] | undefined
if (add?.length) parts.push(`Added ${add.length} column${add.length !== 1 ? 's' : ''}`)
const remove = columns.remove as string[] | undefined
if (remove?.length) parts.push(`Removed ${remove.length} column${remove.length !== 1 ? 's' : ''}`)
}
return parts.length > 0 ? parts.join(', ') : 'Updated view'
}
export const getAppActionCardData = (tool: ToolCall): AppActionCardData | null => {
if (tool.name !== 'app-navigation') return null
const result = tool.result as Record<string, unknown> | undefined
// While pending/running, derive label from input
if (!result || !result.success) {
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
if (!input) return null
const action = input.action as string
switch (action) {
case 'open-note': return { action, label: `Opening ${(input.path as string || '').split('/').pop()?.replace(/\.md$/, '') || 'note'}...` }
case 'open-view': return { action, label: `Opening ${input.view} view...` }
case 'update-base-view': return { action, label: 'Updating view...' }
case 'create-base': return { action, label: `Creating "${input.name}"...` }
case 'get-base-state': return null // renders as normal tool block
default: return null
}
}
switch (result.action) {
case 'open-note': {
const filePath = result.path as string || ''
const name = filePath.split('/').pop()?.replace(/\.md$/, '') || 'note'
return { action: 'open-note', label: `Opened ${name}` }
}
case 'open-view':
return { action: 'open-view', label: `Opened ${result.view} view` }
case 'update-base-view':
return {
action: 'update-base-view',
label: summarizeFilterUpdates(result.updates as Record<string, unknown> || {}),
details: result.updates as Record<string, unknown>,
}
case 'create-base':
return { action: 'create-base', label: `Created base "${result.name}"` }
default:
return null // get-base-state renders as normal tool block
}
}
// Parse attached files from message content and return clean message + file paths.
export const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
const attachedFilesRegex = /<attached-files>\s*([\s\S]*?)\s*<\/attached-files>/
@ -178,6 +259,142 @@ export const parseAttachedFiles = (content: string): { message: string; files: s
return { message: cleanMessage.trim(), files }
}
// Composio connect card data
export type ComposioConnectCardData = {
toolkitSlug: string
toolkitDisplayName: string
alreadyConnected: boolean
/** When true, the connect card should not be rendered (toolkit was already connected). */
hidden: boolean
}
export const getComposioConnectCardData = (tool: ToolCall): ComposioConnectCardData | null => {
if (tool.name !== 'composio-connect-toolkit') return null
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
const toolkitSlug = (input?.toolkitSlug as string) || ''
const alreadyConnected = result?.alreadyConnected === true
return {
toolkitSlug,
toolkitDisplayName: COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug,
alreadyConnected,
// Don't render a connect card if the toolkit was already connected —
// the original card from the first connect call already shows the "Connected" state.
hidden: alreadyConnected,
}
}
// Human-friendly display names for builtin tools
const TOOL_DISPLAY_NAMES: Record<string, string> = {
'workspace-readFile': 'Reading file',
'workspace-writeFile': 'Writing file',
'workspace-edit': 'Editing file',
'workspace-readdir': 'Reading directory',
'workspace-exists': 'Checking path',
'workspace-stat': 'Getting file info',
'workspace-glob': 'Finding files',
'workspace-grep': 'Searching files',
'workspace-mkdir': 'Creating directory',
'workspace-rename': 'Renaming',
'workspace-copy': 'Copying file',
'workspace-remove': 'Removing',
'workspace-getRoot': 'Getting workspace root',
'loadSkill': 'Loading skill',
'parseFile': 'Parsing file',
'LLMParse': 'Extracting content',
'analyzeAgent': 'Analyzing agent',
'executeCommand': 'Running command',
'addMcpServer': 'Adding MCP server',
'listMcpServers': 'Listing MCP servers',
'listMcpTools': 'Listing MCP tools',
'executeMcpTool': 'Running MCP tool',
'web-search': 'Searching the web',
'save-to-memory': 'Saving to memory',
'app-navigation': 'Navigating app',
'composio-list-toolkits': 'Listing integrations',
'composio-search-tools': 'Searching tools',
'composio-execute-tool': 'Running tool',
'composio-connect-toolkit': 'Connecting service',
}
/**
* Get a human-friendly display name for a tool call.
* For Composio tools, returns a contextual label (e.g., "Found 3 tools for 'send email' in Gmail").
* For builtin tools, returns a static friendly name (e.g., "Reading file").
* Falls back to the raw tool name if no mapping exists.
*/
export const getToolDisplayName = (tool: ToolCall): string => {
const composioData = getComposioActionCardData(tool)
if (composioData) return composioData.label
return TOOL_DISPLAY_NAMES[tool.name] || tool.name
}
// Composio action card data (for search, execute, list tools)
export type ComposioActionCardData = {
actionType: 'search' | 'execute' | 'list'
label: string
}
export const getComposioActionCardData = (tool: ToolCall): ComposioActionCardData | null => {
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
if (tool.name === 'composio-search-tools') {
const query = (input?.query as string) || 'tools'
const toolkitSlug = input?.toolkitSlug as string | undefined
const toolkit = toolkitSlug ? COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug : null
const count = (result?.resultCount as number) ?? null
let label = `Searching for "${query}"`
if (toolkit) label += ` in ${toolkit}`
if (count !== null && tool.status === 'completed') {
label = count > 0 ? `Found ${count} tool${count !== 1 ? 's' : ''} for "${query}"` : `No tools found for "${query}"`
if (toolkit) label += ` in ${toolkit}`
}
return { actionType: 'search', label }
}
if (tool.name === 'composio-execute-tool') {
const toolSlug = (input?.toolSlug as string) || ''
const toolkitSlug = (input?.toolkitSlug as string) || ''
const toolkit = COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug
const successful = result?.successful as boolean | undefined
// Make the tool slug human-readable: GITHUB_ISSUES_LIST_FOR_REPO → "Issues list for repo"
const readableName = toolSlug
.replace(/^[A-Z]+_/, '') // Remove toolkit prefix
.toLowerCase()
.replace(/_/g, ' ')
.replace(/^\w/, c => c.toUpperCase())
let label = `Running ${readableName}`
if (toolkit) label += ` on ${toolkit}`
if (tool.status === 'completed') {
label = successful === false ? `Failed: ${readableName}` : `${readableName}`
if (toolkit) label += ` on ${toolkit}`
}
return { actionType: 'execute', label }
}
if (tool.name === 'composio-list-toolkits') {
const count = (result?.totalCount as number) ?? null
const connected = (result?.connectedCount as number) ?? null
let label = 'Listing available integrations'
if (count !== null && tool.status === 'completed') {
label = `${count} integrations available`
if (connected !== null && connected > 0) label += `, ${connected} connected`
}
return { actionType: 'list', label }
}
return null
}
export const inferRunTitleFromMessage = (content: string): string | undefined => {
const { message } = parseAttachedFiles(content)
const normalized = message.replace(/\s+/g, ' ').trim()

View file

@ -0,0 +1,10 @@
/**
* Merge Deepgram query params onto a Rowboat WebSocket base URL from account config.
*/
export function buildDeepgramListenUrl(baseWsUrl: string, params: URLSearchParams): string {
const url = new URL("/deepgram/v1/listen", baseWsUrl);
for (const [key, value] of params) {
url.searchParams.set(key, value);
}
return url.toString();
}

View file

@ -0,0 +1,367 @@
/**
* Utilities for splitting, joining, and extracting tags from YAML frontmatter
* in knowledge notes and email files.
*/
/** Split content into raw frontmatter block and body text. */
export function splitFrontmatter(content: string): { raw: string | null; body: string } {
if (!content.startsWith('---')) {
return { raw: null, body: content }
}
const endIndex = content.indexOf('\n---', 3)
if (endIndex === -1) {
return { raw: null, body: content }
}
// raw includes both delimiters and the trailing newline after closing ---
const closingEnd = endIndex + 4 // '\n---' is 4 chars
const raw = content.slice(0, closingEnd)
// body starts after the closing --- and its trailing newline
let body = content.slice(closingEnd)
if (body.startsWith('\n')) {
body = body.slice(1)
}
return { raw, body }
}
/** Re-prepend raw frontmatter before body when saving. */
export function joinFrontmatter(raw: string | null, body: string): string {
if (!raw) return body
return raw + '\n' + body
}
/** Structured frontmatter fields extracted from categorized YAML. */
export type FrontmatterFields = {
relationship: string | null
relationship_sub: string[]
topic: string[]
email_type: string[]
action: string[]
status: string | null
source: string[]
}
/**
* Extract structured tag categories from raw frontmatter YAML.
*
* Handles both the new categorized format (top-level keys) and the legacy
* flat `tags:` list. For legacy notes the flat tags are mapped into
* categories using known tag values.
*/
export function extractFrontmatterFields(raw: string | null): FrontmatterFields {
const fields: FrontmatterFields = {
relationship: null,
relationship_sub: [],
topic: [],
email_type: [],
action: [],
status: null,
source: [],
}
if (!raw) return fields
const lines = raw.split('\n')
let currentKey: string | null = null
for (const line of lines) {
// Top-level key detection
const topMatch = line.match(/^(\w+):\s*(.*)$/)
if (topMatch || line === '---') {
currentKey = null
}
if (topMatch) {
const key = topMatch[1]
const value = topMatch[2].trim()
if (key in fields) {
currentKey = key
if (value) {
const field = fields[key as keyof FrontmatterFields]
if (Array.isArray(field)) {
(field as string[]).push(value)
} else {
// single-value field
;(fields as Record<string, unknown>)[key] = value
}
currentKey = null // inline value, no list follows
}
continue
}
// Legacy flat tags: — parse and distribute into categories
if (key === 'tags') {
currentKey = '__legacy_tags'
continue
}
}
// List items under a categorized key
if (currentKey && currentKey !== '__legacy_tags') {
const itemMatch = line.match(/^\s+-\s+(.+)$/)
if (itemMatch) {
const value = itemMatch[1].trim()
const field = fields[currentKey as keyof FrontmatterFields]
if (Array.isArray(field)) {
(field as string[]).push(value)
} else {
;(fields as Record<string, unknown>)[currentKey] = value
}
}
continue
}
// Legacy flat tag items → map into categories
if (currentKey === '__legacy_tags') {
const itemMatch = line.match(/^\s+-\s+(.+)$/)
if (itemMatch) {
const tag = itemMatch[1].trim()
const cat = LEGACY_TAG_TO_CATEGORY[tag]
if (cat) {
const field = fields[cat as keyof FrontmatterFields]
if (Array.isArray(field)) {
(field as string[]).push(tag)
} else if (!(fields as Record<string, unknown>)[cat]) {
;(fields as Record<string, unknown>)[cat] = tag
}
}
}
continue
}
}
return fields
}
/**
* Extract ALL top-level YAML key/value pairs from raw frontmatter.
* Returns a flat record where scalar values are strings and list values are string[].
* Skips `---` delimiters and blank lines.
*/
export function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
const result: Record<string, string | string[]> = {}
if (!raw) return result
const lines = raw.split('\n')
let currentKey: string | null = null
for (const line of lines) {
if (line === '---' || line.trim() === '') {
currentKey = null
continue
}
// Top-level key: value
const topMatch = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/)
if (topMatch) {
const key = topMatch[1]
const value = topMatch[2].trim()
if (value) {
result[key] = value
currentKey = null
} else {
// List will follow
currentKey = key
result[key] = []
}
continue
}
// List item under current key
if (currentKey) {
const itemMatch = line.match(/^\s+-\s+(.+)$/)
if (itemMatch) {
const arr = result[currentKey]
if (Array.isArray(arr)) {
arr.push(itemMatch[1].trim())
}
}
}
}
return result
}
/**
* Convert a Record of frontmatter fields back to a raw YAML frontmatter string.
* Returns null if no non-empty fields remain.
*/
export function buildFrontmatter(fields: Record<string, string | string[]>): string | null {
const lines: string[] = []
for (const [key, value] of Object.entries(fields)) {
if (Array.isArray(value)) {
if (value.length === 0) continue
lines.push(`${key}:`)
for (const item of value) {
if (item.trim()) lines.push(` - ${item.trim()}`)
}
} else {
const trimmed = (value ?? '').trim()
if (!trimmed) continue
lines.push(`${key}: ${trimmed}`)
}
}
if (lines.length === 0) return null
return `---\n${lines.join('\n')}\n---`
}
/** Map known tag values → category for legacy flat-list frontmatter. */
const LEGACY_TAG_TO_CATEGORY: Record<string, string> = {
// relationship
investor: 'relationship', customer: 'relationship', prospect: 'relationship',
partner: 'relationship', vendor: 'relationship', product: 'relationship',
candidate: 'relationship', team: 'relationship', advisor: 'relationship',
personal: 'relationship', press: 'relationship', community: 'relationship',
government: 'relationship',
// relationship_sub
primary: 'relationship_sub', secondary: 'relationship_sub',
'executive-assistant': 'relationship_sub', cc: 'relationship_sub',
'referred-by': 'relationship_sub', former: 'relationship_sub',
champion: 'relationship_sub', blocker: 'relationship_sub',
// topic
sales: 'topic', support: 'topic', legal: 'topic', finance: 'topic',
hiring: 'topic', fundraising: 'topic', travel: 'topic', event: 'topic',
shopping: 'topic', health: 'topic', learning: 'topic', research: 'topic',
// email_type
intro: 'email_type', followup: 'email_type',
// action
'action-required': 'action', urgent: 'action', waiting: 'action',
// status
active: 'status', archived: 'status', stale: 'status',
// source
email: 'source', meeting: 'source', browser: 'source',
'web-search': 'source', manual: 'source', import: 'source',
}
/** Tag category keys used in the categorized frontmatter format. */
const TAG_CATEGORY_KEYS = new Set([
'relationship',
'relationship_sub',
'topic',
'email_type',
'action',
'status',
'source',
])
/** Keys that are metadata, not tags — skip when collecting tags. */
const METADATA_KEYS = new Set(['processed', 'labeled_at', 'tagged_at'])
/**
* Extract tags from raw frontmatter YAML.
*
* Handles three formats:
* - Legacy flat list: `tags:` followed by ` - value` items
* - Categorized format: top-level keys like `relationship: customer` or
* `topic:` followed by ` - value` list items
* - Email format: `labels:` with nested keys (relationship, topics, type, filter, action)
* where values can be single strings or ` - value` arrays
*
* Skips metadata keys like `processed`, `labeled_at`, `tagged_at`.
*/
export function extractTags(raw: string | null): string[] {
if (!raw) return []
const lines = raw.split('\n')
const tags: string[] = []
let inTags = false
let inLabels = false
let inLabelSubKey = false
let inCategoryList = false
for (const line of lines) {
// Top-level key detection — resets all nested state
if (/^\w/.test(line) || line === '---') {
inTags = false
inLabels = false
inLabelSubKey = false
inCategoryList = false
}
// Legacy note format: tags:
if (/^tags:\s*$/.test(line)) {
inTags = true
inLabels = false
inCategoryList = false
continue
}
// Email format: labels:
if (/^labels:\s*$/.test(line)) {
inLabels = true
inTags = false
inCategoryList = false
continue
}
// Categorized format: top-level tag category key
const topKeyMatch = line.match(/^(\w+):\s*(.*)$/)
if (topKeyMatch) {
const key = topKeyMatch[1]
const inlineValue = topKeyMatch[2].trim()
if (TAG_CATEGORY_KEYS.has(key)) {
if (inlineValue) {
// Single value: `relationship: customer`
tags.push(inlineValue)
inCategoryList = false
} else {
// List follows: `topic:\n - sales`
inCategoryList = true
}
continue
}
}
// Collect tag items under `tags:`
if (inTags) {
const match = line.match(/^\s+-\s+(.+)$/)
if (match) {
tags.push(match[1].trim())
}
continue
}
// Collect list items under a category key
if (inCategoryList) {
const match = line.match(/^\s+-\s+(.+)$/)
if (match) {
tags.push(match[1].trim())
}
continue
}
// Handle labels: nested structure
if (inLabels) {
// Sub-key like ` relationship:` or ` topics:`
const subKeyMatch = line.match(/^\s{2}(\w+):\s*(.*)$/)
if (subKeyMatch) {
const key = subKeyMatch[1]
const inlineValue = subKeyMatch[2].trim()
if (METADATA_KEYS.has(key)) {
inLabelSubKey = false
continue
}
if (inlineValue) {
// Inline value like ` type: person`
tags.push(inlineValue)
inLabelSubKey = false
} else {
// Array follows
inLabelSubKey = true
}
continue
}
// Array item under a sub-key like ` - value`
if (inLabelSubKey) {
const itemMatch = line.match(/^\s{4}-\s+(.+)$/)
if (itemMatch) {
tags.push(itemMatch[1].trim())
}
}
}
}
return tags
}

File diff suppressed because it is too large Load diff

View file

@ -24,7 +24,9 @@
"ai": "^5.0.133",
"awilix": "^12.0.5",
"chokidar": "^4.0.3",
"cors": "^2.8.6",
"cron-parser": "^5.5.0",
"express": "^5.2.1",
"glob": "^13.0.0",
"google-auth-library": "^10.5.0",
"isomorphic-git": "^1.29.0",
@ -41,6 +43,8 @@
"zod": "^4.2.1"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.0.3",
"@types/papaparse": "^5.5.2",
"@types/pdf-parse": "^1.1.5"

View file

@ -0,0 +1,8 @@
import container from '../di/container.js';
import { IOAuthRepo } from '../auth/repo.js';
export async function isSignedIn(): Promise<boolean> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const { tokens } = await oauthRepo.read('rowboat');
return !!tokens;
}

View file

@ -2,7 +2,6 @@ import { jsonSchema, ModelMessage } from "ai";
import fs from "fs";
import path from "path";
import { WorkDir } from "../config/config.js";
import { getNoteCreationStrictness } from "../config/note_creation_config.js";
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage } from "@x/shared/dist/message.js";
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
@ -11,11 +10,13 @@ import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js";
import { execTool } from "../application/lib/exec-tool.js";
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
import { BuiltinTools } from "../application/lib/builtin-tools.js";
import { CopilotAgent } from "../application/assistant/agent.js";
import { buildCopilotAgent } from "../application/assistant/agent.js";
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
import container from "../di/container.js";
import { IModelConfigRepo } from "../models/repo.js";
import { createProvider } from "../models/models.js";
import { isSignedIn } from "../account/account.js";
import { getGatewayProvider } from "../models/gateway.js";
import { IAgentsRepo } from "./repo.js";
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
import { IBus } from "../application/lib/bus.js";
@ -25,9 +26,65 @@ import { IRunsLock } from "../runs/lock.js";
import { IAbortRegistry } from "../runs/abort-registry.js";
import { PrefixLogger } from "@x/shared";
import { parse } from "yaml";
import { raw as noteCreationMediumRaw } from "../knowledge/note_creation_medium.js";
import { raw as noteCreationLowRaw } from "../knowledge/note_creation_low.js";
import { raw as noteCreationHighRaw } from "../knowledge/note_creation_high.js";
import { getRaw as getNoteCreationRaw } from "../knowledge/note_creation.js";
import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js";
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
function loadAgentNotesContext(): string | null {
const sections: string[] = [];
const userFile = path.join(AGENT_NOTES_DIR, 'user.md');
const prefsFile = path.join(AGENT_NOTES_DIR, 'preferences.md');
try {
if (fs.existsSync(userFile)) {
const content = fs.readFileSync(userFile, 'utf-8').trim();
if (content) {
sections.push(`## About the User\nThese are notes you took about the user in previous chats.\n\n${content}`);
}
}
} catch { /* ignore */ }
try {
if (fs.existsSync(prefsFile)) {
const content = fs.readFileSync(prefsFile, 'utf-8').trim();
if (content) {
sections.push(`## User Preferences\nThese are notes you took on their general preferences.\n\n${content}`);
}
}
} catch { /* ignore */ }
// List other Agent Notes files for on-demand access
const otherFiles: string[] = [];
const skipFiles = new Set(['user.md', 'preferences.md', 'inbox.md']);
try {
if (fs.existsSync(AGENT_NOTES_DIR)) {
function listMdFiles(dir: string, prefix: string) {
for (const entry of fs.readdirSync(dir)) {
const fullPath = path.join(dir, entry);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
listMdFiles(fullPath, `${prefix}${entry}/`);
} else if (entry.endsWith('.md') && !skipFiles.has(`${prefix}${entry}`)) {
otherFiles.push(`${prefix}${entry}`);
}
}
}
listMdFiles(AGENT_NOTES_DIR, '');
}
} catch { /* ignore */ }
if (otherFiles.length > 0) {
sections.push(`## More Specific Preferences\nFor more specific preferences, you can read these files using workspace-readFile. Only read them when relevant to the current task.\n\n${otherFiles.map(f => `- knowledge/Agent Notes/${f}`).join('\n')}`);
}
if (sections.length === 0) return null;
return `# Agent Memory\n\n${sections.join('\n\n')}`;
}
export interface IAgentRuntime {
trigger(runId: string): Promise<void>;
@ -312,23 +369,11 @@ function formatLlmStreamError(rawError: unknown): string {
export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
if (id === "copilot" || id === "rowboatx") {
return CopilotAgent;
return buildCopilotAgent();
}
if (id === 'note_creation') {
const strictness = getNoteCreationStrictness();
let raw = '';
switch (strictness) {
case 'medium':
raw = noteCreationMediumRaw;
break;
case 'low':
raw = noteCreationLowRaw;
break;
case 'high':
raw = noteCreationHighRaw;
break;
}
const raw = getNoteCreationRaw();
let agent: z.infer<typeof Agent> = {
name: id,
instructions: raw,
@ -353,6 +398,106 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
return agent;
}
if (id === 'labeling_agent') {
const labelingAgentRaw = getLabelingAgentRaw();
let agent: z.infer<typeof Agent> = {
name: id,
instructions: labelingAgentRaw,
};
if (labelingAgentRaw.startsWith("---")) {
const end = labelingAgentRaw.indexOf("\n---", 3);
if (end !== -1) {
const fm = labelingAgentRaw.slice(3, end).trim();
const content = labelingAgentRaw.slice(end + 4).trim();
const yaml = parse(fm);
const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml);
agent = {
...agent,
...parsed,
instructions: content,
};
}
}
return agent;
}
if (id === 'note_tagging_agent') {
const noteTaggingAgentRaw = getNoteTaggingAgentRaw();
let agent: z.infer<typeof Agent> = {
name: id,
instructions: noteTaggingAgentRaw,
};
if (noteTaggingAgentRaw.startsWith("---")) {
const end = noteTaggingAgentRaw.indexOf("\n---", 3);
if (end !== -1) {
const fm = noteTaggingAgentRaw.slice(3, end).trim();
const content = noteTaggingAgentRaw.slice(end + 4).trim();
const yaml = parse(fm);
const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml);
agent = {
...agent,
...parsed,
instructions: content,
};
}
}
return agent;
}
if (id === 'inline_task_agent') {
const inlineTaskAgentRaw = getInlineTaskAgentRaw();
let agent: z.infer<typeof Agent> = {
name: id,
instructions: inlineTaskAgentRaw,
};
if (inlineTaskAgentRaw.startsWith("---")) {
const end = inlineTaskAgentRaw.indexOf("\n---", 3);
if (end !== -1) {
const fm = inlineTaskAgentRaw.slice(3, end).trim();
const content = inlineTaskAgentRaw.slice(end + 4).trim();
const yaml = parse(fm);
const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml);
agent = {
...agent,
...parsed,
instructions: content,
};
}
}
return agent;
}
if (id === 'agent_notes_agent') {
const agentNotesAgentRaw = getAgentNotesAgentRaw();
let agent: z.infer<typeof Agent> = {
name: id,
instructions: agentNotesAgentRaw,
};
if (agentNotesAgentRaw.startsWith("---")) {
const end = agentNotesAgentRaw.indexOf("\n---", 3);
if (end !== -1) {
const fm = agentNotesAgentRaw.slice(3, end).trim();
const content = agentNotesAgentRaw.slice(end + 4).trim();
const yaml = parse(fm);
const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml);
agent = {
...agent,
...parsed,
instructions: content,
};
}
}
return agent;
}
const repo = container.resolve<IAgentsRepo>('agentsRepo');
return await repo.fetch(id);
}
@ -705,15 +850,28 @@ export async function* streamAgent({
const tools = await buildTools(agent);
// set up provider + model
const provider = createProvider(modelConfig.provider);
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep"];
const modelId = (knowledgeGraphAgents.includes(state.agentName!) && modelConfig.knowledgeGraphModel)
? modelConfig.knowledgeGraphModel
: modelConfig.model;
const signedIn = await isSignedIn();
const provider = signedIn
? await getGatewayProvider()
: createProvider(modelConfig.provider);
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent", "agent_notes_agent"];
const isKgAgent = knowledgeGraphAgents.includes(state.agentName!);
const isInlineTaskAgent = state.agentName === "inline_task_agent";
const defaultModel = signedIn ? "gpt-5.4" : modelConfig.model;
const defaultKgModel = signedIn ? "gpt-5.4-mini" : defaultModel;
const defaultInlineTaskModel = signedIn ? "gpt-5.4" : defaultModel;
const modelId = isInlineTaskAgent
? defaultInlineTaskModel
: (isKgAgent && modelConfig.knowledgeGraphModel)
? modelConfig.knowledgeGraphModel
: isKgAgent ? defaultKgModel : defaultModel;
const model = provider.languageModel(modelId);
logger.log(`using model: ${modelId}`);
let loopCounter = 0;
let voiceInput = false;
let voiceOutput: 'summary' | 'full' | null = null;
let searchEnabled = false;
while (true) {
// Check abort at the top of each iteration
signal.throwIfAborted();
@ -832,6 +990,15 @@ export async function* streamAgent({
if (!msg) {
break;
}
if (msg.voiceInput) {
voiceInput = true;
}
if (msg.searchEnabled) {
searchEnabled = true;
}
if (msg.voiceOutput) {
voiceOutput = msg.voiceOutput;
}
loopLogger.log('dequeued user message', msg.messageId);
yield* processEvent({
runId,
@ -871,7 +1038,29 @@ export async function* streamAgent({
minute: '2-digit',
timeZoneName: 'short'
});
const instructionsWithDateTime = `Current date and time: ${currentDateTime}\n\n${agent.instructions}`;
let instructionsWithDateTime = `Current date and time: ${currentDateTime}\n\n${agent.instructions}`;
// Inject Agent Notes context for copilot
if (state.agentName === 'copilot' || state.agentName === 'rowboatx') {
const agentNotesContext = loadAgentNotesContext();
if (agentNotesContext) {
instructionsWithDateTime += `\n\n${agentNotesContext}`;
}
}
if (voiceInput) {
loopLogger.log('voice input enabled, injecting voice input prompt');
instructionsWithDateTime += `\n\n# Voice Input\nThe user's message was transcribed from speech. Be aware that:\n- There may be transcription errors. Silently correct obvious ones (e.g. homophones, misheard words). If an error is genuinely ambiguous, briefly mention your interpretation (e.g. "I'm assuming you meant X").\n- Spoken messages are often long-winded. The user may ramble, repeat themselves, or correct something they said earlier in the same message. Focus on their final intent, not every word verbatim.`;
}
if (voiceOutput === 'summary') {
loopLogger.log('voice output enabled (summary mode), injecting voice output prompt');
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY — READ THIS FIRST)\nThe user has voice output enabled. THIS IS YOUR #1 PRIORITY: you MUST start your response with <voice></voice> tags. If your response does not begin with <voice> tags, the user will hear nothing — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. Do not start with markdown, headings, or any other text. The literal first characters of your response must be "<voice>".\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n5. After all <voice> tags, you may include detailed written content (markdown, tables, code, etc.) that will be shown visually but not spoken.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Alex — March 11\n### Roadmap\n- Agreed to push Q2 launch to April 15...\n(detailed written content continues)\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You have five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you requested and Taylor flagged a contract issue.</voice>\n<voice>There's also a warm intro from a VC partner connecting you with someone at a prospective customer.</voice>\n<voice>I've drafted responses for three of them. The details and drafts are below.</voice>\n\n(email blocks, tables, and detailed content follow)\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a pretty packed day — seven meetings starting with standup at 9.</voice>\n<voice>The big ones are your investor call at 11, lunch with a partner from your lead VC at 12:30, and a customer call at 4.</voice>\n<voice>Your only free block for deep work is 2:30 to 4.</voice>\n\n(calendar block with full event details follows)\n\nExample 4 — User asks: "draft an email to Sam with our metrics"\n\n<voice>Done — I've drafted the email to Sam with your latest WAU and churn numbers.</voice>\n<voice>Take a look at the draft below and send it when you're ready.</voice>\n\n(email block with draft follows)\n\nREMEMBER: If you do not start with <voice> tags, the user hears silence. Always speak first, then write.`;
} else if (voiceOutput === 'full') {
loopLogger.log('voice output enabled (full mode), injecting voice output prompt');
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY — READ THIS FIRST)\nThe user wants your ENTIRE response spoken aloud. THIS IS YOUR #1 PRIORITY: every single sentence must be wrapped in <voice></voice> tags. If you write anything outside <voice> tags, the user will not hear it — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. The literal first characters of your response must be "<voice>".\n2. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n3. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n4. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n5. EVERY sentence MUST be inside a <voice> tag. Do not leave ANY content outside <voice> tags. If it's not in a <voice> tag, the user cannot hear it.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Alex will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You've got five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you asked for, and Taylor flagged a contract issue that needs your sign-off.</voice>\n<voice>There's a warm intro from a VC partner connecting you with an engineering lead at a potential customer.</voice>\n<voice>And someone from a prospective client wants to confirm your API tier before your call this afternoon.</voice>\n<voice>I've drafted replies for three of them — the metrics update, the intro, and the API question.</voice>\n<voice>The only one I left for you is Taylor's contract redline, since that needs your judgment on the liability cap.</voice>\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a packed day — seven meetings starting with standup at 9.</voice>\n<voice>The highlights are your investor call at 11, lunch with a VC partner at 12:30, and a customer call at 4.</voice>\n<voice>Your only open block for deep work is 2:30 to 4, so plan accordingly.</voice>\n<voice>Oh, and your 1-on-1 with your co-founder is at 5:30 — that's a walking meeting.</voice>\n\nExample 4 — User asks: "how are our metrics looking?"\n\n<voice>Metrics are looking strong this week.</voice>\n<voice>You hit 2,573 weekly active users, which is up 12% week over week.</voice>\n<voice>That means you've crossed the 2,500 milestone — worth calling out in your next investor update.</voice>\n<voice>Churn is down to 4.1%, improving month over month.</voice>\n<voice>The trailing 8-week compound growth rate is about 10%.</voice>\n\nREMEMBER: Start with <voice> immediately. No preamble, no markdown before it. Speak first.`;
}
if (searchEnabled) {
loopLogger.log('search enabled, injecting search prompt');
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Use the web-search tool to answer their query.`;
}
let streamError: string | null = null;
for await (const event of streamLlm(
model,

View file

@ -1,19 +1,23 @@
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
import z from "zod";
import { CopilotInstructions } from "./instructions.js";
import { buildCopilotInstructions } from "./instructions.js";
import { BuiltinTools } from "../lib/builtin-tools.js";
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
tools[name] = {
type: "builtin",
name,
/**
* Build the CopilotAgent dynamically.
* Tools are derived from the current BuiltinTools (which include Composio meta-tools),
* and instructions include the live Composio connection status.
*/
export async function buildCopilotAgent(): Promise<z.infer<typeof Agent>> {
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
tools[name] = { type: "builtin", name };
}
const instructions = await buildCopilotInstructions();
return {
name: "rowboatx",
description: "Rowboatx copilot",
instructions,
tools,
};
}
export const CopilotAgent: z.infer<typeof Agent> = {
name: "rowboatx",
description: "Rowboatx copilot",
instructions: CopilotInstructions,
tools,
}

View file

@ -1,9 +1,42 @@
import { skillCatalog } from "./skills/index.js";
import { WorkDir as BASE_DIR } from "../../config/config.js";
import { skillCatalog } from "./skills/index.js"; // eslint-disable-line @typescript-eslint/no-unused-vars -- used in template literal
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
import { composioAccountsRepo } from "../../composio/repo.js";
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
/**
* Generate dynamic instructions section for Composio integrations.
* Lists connected toolkits and explains the meta-tool discovery flow.
*/
async function getComposioToolsPrompt(): Promise<string> {
if (!(await isComposioConfigured())) {
return `
## Composio Integrations
**Composio is not configured.** Composio enables integrations with third-party services like Google Sheets, GitHub, Slack, Jira, Notion, LinkedIn, and 20+ others.
When the user asks to interact with any third-party service (e.g., "connect to Google Sheets", "create a GitHub issue"), do NOT attempt to write code, use shell commands, or load the composio-integration skill. Instead, let the user know that these integrations are available through Composio, and they can enable them by adding their Composio API key in **Settings > Tools Library**. They can get their key from https://app.composio.dev/settings.
**Exception Email and Calendar:** For email-related requests (reading emails, sending emails, drafting replies) or calendar-related requests (checking schedule, listing events), do NOT direct the user to Composio. Instead, tell them to connect their email and calendar in **Settings > Connected Accounts**.
`;
}
const connectedToolkits = composioAccountsRepo.getConnectedToolkits();
const connectedSection = connectedToolkits.length > 0
? `**Currently connected:** ${connectedToolkits.map(slug => CURATED_TOOLKITS.find(t => t.slug === slug)?.displayName ?? slug).join(', ')}`
: `**No services connected yet.** Load the \`composio-integration\` skill to help the user connect one.`;
return `
## Composio Integrations
${connectedSection}
Load the \`composio-integration\` skill when the user asks to interact with any third-party service. NEVER say "I can't access [service]" without loading the skill and trying Composio first.
`;
}
export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
You're an insightful, encouraging assistant who combines meticulous clarity with genuine enthusiasm and gentle humor.
@ -25,7 +58,9 @@ You're an insightful, encouraging assistant who combines meticulous clarity with
## What Rowboat Is
Rowboat is an agentic assistant for everyday work - emails, meetings, projects, and people. Users give you tasks like "draft a follow-up email," "prep me for this meeting," or "summarize where we are with this project." You figure out what context you need, pull from emails and meetings, and get it done.
**Email Drafting:** When users ask you to draft emails or respond to emails, load the \`draft-emails\` skill first. It provides structured guidance for processing emails, gathering context from calendar and knowledge base, and creating well-informed draft responses.
**Email Drafting:** When users ask you to **draft** or **compose** emails (e.g., "draft a follow-up to Monica", "write an email to John about the project"), load the \`draft-emails\` skill first. Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.
**Third-Party Services:** When users ask to interact with any external service (Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.) reading emails, listing issues, sending messages, fetching profiles load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.
**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
@ -33,7 +68,32 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base.
**Slack:** When users ask about Slack messages, want to send messages to teammates, check channel conversations, or find someone on Slack, load the \`slack\` skill. You can send messages, view channel history, search conversations, and find users. Always check if Slack is connected first with \`slack-checkConnection\`, and always show message drafts to the user before sending.
**App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
## Learning About the User (save-to-memory)
Use the \`save-to-memory\` tool to note things worth remembering about the user. This builds a persistent profile that helps you serve them better over time. Call it proactively — don't ask permission.
**When to save:**
- User states a preference: "I prefer bullet points"
- User corrects your style: "too formal, keep it casual"
- You learn about their relationships: "Monica is my co-founder"
- You notice workflow patterns: "no meetings before 11am"
- User gives explicit instructions: "never use em-dashes"
- User has preferences for specific tasks: "pitch decks should be minimal, max 12 slides"
**Capture context, not blanket rules:**
- BAD: "User prefers casual tone" this loses important context
- GOOD: "User prefers casual tone with internal team (Ramnique, Monica) but formal/polished with investors (Brad, Dalton)"
- BAD: "User likes short emails" too vague
- GOOD: "User sends very terse 1-2 line emails to co-founder Ramnique, but writes structured 2-3 paragraph emails to investors with proper greetings"
- Always note WHO or WHAT CONTEXT a preference applies to. Most preferences are situational, not universal.
**When NOT to save:**
- Ephemeral task details ("draft an email about X")
- Things already in the knowledge graph
- Information you can derive from reading their notes
## Memory That Compounds
Unlike other AI assistants that start cold every session, you have access to a live knowledge graph that updates itself from Gmail, calendar, and meeting notes (Google Meet, Granola, Fireflies). This isn't just summaries - it's structured extraction of decisions, commitments, open questions, and context, routed to long-lived notes for each person, project, and topic.
@ -140,13 +200,9 @@ Always consult this catalog first so you load the right skills before taking act
- Never start a response with a heading. Lead with a sentence or two of context first.
- Avoid deeply nested bullets. If nesting beyond 2 levels, restructure.
## MCP Tool Discovery (CRITICAL)
## Tool Priority
**ALWAYS check for MCP tools BEFORE saying you can't do something.**
When a user asks for ANY task that might require external capabilities (web search, internet access, APIs, data fetching, etc.), check MCP tools first using \`listMcpServers\` and \`listMcpTools\`. Load the "mcp-integration" skill for detailed guidance on discovering and executing MCP tools.
**DO NOT** immediately respond with "I can't access the internet" or "I don't have that capability" without checking MCP tools first!
For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integration\` skill. For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.
## Execution Reminders
- Explore existing files and structure before creating new assets.
@ -183,7 +239,10 @@ ${runtimeContextPrompt}
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
- \`loadSkill\` - Skill loading
- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.
- \`web-search\` and \`research-search\` - Web and research search tools (available when configured). **You MUST load the \`web-search\` skill before using either of these tools.** It tells you which tool to pick and how many searches to do.
- \`web-search\` - Search the web. Returns rich results with full text, highlights, and metadata. The \`category\` parameter defaults to \`general\` (full web search) — only use a specific category like \`news\`, \`company\`, \`research paper\` etc. when the query is clearly about that type. For everyday queries (weather, restaurants, prices, how-to), use \`general\`.
- \`app-navigation\` - Control the app UI: open notes, switch views, filter/search the knowledge base, manage saved views. **Load the \`app-navigation\` skill before using this tool.**
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
- \`composio-list-toolkits\`, \`composio-search-tools\`, \`composio-execute-tool\`, \`composio-connect-toolkit\` — Composio integration tools. Load the \`composio-integration\` skill for usage guidance.
**Prefer these tools whenever possible** they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`.
@ -223,3 +282,29 @@ This renders as an interactive card in the UI that the user can click to open th
**IMPORTANT:** Only use filepath blocks for files that already exist. The card is clickable and opens the file, so it must point to a real file. If you are proposing a path for a file that hasn't been created yet (e.g., "Shall I save it at ~/Documents/report.pdf?"), use inline code (\`~/Documents/report.pdf\`) instead of a filepath block. Use the filepath block only after the file has been written/created successfully.
Never output raw file paths in plain text when they could be wrapped in a filepath block unless the file does not exist yet.`;
/**
* Cached Composio instructions. Invalidated by calling invalidateCopilotInstructionsCache().
*/
let cachedInstructions: string | null = null;
/**
* Invalidate the cached instructions so the next buildCopilotInstructions() call
* regenerates the Composio section. Call this after connecting/disconnecting a toolkit.
*/
export function invalidateCopilotInstructionsCache(): void {
cachedInstructions = null;
}
/**
* Build full copilot instructions with dynamic Composio tools section.
* Results are cached and reused until invalidated via invalidateCopilotInstructionsCache().
*/
export async function buildCopilotInstructions(): Promise<string> {
if (cachedInstructions !== null) return cachedInstructions;
const composioPrompt = await getComposioToolsPrompt();
cachedInstructions = composioPrompt
? CopilotInstructions + '\n' + composioPrompt
: CopilotInstructions;
return cachedInstructions;
}

View file

@ -0,0 +1,82 @@
export const skill = String.raw`
# App Navigation Skill
You have access to the **app-navigation** tool which lets you control the Rowboat UI directly opening notes, switching views, filtering the knowledge base, and creating saved views.
## Actions
### open-note
Open a specific knowledge file in the editor pane.
**When to use:** When the user asks to see, open, or view a specific note (e.g., "open John's note", "show me the Acme project page").
**Parameters:**
- ` + "`path`" + `: Full workspace-relative path (e.g., ` + "`knowledge/People/John Smith.md`" + `)
**Tips:**
- Use ` + "`workspace-grep`" + ` first to find the exact path if you're unsure of the filename.
- Always pass the full ` + "`knowledge/...`" + ` path, not just the filename.
### open-view
Switch the UI to the graph or bases view.
**When to use:** When the user asks to see the knowledge graph, view all notes, or open the bases/table view.
**Parameters:**
- ` + "`view`" + `: ` + "`\"graph\"`" + ` or ` + "`\"bases\"`" + `
### update-base-view
Change filters, columns, sort order, or search in the bases (table) view.
**When to use:** When the user asks to find, filter, sort, or search notes. Examples: "show me all active customers", "filter by topic=hiring", "sort by name", "search for pricing".
**Parameters:**
- ` + "`filters`" + `: Object with ` + "`set`" + `, ` + "`add`" + `, ` + "`remove`" + `, or ` + "`clear`" + ` each takes an array of ` + "`{ category, value }`" + ` pairs.
- ` + "`set`" + `: Replace ALL current filters with these.
- ` + "`add`" + `: Append filters without removing existing ones.
- ` + "`remove`" + `: Remove specific filters.
- ` + "`clear: true`" + `: Remove all filters.
- ` + "`columns`" + `: Object with ` + "`set`" + `, ` + "`add`" + `, or ` + "`remove`" + ` each takes an array of column names (frontmatter keys).
- ` + "`sort`" + `: ` + "`{ field, dir }`" + ` where dir is ` + "`\"asc\"`" + ` or ` + "`\"desc\"`" + `.
- ` + "`search`" + `: Free-text search string.
**Tips:**
- If unsure what categories/values are available, call ` + "`get-base-state`" + ` first.
- For "show me X", prefer ` + "`filters.set`" + ` to start fresh rather than ` + "`filters.add`" + `.
- Categories come from frontmatter keys (e.g., relationship, status, topic, type).
- **CRITICAL: Do NOT pass ` + "`columns`" + ` unless the user explicitly asks to show/hide specific columns.** Omit the ` + "`columns`" + ` parameter entirely when only filtering, sorting, or searching. Passing ` + "`columns`" + ` will override the user's current column layout and can make the view appear empty.
### get-base-state
Retrieve information about what's in the knowledge base available filter categories, values, and note count.
**When to use:** When you need to know what properties exist before filtering, or when the user asks "what can I filter by?", "how many notes are there?", etc.
**Parameters:**
- ` + "`base_name`" + ` (optional): Name of a saved base to inspect.
### create-base
Save the current view configuration as a named base.
**When to use:** When the user asks to save a filtered view, create a saved search, or says "save this as [name]".
**Parameters:**
- ` + "`name`" + `: Human-readable name for the base.
## Workflow Example
1. User: "Show me all people who are customers"
2. First, check what properties are available:
` + "`app-navigation({ action: \"get-base-state\" })`" + `
3. Apply filters based on the available properties:
` + "`app-navigation({ action: \"update-base-view\", filters: { set: [{ category: \"relationship\", value: \"customer\" }] } })`" + `
4. If the user wants to save it:
` + "`app-navigation({ action: \"create-base\", name: \"Customers\" })`" + `
## Important Notes
- The ` + "`update-base-view`" + ` action will automatically navigate to the bases view if the user isn't already there.
- ` + "`open-note`" + ` validates that the file exists before navigating.
- Filter categories and values come from frontmatter in knowledge files.
- **Never send ` + "`columns`" + ` or ` + "`sort`" + ` with ` + "`update-base-view`" + ` unless the user specifically asks to change them.** Only pass the parameters you intend to change omitted parameters are left untouched.
`;
export default skill;

View file

@ -0,0 +1,127 @@
export const skill = String.raw`
# Composio Integration
**Load this skill** when the user asks to interact with ANY third-party service email, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, calendar, etc. This skill provides the complete workflow for discovering, connecting, and executing Composio tools.
## Available Tools
| Tool | Purpose |
|------|---------|
| **composio-list-toolkits** | List all available integrations and their connection status |
| **composio-search-tools** | Search for tools by use case; returns slugs and input schemas |
| **composio-execute-tool** | Execute a tool by slug with parameters |
| **composio-connect-toolkit** | Connect a service via OAuth (opens browser) |
## Toolkit Slugs (exact values for toolkitSlug parameter)
| Service | Slug |
|---------|------|
| Gmail | \`gmail\` |
| Google Calendar | \`googlecalendar\` |
| Google Sheets | \`googlesheets\` |
| Google Docs | \`googledocs\` |
| Google Drive | \`googledrive\` |
| Slack | \`slack\` |
| GitHub | \`github\` |
| Notion | \`notion\` |
| Linear | \`linear\` |
| Jira | \`jira\` |
| Asana | \`asana\` |
| Trello | \`trello\` |
| HubSpot | \`hubspot\` |
| Salesforce | \`salesforce\` |
| LinkedIn | \`linkedin\` |
| X (Twitter) | \`twitter\` |
| Reddit | \`reddit\` |
| Dropbox | \`dropbox\` |
| OneDrive | \`onedrive\` |
| Microsoft Outlook | \`microsoft_outlook\` |
| Microsoft Teams | \`microsoft_teams\` |
| Calendly | \`calendly\` |
| Cal.com | \`cal\` |
| Intercom | \`intercom\` |
| Zendesk | \`zendesk\` |
| Airtable | \`airtable\` |
**IMPORTANT:** Always use these exact slugs. Do NOT guess e.g., Google Sheets is \`googlesheets\` (no underscore), not \`google_sheets\`.
## Critical: Check First, Connect Second
**BEFORE calling composio-connect-toolkit, ALWAYS check if the service is already connected.** The system prompt includes a "Currently connected" list. If the service is there, skip connecting and go straight to search + execute.
**Flow:**
1. Check if the service is in the "Currently connected" list (in the system prompt above)
2. If **connected** go directly to step 4
3. If **NOT connected** call \`composio-connect-toolkit\` once, wait for user to authenticate, then continue
4. Call \`composio-search-tools\` with SHORT keyword queries
5. Read the \`inputSchema\` from results — note \`required\` fields
6. Call \`composio-execute-tool\` with slug, toolkit, and all required arguments
**NEVER call composio-connect-toolkit for a service that's already connected.** This creates duplicate connect cards in the UI.
## Search Query Tips
Use **short keyword queries**, not full sentences:
| Good | Bad |
|---------|--------|
| "list issues" | "get all open issues for a GitHub repository" |
| "send email" | "send an email to someone using Gmail" |
| "get profile" | "fetch the authenticated user's profile details" |
| "create spreadsheet" | "create a new Google Sheets spreadsheet with data" |
If the first search returns 0 results, try a different short query (e.g., "issues" instead of "list issues").
## Passing Arguments
**ALWAYS include the \`arguments\` field** when calling \`composio-execute-tool\`, even if the tool has no required parameters.
- Read the \`inputSchema\` from search results carefully
- Extract user-provided values into the correct fields (e.g., "rowboatlabs/rowboat" \`owner: "rowboatlabs", repo: "rowboat"\`)
- For tools with empty \`properties: {}\`, pass \`arguments: {}\`
- For tools with required fields, pass all of them
### Example: GitHub Issues
User says: "Get me the open issues on rowboatlabs/rowboat"
1. \`composio-search-tools({ query: "list issues", toolkitSlug: "github" })\`
finds \`GITHUB_ISSUES_LIST_FOR_REPO\` with required: ["owner", "repo"]
2. \`composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })\`
### Example: Gmail Fetch
User says: "What's my latest email?"
1. \`composio-search-tools({ query: "fetch emails", toolkitSlug: "gmail" })\`
finds \`GMAIL_FETCH_EMAILS\`
2. \`composio-execute-tool({ toolSlug: "GMAIL_FETCH_EMAILS", toolkitSlug: "gmail", arguments: { user_id: "me", max_results: 5 } })\`
### Example: LinkedIn Profile (no-arg tool)
User says: "Get my LinkedIn profile"
1. \`composio-search-tools({ query: "get profile", toolkitSlug: "linkedin" })\`
finds \`LINKEDIN_GET_MY_INFO\` with properties: {}
2. \`composio-execute-tool({ toolSlug: "LINKEDIN_GET_MY_INFO", toolkitSlug: "linkedin", arguments: {} })\`
## Error Recovery
- **If a tool call fails** (missing fields, 500 error): Fix the arguments and retry IMMEDIATELY. Do NOT stop and narrate the error to the user.
- **If search returns 0 results**: Try a different short query. If still 0, the tool may not exist for that service.
- **If a tool requires connection**: Call \`composio-connect-toolkit\` once, then retry after connection.
## Multi-Part Requests
When the user says "connect X and then do Y" complete BOTH parts in one turn:
1. If X is already connected (check the connected list), skip to Y immediately
2. If X needs connecting, connect it, then proceed to Y after authentication
## Confirmation Rules
- **Read-only actions** (fetch, list, get, search): Execute without asking
- **Mutating actions** (send email, create issue, post, delete): Show the user what you're about to do and confirm before executing
- **Connecting a toolkit**: Always safe just do it when needed
`;
export default skill;

View file

@ -173,6 +173,56 @@ Documents are stored in \`~/.rowboat/knowledge/\` with subfolders:
- \`Topics/\` - Subject matter notes
- Root level for general documents
## Rich Blocks
Notes support rich block types beyond standard Markdown. Blocks are fenced code blocks with a language identifier and a JSON body. Use these when the user asks for visual content like charts, tables, images, or embeds.
### Image Block
Displays an image with optional alt text and caption.
\`\`\`image
{"src": "https://example.com/photo.png", "alt": "Description", "caption": "Optional caption"}
\`\`\`
- \`src\` (required): URL or relative path to the image
- \`alt\` (optional): Alt text
- \`caption\` (optional): Caption displayed below the image
### Embed Block
Embeds external content (YouTube videos, Figma designs, or generic links).
\`\`\`embed
{"provider": "youtube", "url": "https://www.youtube.com/watch?v=VIDEO_ID", "caption": "Video title"}
\`\`\`
- \`provider\` (required): \`"youtube"\`, \`"figma"\`, or \`"generic"\`
- \`url\` (required): Full URL to the content
- \`caption\` (optional): Caption displayed below the embed
- YouTube and Figma render as iframes; generic shows a link card
### Chart Block
Renders a chart from inline data.
\`\`\`chart
{"chart": "bar", "title": "Q1 Revenue", "data": [{"month": "Jan", "revenue": 50000}, {"month": "Feb", "revenue": 62000}], "x": "month", "y": "revenue"}
\`\`\`
- \`chart\` (required): \`"line"\`, \`"bar"\`, or \`"pie"\`
- \`title\` (optional): Chart title
- \`data\` (optional): Array of objects with the data points
- \`source\` (optional): Relative path to a JSON file containing the data array (alternative to inline data)
- \`x\` (required): Key name for the x-axis / label field
- \`y\` (required): Key name for the y-axis / value field
### Table Block
Renders a styled table from structured data.
\`\`\`table
{"title": "Team", "columns": ["name", "role"], "data": [{"name": "Alice", "role": "Eng"}, {"name": "Bob", "role": "Design"}]}
\`\`\`
- \`columns\` (required): Array of column names (determines display order)
- \`data\` (required): Array of row objects
- \`title\` (optional): Table title
### Block Guidelines
- The JSON must be valid and on a single line (no pretty-printing)
- Insert blocks using \`workspace-editFile\` just like any other content
- When the user asks for a chart, table, or embed use blocks rather than plain Markdown tables or image links
- When editing a note that already contains blocks, preserve them unless the user asks to change them
## Best Practices
**Writing style:**

View file

@ -7,10 +7,11 @@ import draftEmailsSkill from "./draft-emails/skill.js";
import mcpIntegrationSkill from "./mcp-integration/skill.js";
import meetingPrepSkill from "./meeting-prep/skill.js";
import organizeFilesSkill from "./organize-files/skill.js";
import slackSkill from "./slack/skill.js";
import backgroundAgentsSkill from "./background-agents/skill.js";
import createPresentationsSkill from "./create-presentations/skill.js";
import webSearchSkill from "./web-search/skill.js";
import appNavigationSkill from "./app-navigation/skill.js";
import composioIntegrationSkill from "./composio-integration/skill.js";
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_PREFIX = "src/application/assistant/skills";
@ -59,12 +60,6 @@ const definitions: SkillDefinition[] = [
summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.",
content: organizeFilesSkill,
},
{
id: "slack",
title: "Slack Integration",
summary: "Send Slack messages, view channel history, search conversations, find users, and manage team communication.",
content: slackSkill,
},
{
id: "background-agents",
title: "Background Agents",
@ -84,10 +79,10 @@ const definitions: SkillDefinition[] = [
content: mcpIntegrationSkill,
},
{
id: "web-search",
title: "Web Search",
summary: "Searching the web or researching a topic. Guidance on when to use web-search vs research-search, and how many searches to do.",
content: webSearchSkill,
id: "composio-integration",
title: "Composio Integration",
summary: "Interact with third-party services (Gmail, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, etc.) via Composio. Search, connect, and execute tools.",
content: composioIntegrationSkill,
},
{
id: "deletion-guardrails",
@ -95,6 +90,12 @@ const definitions: SkillDefinition[] = [
summary: "Following the confirmation process before removing workflows or agents and their dependencies.",
content: deletionGuardrailsSkill,
},
{
id: "app-navigation",
title: "App Navigation",
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
content: appNavigationSkill,
},
];
const skillEntries = definitions.map((definition) => ({

View file

@ -3,9 +3,13 @@ export const skill = String.raw`
**Load this skill proactively** when a user asks for ANY task that might require external capabilities (web search, internet access, APIs, data fetching, time/date, etc.). This skill provides complete guidance on discovering and executing MCP tools.
## CRITICAL: Always Check MCP Tools First
## CRITICAL: Composio Tools Take Priority Over MCP
**IMPORTANT**: When a user asks for ANY task that might require external capabilities (web search, API calls, data fetching, etc.), ALWAYS:
**If a Composio toolkit is connected for the service the user wants (GitHub, Gmail, Slack, etc.), use the \`composio-search-tools\` and \`composio-execute-tool\` builtin tools — NOT MCP tools.** Composio integrations are already authenticated and ready to use. Only fall back to MCP tools if the service is NOT available through Composio.
## When to Check MCP Tools
**IMPORTANT**: When a user asks for a task that requires external capabilities AND no Composio toolkit covers it, check MCP tools:
1. **First check**: Call \`listMcpServers\` to see what's available
2. **Then list tools**: Call \`listMcpTools\` on relevant servers
@ -18,14 +22,12 @@ export const skill = String.raw`
| User Request | Check For | Likely Tool |
|--------------|-----------|-------------|
| "Search the web/internet" | firecrawl, composio, fetch | \`firecrawl_search\`, \`COMPOSIO_SEARCH_WEB\` |
| "Search the web/internet" | firecrawl, fetch | \`firecrawl_search\` |
| "Scrape this website" | firecrawl | \`firecrawl_scrape\` |
| "Read/write files" | filesystem | \`read_file\`, \`write_file\` |
| "Get current time/date" | time | \`get_current_time\` |
| "Make HTTP request" | fetch | \`fetch\`, \`post\` |
| "GitHub operations" | github | \`create_issue\`, \`search_repos\` |
| "Generate audio/speech" | elevenLabs | \`text_to_speech\` |
| "Tweet/social media" | twitter, composio | Various social tools |
## Key concepts
- MCP servers expose tools (web scraping, APIs, databases, etc.) declared in \`config/mcp.json\`.
@ -242,7 +244,7 @@ The schema tells you:
**Example schema from listMcpTools:**
\`\`\`json
{
"name": "COMPOSIO_SEARCH_WEB",
"name": "firecrawl_search",
"inputSchema": {
"type": "object",
"properties": {
@ -263,10 +265,10 @@ The schema tells you:
**Correct executeMcpTool call:**
\`\`\`json
{
"serverName": "composio",
"toolName": "COMPOSIO_SEARCH_WEB",
"serverName": "firecrawl",
"toolName": "firecrawl_search",
"arguments": {
"query": "elon musk latest news"
"query": "latest AI news"
}
}
\`\`\`
@ -274,18 +276,18 @@ The schema tells you:
**WRONG - Missing arguments:**
\`\`\`json
{
"serverName": "composio",
"toolName": "COMPOSIO_SEARCH_WEB"
"serverName": "firecrawl",
"toolName": "firecrawl_search"
}
\`\`\`
**WRONG - Wrong parameter name:**
\`\`\`json
{
"serverName": "composio",
"toolName": "COMPOSIO_SEARCH_WEB",
"serverName": "firecrawl",
"toolName": "firecrawl_search",
"arguments": {
"search": "elon musk" // Wrong! Should be "query"
"search": "latest AI news" // Wrong! Should be "query"
}
}
\`\`\`

View file

@ -1,121 +1,124 @@
import { slackToolCatalogMarkdown } from "./tool-catalog.js";
const skill = String.raw`
# Slack Integration Skill
# Slack Integration Skill (agent-slack CLI)
You can interact with Slack to help users communicate with their team. This includes sending messages, viewing channel history, finding users, and searching conversations.
You interact with Slack by running **agent-slack** commands through \`executeCommand\`.
## Prerequisites
---
## 1. Check Connection
Before any Slack operation, read \`~/.rowboat/config/slack.json\`. If \`enabled\` is \`false\` or the \`workspaces\` array is empty, simply tell the user: "Slack is not enabled. You can enable it in the Connectors settings." Do not attempt any agent-slack commands.
If enabled, use the workspace URLs from the config for all commands.
---
## 2. Core Commands
### Messages
| Action | Command |
|--------|---------|
| List recent messages | \`agent-slack message list "#channel-name" --limit 25\` |
| List thread replies | \`agent-slack message list "#channel" --thread-ts 1234567890.123456\` |
| Get a single message | \`agent-slack message get "https://team.slack.com/archives/C.../p..."\` |
| Send a message | \`agent-slack message send "#channel-name" "Hello team!"\` |
| Reply in thread | \`agent-slack message send "#channel-name" "Reply text" --thread-ts 1234567890.123456\` |
| Edit a message | \`agent-slack message edit "#channel-name" --ts 1234567890.123456 "Updated text"\` |
| Delete a message | \`agent-slack message delete "#channel-name" --ts 1234567890.123456\` |
**Targets** can be:
- A full Slack URL: \`https://team.slack.com/archives/C01234567/p1234567890123456\`
- A channel name: \`"#general"\` or \`"general"\`
- A channel ID: \`C01234567\`
### Reactions
Before using Slack tools, ALWAYS check if Slack is connected:
\`\`\`
slack-checkConnection({})
agent-slack message react add "<target>" <emoji> --ts <ts>
agent-slack message react remove "<target>" <emoji> --ts <ts>
\`\`\`
If not connected, inform the user they need to connect Slack from the settings/onboarding.
### Search
## Available Tools
### Check Connection
\`\`\`
slack-checkConnection({})
\`\`\`
Returns whether Slack is connected and ready to use.
### List Users
\`\`\`
slack-listUsers({ limit: 100 })
\`\`\`
Lists users in the workspace. Use this to resolve a name to a user ID.
### List DM Conversations
\`\`\`
slack-getDirectMessages({ limit: 50 })
\`\`\`
Lists DM channels (type "im"). Each entry includes the DM channel ID and the user ID.
### List Channels
\`\`\`
slack-listChannels({ types: "public_channel,private_channel", limit: 100 })
\`\`\`
Lists channels the user has access to.
### Get Conversation History
\`\`\`
slack-getChannelHistory({ channel: "C01234567", limit: 20 })
\`\`\`
Fetches recent messages for a channel or DM.
### Search Messages
\`\`\`
slack-searchMessages({ query: "in:@username", count: 20 })
\`\`\`
Searches Slack messages using Slack search syntax.
### Send a Message
\`\`\`
slack-sendMessage({ channel: "C01234567", text: "Hello team!" })
\`\`\`
Sends a message to a channel or DM. Always show the draft first.
### Execute a Slack Action
\`\`\`
slack-executeAction({
toolSlug: "EXACT_TOOL_SLUG_FROM_DISCOVERY",
input: { /* tool-specific parameters */ }
})
\`\`\`
Executes any Slack tool using its exact slug discovered from \`slack-listAvailableTools\`.
### Discover Available Tools (Fallback)
\`\`\`
slack-listAvailableTools({ search: "conversation" })
\`\`\`
Lists available Slack tools from Composio. Use this only if a builtin Slack tool fails and you need a specific slug.
## Composio Slack Tool Catalog (Pinned)
Use the exact tool slugs below with \`slack-executeAction\` when needed. Prefer these over \`slack-listAvailableTools\` to avoid redundant discovery.
${slackToolCatalogMarkdown}
## Workflow
### Step 1: Check Connection
\`\`\`
slack-checkConnection({})
agent-slack search messages "query text" --limit 20
agent-slack search messages "query" --channel "#channel-name" --user "@username"
agent-slack search messages "query" --after 2025-01-01 --before 2025-02-01
agent-slack search files "query" --limit 10
\`\`\`
### Step 2: Choose the Builtin Tool
Use the builtin Slack tools above for common tasks. Only fall back to \`slack-listAvailableTools\` + \`slack-executeAction\` if something is missing.
### Channels
## Common Tasks
\`\`\`
agent-slack channel new --name "project-x" --workspace https://team.slack.com
agent-slack channel new --name "secret-project" --private
agent-slack channel invite --channel "#project-x" --users "@alice,@bob"
\`\`\`
### Find the Most Recent DM with Someone
1. Search messages first: \`slack-searchMessages({ query: "in:@Name", count: 1 })\`
2. If you need exact DM history:
- \`slack-listUsers({})\` to find the user ID
- \`slack-getDirectMessages({})\` to find the DM channel for that user
- \`slack-getChannelHistory({ channel: "D...", limit: 20 })\`
### Users
### Send a Message
1. Draft the message and show it to the user
2. ONLY after user approval, send using \`slack-sendMessage\`
\`\`\`
agent-slack user list --limit 200
agent-slack user get "@username"
agent-slack user get U01234567
\`\`\`
### Search Messages
1. Use \`slack-searchMessages({ query: "...", count: 20 })\`
### Canvases
\`\`\`
agent-slack canvas get "https://team.slack.com/docs/F01234567"
agent-slack canvas get F01234567 --workspace https://team.slack.com
\`\`\`
---
## 3. Multi-Workspace
**Important:** The user has chosen which workspaces to use. Before your first Slack operation, read \`~/.rowboat/config/slack.json\` to see the selected workspaces. Only interact with workspaces listed in that config — ignore any other authenticated workspaces.
If the selected workspace list contains multiple entries, use \`--workspace <url>\` to disambiguate:
\`\`\`
agent-slack message list "#general" --workspace https://team.slack.com
\`\`\`
If only one workspace is selected, always use \`--workspace\` with its URL to avoid ambiguity with other authenticated workspaces.
---
## 4. Token Budget Control
Use \`--limit\` to control how many messages/results are returned. Use \`--max-body-chars\` or \`--max-content-chars\` to truncate long message bodies:
\`\`\`
agent-slack message list "#channel" --limit 10
agent-slack search messages "query" --limit 5 --max-content-chars 2000
\`\`\`
---
## 5. Discovering More Commands
For any command you're unsure about:
\`\`\`
agent-slack --help
agent-slack message --help
agent-slack search --help
agent-slack channel --help
\`\`\`
---
## Best Practices
- **Always show drafts before sending** - Never send Slack messages without user confirmation
- **Summarize, don't dump** - When showing channel history, summarize the key points
- **Cross-reference with knowledge base** - Check if mentioned people have notes in the knowledge base
## Error Handling
If a Slack operation fails:
1. Try \`slack-listAvailableTools\` to verify the tool slug is correct
2. Check if Slack is still connected with \`slack-checkConnection\`
3. Inform the user of the specific error
- **Always show drafts before sending** Never send Slack messages without user confirmation
- **Summarize, don't dump** When showing channel history, summarize the key points rather than pasting everything
- **Prefer Slack URLs** When referring to messages, use Slack URLs over raw channel names when available
- **Use --limit** Always set reasonable limits to keep output concise and token-efficient
- **Resolve user IDs** Messages contain raw user IDs like \`U078AHJP341\`. Resolve them to real names before presenting to the user. Batch all lookups into a single \`executeCommand\` call using \`;\` separators, e.g. \`agent-slack user get U078AHJP341 --workspace ... ; agent-slack user get U090UEZCEQ0 --workspace ...\`
- **Cross-reference with knowledge base** Check if mentioned people have notes in the knowledge base
`;
export default skill;

View file

@ -1,117 +0,0 @@
export type SlackToolDefinition = {
name: string;
slug: string;
description: string;
};
export const slackToolCatalog: SlackToolDefinition[] = [
{ name: "Add Emoji Alias", slug: "SLACK_ADD_AN_EMOJI_ALIAS_IN_SLACK", description: "Adds an alias for an existing custom emoji." },
{ name: "Add Remote File", slug: "SLACK_ADD_A_REMOTE_FILE_FROM_A_SERVICE", description: "Adds a reference to an external file (e.g., GDrive, Dropbox) to Slack." },
{ name: "Add Star to Item", slug: "SLACK_ADD_A_STAR_TO_AN_ITEM", description: "Stars a channel, file, comment, or message." },
{ name: "Add Call Participants", slug: "SLACK_ADD_CALL_PARTICIPANTS", description: "Registers new participants added to a Slack call." },
{ name: "Add Emoji", slug: "SLACK_ADD_EMOJI", description: "Adds a custom emoji to a workspace via a unique name and URL." },
{ name: "Add Reaction", slug: "SLACK_ADD_REACTION_TO_AN_ITEM", description: "Adds a specified emoji reaction to a message." },
{ name: "Archive Channel", slug: "SLACK_ARCHIVE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Archives a public or private channel." },
{ name: "Archive Conversation", slug: "SLACK_ARCHIVE_A_SLACK_CONVERSATION", description: "Archives a conversation by its ID." },
{ name: "Close DM/MPDM", slug: "SLACK_CLOSE_DM_OR_MULTI_PERSON_DM", description: "Closes a DM or MPDM sidebar view for the user." },
{ name: "Create Reminder", slug: "SLACK_CREATE_A_REMINDER", description: "Creates a reminder with text and time (natural language supported)." },
{ name: "Create User Group", slug: "SLACK_CREATE_A_SLACK_USER_GROUP", description: "Creates a new user group (subteam)." },
{ name: "Create Channel", slug: "SLACK_CREATE_CHANNEL", description: "Initiates a public or private channel conversation." },
{ name: "Create Channel Conversation", slug: "SLACK_CREATE_CHANNEL_BASED_CONVERSATION", description: "Creates a new channel with specific org-wide or team settings." },
{ name: "Customize URL Unfurl", slug: "SLACK_CUSTOMIZE_URL_UNFURL", description: "Defines custom content for URL previews in a specific message." },
{ name: "Delete File Comment", slug: "SLACK_DELETE_A_COMMENT_ON_A_FILE", description: "Deletes a specific comment from a file." },
{ name: "Delete File", slug: "SLACK_DELETE_A_FILE_BY_ID", description: "Permanently deletes a file by its ID." },
{ name: "Delete Channel", slug: "SLACK_DELETE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Irreversibly deletes a channel and its history (Enterprise only)." },
{ name: "Delete Scheduled Message", slug: "SLACK_DELETE_A_SCHEDULED_MESSAGE_IN_A_CHAT", description: "Deletes a pending scheduled message." },
{ name: "Delete Reminder", slug: "SLACK_DELETE_A_SLACK_REMINDER", description: "Deletes an existing reminder." },
{ name: "Delete Message", slug: "SLACK_DELETES_A_MESSAGE_FROM_A_CHAT", description: "Deletes a message by channel ID and timestamp." },
{ name: "Delete Profile Photo", slug: "SLACK_DELETE_USER_PROFILE_PHOTO", description: "Reverts the user's profile photo to the default avatar." },
{ name: "Disable User Group", slug: "SLACK_DISABLE_AN_EXISTING_SLACK_USER_GROUP", description: "Disables (archives) a user group." },
{ name: "Enable User Group", slug: "SLACK_ENABLE_A_SPECIFIED_USER_GROUP", description: "Reactivates a disabled user group." },
{ name: "Share File Publicly", slug: "SLACK_ENABLE_PUBLIC_SHARING_OF_A_FILE", description: "Generates a public URL for a file." },
{ name: "End Call", slug: "SLACK_END_A_CALL_WITH_DURATION_AND_ID", description: "Ends an ongoing call." },
{ name: "End Snooze", slug: "SLACK_END_SNOOZE", description: "Ends the current user's snooze mode immediately." },
{ name: "End DND Session", slug: "SLACK_END_USER_DO_NOT_DISTURB_SESSION", description: "Ends the current DND session." },
{ name: "Fetch Bot Info", slug: "SLACK_FETCH_BOT_USER_INFORMATION", description: "Fetches metadata for a specific bot user." },
{ name: "Fetch History", slug: "SLACK_FETCH_CONVERSATION_HISTORY", description: "Fetches chronological messages and events from a channel." },
{ name: "Fetch Item Reactions", slug: "SLACK_FETCH_ITEM_REACTIONS", description: "Fetches all reactions for a message, file, or comment." },
{ name: "Retrieve Replies", slug: "SLACK_FETCH_MESSAGE_THREAD_FROM_A_CONVERSATION", description: "Retrieves replies to a specific parent message." },
{ name: "Fetch Team Info", slug: "SLACK_FETCH_TEAM_INFO", description: "Fetches comprehensive metadata about the team." },
{ name: "Fetch Workspace Settings", slug: "SLACK_FETCH_WORKSPACE_SETTINGS_INFORMATION", description: "Retrieves detailed settings for a specific workspace." },
{ name: "Find Channels", slug: "SLACK_FIND_CHANNELS", description: "Searches channels by name, topic, or purpose." },
{ name: "Find User by Email", slug: "SLACK_FIND_USER_BY_EMAIL_ADDRESS", description: "Finds a user object using their email address." },
{ name: "Find Users", slug: "SLACK_FIND_USERS", description: "Searches users by name, email, or display name." },
{ name: "Get Conversation Preferences", slug: "SLACK_GET_CHANNEL_CONVERSATION_PREFERENCES", description: "Retrieves posting/threading preferences for a channel." },
{ name: "Get Reminder Info", slug: "SLACK_GET_REMINDER_INFORMATION", description: "Retrieves detailed information for a specific reminder." },
{ name: "Get Remote File", slug: "SLACK_GET_REMOTE_FILE", description: "Retrieves info about a previously added remote file." },
{ name: "Get Team DND Status", slug: "SLACK_GET_TEAM_DND_STATUS", description: "Retrieves the DND status for specific users." },
{ name: "Get User Presence", slug: "SLACK_GET_USER_PRESENCE_INFO", description: "Retrieves real-time presence (active/away)." },
{ name: "Invite to Channel", slug: "SLACK_INVITE_USERS_TO_A_SLACK_CHANNEL", description: "Invites users to a channel by their user IDs." },
{ name: "Invite to Workspace", slug: "SLACK_INVITE_USER_TO_WORKSPACE", description: "Invites a user to a workspace and channels via email." },
{ name: "Join Conversation", slug: "SLACK_JOIN_AN_EXISTING_CONVERSATION", description: "Joins a conversation by channel ID." },
{ name: "Leave Conversation", slug: "SLACK_LEAVE_A_CONVERSATION", description: "Leaves a conversation." },
{ name: "List All Channels", slug: "SLACK_LIST_ALL_CHANNELS", description: "Lists all conversations with various filters." },
{ name: "List All Users", slug: "SLACK_LIST_ALL_USERS", description: "Retrieves a paginated list of all users in the workspace." },
{ name: "List User Group Members", slug: "SLACK_LIST_ALL_USERS_IN_A_USER_GROUP", description: "Lists all user IDs within a group." },
{ name: "List Conversations", slug: "SLACK_LIST_CONVERSATIONS", description: "Retrieves conversations accessible to a specific user." },
{ name: "List Files", slug: "SLACK_LIST_FILES_WITH_FILTERS_IN_SLACK", description: "Lists files and metadata with filtering options." },
{ name: "List Reminders", slug: "SLACK_LIST_REMINDERS", description: "Lists all reminders for the authenticated user." },
{ name: "List Remote Files", slug: "SLACK_LIST_REMOTE_FILES", description: "Retrieves info about a team's remote files." },
{ name: "List Scheduled Messages", slug: "SLACK_LIST_SCHEDULED_MESSAGES", description: "Lists pending scheduled messages." },
{ name: "List Pinned Items", slug: "SLACK_LISTS_PINNED_ITEMS_IN_A_CHANNEL", description: "Retrieves all messages/files pinned to a channel." },
{ name: "List Starred Items", slug: "SLACK_LIST_STARRED_ITEMS", description: "Lists items starred by the user." },
{ name: "List Custom Emojis", slug: "SLACK_LIST_TEAM_CUSTOM_EMOJIS", description: "Lists all workspace custom emojis and their URLs." },
{ name: "List User Groups", slug: "SLACK_LIST_USER_GROUPS_FOR_TEAM_WITH_OPTIONS", description: "Lists user-created and default user groups." },
{ name: "List User Reactions", slug: "SLACK_LIST_USER_REACTIONS", description: "Lists all reactions added by a specific user." },
{ name: "List Admin Users", slug: "SLACK_LIST_WORKSPACE_USERS", description: "Retrieves a paginated list of workspace administrators." },
{ name: "Set User Presence", slug: "SLACK_MANUALLY_SET_USER_PRESENCE", description: "Manually overrides automated presence status." },
{ name: "Mark Reminder Complete", slug: "SLACK_MARK_REMINDER_AS_COMPLETE", description: "Marks a reminder as complete (deprecated by Slack in March 2023)." },
{ name: "Open DM", slug: "SLACK_OPEN_DM", description: "Opens/resumes a DM or MPDM." },
{ name: "Pin Item", slug: "SLACK_PINS_AN_ITEM_TO_A_CHANNEL", description: "Pins a message to a channel." },
{ name: "Remove Remote File", slug: "SLACK_REMOVE_A_REMOTE_FILE", description: "Removes a reference to an external file." },
{ name: "Remove Star", slug: "SLACK_REMOVE_A_STAR_FROM_AN_ITEM", description: "Unstars an item." },
{ name: "Remove from Channel", slug: "SLACK_REMOVE_A_USER_FROM_A_CONVERSATION", description: "Removes a specified user from a conversation." },
{ name: "Remove Call Participants", slug: "SLACK_REMOVE_CALL_PARTICIPANTS", description: "Registers the removal of participants from a call." },
{ name: "Remove Reaction", slug: "SLACK_REMOVE_REACTION_FROM_ITEM", description: "Removes an emoji reaction from an item." },
{ name: "Rename Conversation", slug: "SLACK_RENAME_A_CONVERSATION", description: "Renames a channel ID/Conversation." },
{ name: "Rename Emoji", slug: "SLACK_RENAME_AN_EMOJI", description: "Renames a custom emoji." },
{ name: "Rename Channel", slug: "SLACK_RENAME_A_SLACK_CHANNEL", description: "Renames a public or private channel." },
{ name: "Retrieve Identity", slug: "SLACK_RETRIEVE_A_USER_S_IDENTITY_DETAILS", description: "Retrieves basic user/team identity details." },
{ name: "Retrieve Call Info", slug: "SLACK_RETRIEVE_CALL_INFORMATION", description: "Retrieves a snapshot of a call's status." },
{ name: "Retrieve Conversation Info", slug: "SLACK_RETRIEVE_CONVERSATION_INFORMATION", description: "Retrieves metadata for a specific conversation." },
{ name: "Get Conversation Members", slug: "SLACK_RETRIEVE_CONVERSATION_MEMBERS_LIST", description: "Lists active user IDs in a conversation." },
{ name: "Retrieve User DND", slug: "SLACK_RETRIEVE_CURRENT_USER_DND_STATUS", description: "Retrieves DND status for a user." },
{ name: "Retrieve File Details", slug: "SLACK_RETRIEVE_DETAILED_INFORMATION_ABOUT_A_FILE", description: "Retrieves metadata and comments for a file." },
{ name: "Retrieve User Details", slug: "SLACK_RETRIEVE_DETAILED_USER_INFORMATION", description: "Retrieves comprehensive info for a specific user ID." },
{ name: "Get Message Permalink", slug: "SLACK_RETRIEVE_MESSAGE_PERMALINK_URL", description: "Gets the permalink URL for a specific message." },
{ name: "Retrieve Team Profile", slug: "SLACK_RETRIEVE_TEAM_PROFILE_DETAILS", description: "Retrieves the profile field structure for a team." },
{ name: "Retrieve User Profile", slug: "SLACK_RETRIEVE_USER_PROFILE_INFORMATION", description: "Retrieves specific profile info for a user." },
{ name: "Revoke Public File", slug: "SLACK_REVOKE_PUBLIC_SHARING_ACCESS_FOR_A_FILE", description: "Revokes a file's public sharing URL." },
{ name: "Schedule Message", slug: "SLACK_SCHEDULE_MESSAGE", description: "Schedules a message for a future time (up to 120 days)." },
{ name: "Search Messages", slug: "SLACK_SEARCH_MESSAGES", description: "Workspace-wide message search with advanced filters." },
{ name: "Send Ephemeral", slug: "SLACK_SEND_EPHEMERAL_MESSAGE", description: "Sends a message visible only to a specific user." },
{ name: "Send Message", slug: "SLACK_SEND_MESSAGE", description: "Posts a message to a channel, DM, or group." },
{ name: "Set Conversation Purpose", slug: "SLACK_SET_A_CONVERSATION_S_PURPOSE", description: "Updates the purpose description of a channel." },
{ name: "Set DND Duration", slug: "SLACK_SET_DND_DURATION", description: "Turns on DND or changes its current duration." },
{ name: "Set Profile Photo", slug: "SLACK_SET_PROFILE_PHOTO", description: "Sets the user's profile image with cropping." },
{ name: "Set Read Cursor", slug: "SLACK_SET_READ_CURSOR_IN_A_CONVERSATION", description: "Marks a specific timestamp as read." },
{ name: "Set User Profile", slug: "SLACK_SET_SLACK_USER_PROFILE_INFORMATION", description: "Updates individual or multiple user profile fields." },
{ name: "Set Conversation Topic", slug: "SLACK_SET_THE_TOPIC_OF_A_CONVERSATION", description: "Updates the topic of a conversation." },
{ name: "Share Me Message", slug: "SLACK_SHARE_A_ME_MESSAGE_IN_A_CHANNEL", description: "Sends a third-person user action message (/me)." },
{ name: "Share Remote File", slug: "SLACK_SHARE_REMOTE_FILE_IN_CHANNELS", description: "Shares a registered remote file into channels." },
{ name: "Start Call", slug: "SLACK_START_CALL", description: "Registers a new call for third-party integration." },
{ name: "Start RTM Session", slug: "SLACK_START_REAL_TIME_MESSAGING_SESSION", description: "Initiates a real-time messaging WebSocket session." },
{ name: "Unarchive Channel", slug: "SLACK_UNARCHIVE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Unarchives a specific channel." },
{ name: "Unarchive Conversation", slug: "SLACK_UNARCHIVE_CHANNEL", description: "Reverses archival for a conversation." },
{ name: "Unpin Item", slug: "SLACK_UNPIN_ITEM_FROM_CHANNEL", description: "Unpins a message from a channel." },
{ name: "Update User Group", slug: "SLACK_UPDATE_AN_EXISTING_SLACK_USER_GROUP", description: "Updates name, handle, or channels for a user group." },
{ name: "Update Remote File", slug: "SLACK_UPDATES_AN_EXISTING_REMOTE_FILE", description: "Updates metadata for a remote file reference." },
{ name: "Update Message", slug: "SLACK_UPDATES_A_SLACK_MESSAGE", description: "Modifies the content of an existing message." },
{ name: "Update Call Info", slug: "SLACK_UPDATE_SLACK_CALL_INFORMATION", description: "Updates call title or join URLs." },
{ name: "Update Group Members", slug: "SLACK_UPDATE_USER_GROUP_MEMBERS", description: "Replaces the member list of a user group." },
{ name: "Upload File", slug: "SLACK_UPLOAD_OR_CREATE_A_FILE_IN_SLACK", description: "Uploads content or binary files to Slack." },
];
export const slackToolCatalogMarkdown = slackToolCatalog
.map((tool) => `- ${tool.name} (${tool.slug}) - ${tool.description}`)
.join("\n");

View file

@ -13,12 +13,16 @@ import * as workspace from "../../workspace/workspace.js";
import { IAgentsRepo } from "../../agents/repo.js";
import { WorkDir } from "../../config/config.js";
import { composioAccountsRepo } from "../../composio/repo.js";
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, listToolkitTools } from "../../composio/client.js";
import { slackToolCatalog } from "../assistant/skills/slack/tool-catalog.js";
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js";
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
import type { ToolContext } from "./exec-tool.js";
import { generateText } from "ai";
import { createProvider } from "../../models/models.js";
import { IModelConfigRepo } from "../../models/repo.js";
import { isSignedIn } from "../../account/account.js";
import { getGatewayProvider } from "../../models/gateway.js";
import { getAccessToken } from "../../auth/tokens.js";
import { API_URL } from "../../config/env.js";
// Parser libraries are loaded dynamically inside parseFile.execute()
// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle.
// Import paths are computed so esbuild cannot statically resolve them.
@ -36,232 +40,6 @@ const BuiltinToolsSchema = z.record(z.string(), z.object({
isAvailable: z.custom<() => Promise<boolean>>().optional(),
}));
type SlackToolHint = {
search?: string;
patterns: string[];
fallbackSlugs?: string[];
preferSlugIncludes?: string[];
excludePatterns?: string[];
minScore?: number;
};
const slackToolHints: Record<string, SlackToolHint> = {
sendMessage: {
search: "message",
patterns: ["send", "message", "channel"],
fallbackSlugs: [
"SLACK_SEND_MESSAGE",
"SLACK_SENDS_A_MESSAGE_TO_A_SLACK_CHANNEL",
"SLACK_SEND_A_MESSAGE",
],
},
listConversations: {
search: "conversation",
patterns: ["list", "conversation", "channel"],
fallbackSlugs: [
"SLACK_LIST_CONVERSATIONS",
"SLACK_LIST_ALL_CHANNELS",
"SLACK_LIST_ALL_SLACK_TEAM_CHANNELS_WITH_VARIOUS_FILTERS",
"SLACK_LIST_CHANNELS",
"SLACK_LIST_CHANNEL",
],
preferSlugIncludes: ["list", "conversation"],
minScore: 2,
},
getConversationHistory: {
search: "history",
patterns: ["history", "conversation", "message"],
fallbackSlugs: [
"SLACK_FETCH_CONVERSATION_HISTORY",
"SLACK_FETCHES_CONVERSATION_HISTORY",
"SLACK_GET_CONVERSATION_HISTORY",
"SLACK_GET_CHANNEL_HISTORY",
],
preferSlugIncludes: ["history"],
minScore: 2,
},
listUsers: {
search: "user",
patterns: ["list", "user"],
fallbackSlugs: [
"SLACK_LIST_ALL_USERS",
"SLACK_LIST_ALL_SLACK_TEAM_USERS_WITH_PAGINATION",
"SLACK_LIST_USERS",
"SLACK_GET_USERS",
"SLACK_USERS_LIST",
],
preferSlugIncludes: ["list", "user"],
excludePatterns: ["find", "by name", "by email", "by_email", "by_name", "lookup", "profile", "info"],
minScore: 2,
},
getUserInfo: {
search: "user",
patterns: ["user", "info", "profile"],
fallbackSlugs: [
"SLACK_GET_USER_INFO",
"SLACK_GET_USER",
"SLACK_USER_INFO",
],
preferSlugIncludes: ["user", "info"],
minScore: 1,
},
searchMessages: {
search: "search",
patterns: ["search", "message"],
fallbackSlugs: [
"SLACK_SEARCH_FOR_MESSAGES_WITH_QUERY",
"SLACK_SEARCH_MESSAGES",
"SLACK_SEARCH_MESSAGE",
],
preferSlugIncludes: ["search"],
minScore: 1,
},
};
const slackToolSlugCache = new Map<string, string>();
const slackToolSlugOverrides: Partial<Record<keyof typeof slackToolHints, string>> = {
sendMessage: "SLACK_SEND_MESSAGE",
listConversations: "SLACK_LIST_CONVERSATIONS",
getConversationHistory: "SLACK_FETCH_CONVERSATION_HISTORY",
listUsers: "SLACK_LIST_ALL_USERS",
getUserInfo: "SLACK_RETRIEVE_DETAILED_USER_INFORMATION",
searchMessages: "SLACK_SEARCH_MESSAGES",
};
const compactObject = (input: Record<string, unknown>) =>
Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
type SlackToolResult = { success: boolean; data?: unknown; error?: string };
/** Helper to execute a Slack tool with consistent account validation and error handling */
async function executeSlackTool(
hintKey: keyof typeof slackToolHints,
params: Record<string, unknown>
): Promise<SlackToolResult> {
const account = composioAccountsRepo.getAccount('slack');
if (!account || account.status !== 'ACTIVE') {
return { success: false, error: 'Slack is not connected' };
}
try {
const toolSlug = await resolveSlackToolSlug(hintKey);
return await executeComposioAction(toolSlug, account.id, compactObject(params));
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
const normalizeSlackTool = (tool: { slug: string; name?: string; description?: string }) =>
`${tool.slug} ${tool.name || ""} ${tool.description || ""}`.toLowerCase();
const scoreSlackTool = (tool: { slug: string; name?: string; description?: string }, patterns: string[]) => {
const slug = tool.slug.toLowerCase();
const name = (tool.name || "").toLowerCase();
const description = (tool.description || "").toLowerCase();
let score = 0;
for (const pattern of patterns) {
const needle = pattern.toLowerCase();
if (slug.includes(needle)) score += 3;
if (name.includes(needle)) score += 2;
if (description.includes(needle)) score += 1;
}
return score;
};
const pickSlackTool = (
tools: Array<{ slug: string; name?: string; description?: string }>,
hint: SlackToolHint,
) => {
let candidates = tools;
if (hint.excludePatterns && hint.excludePatterns.length > 0) {
candidates = candidates.filter((tool) => {
const haystack = normalizeSlackTool(tool);
return !hint.excludePatterns!.some((pattern) => haystack.includes(pattern.toLowerCase()));
});
}
if (hint.preferSlugIncludes && hint.preferSlugIncludes.length > 0) {
const preferred = candidates.filter((tool) =>
hint.preferSlugIncludes!.every((pattern) => tool.slug.toLowerCase().includes(pattern.toLowerCase()))
);
if (preferred.length > 0) {
candidates = preferred;
}
}
let best: { slug: string; name?: string; description?: string } | null = null;
let bestScore = 0;
for (const tool of candidates) {
const score = scoreSlackTool(tool, hint.patterns);
if (score > bestScore) {
bestScore = score;
best = tool;
}
}
if (!best || (hint.minScore !== undefined && bestScore < hint.minScore)) {
return null;
}
return best;
};
const resolveSlackToolSlug = async (hintKey: keyof typeof slackToolHints) => {
const cached = slackToolSlugCache.get(hintKey);
if (cached) return cached;
const hint = slackToolHints[hintKey];
const override = slackToolSlugOverrides[hintKey];
if (override && slackToolCatalog.some((tool) => tool.slug === override)) {
slackToolSlugCache.set(hintKey, override);
return override;
}
const resolveFromTools = (tools: Array<{ slug: string; name?: string; description?: string }>) => {
if (hint.fallbackSlugs && hint.fallbackSlugs.length > 0) {
const fallbackSet = new Set(hint.fallbackSlugs.map((slug) => slug.toLowerCase()));
const fallback = tools.find((tool) => fallbackSet.has(tool.slug.toLowerCase()));
if (fallback) return fallback.slug;
}
const best = pickSlackTool(tools, hint);
return best?.slug || null;
};
const initialTools = slackToolCatalog;
if (!initialTools.length) {
throw new Error("No Slack tools returned from Composio");
}
const initialSlug = resolveFromTools(initialTools);
if (initialSlug) {
slackToolSlugCache.set(hintKey, initialSlug);
return initialSlug;
}
const allSlug = resolveFromTools(slackToolCatalog);
if (!allSlug) {
const fallback = await listToolkitTools("slack", hint.search || null);
const fallbackSlug = resolveFromTools(fallback.items || []);
if (!fallbackSlug) {
throw new Error(`Unable to resolve Slack tool for ${hintKey}. Try slack-listAvailableTools.`);
}
slackToolSlugCache.set(hintKey, fallbackSlug);
return fallbackSlug;
}
slackToolSlugCache.set(hintKey, allSlug);
return allSlug;
};
const LLMPARSE_MIME_TYPES: Record<string, string> = {
'.pdf': 'application/pdf',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
@ -861,7 +639,9 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
// Resolve model config from DI container
const modelConfigRepo = container.resolve<IModelConfigRepo>('modelConfigRepo');
const modelConfig = await modelConfigRepo.getConfig();
const provider = createProvider(modelConfig.provider);
const provider = await isSignedIn()
? await getGatewayProvider()
: createProvider(modelConfig.provider);
const model = provider.languageModel(modelConfig.model);
const userPrompt = prompt || 'Convert this file to well-structured markdown.';
@ -1110,276 +890,159 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
// ============================================================================
// Slack Tools (via Composio)
// App Navigation
// ============================================================================
'slack-checkConnection': {
description: 'Check if Slack is connected and ready to use. Use this before other Slack operations.',
inputSchema: z.object({}),
execute: async () => {
if (!isComposioConfigured()) {
return {
connected: false,
error: 'Composio is not configured. Please set up your Composio API key first.',
};
'app-navigation': {
description: 'Control the app UI - navigate to notes, switch views, filter/search the knowledge base, and manage saved views.',
inputSchema: z.object({
action: z.enum(["open-note", "open-view", "update-base-view", "get-base-state", "create-base"]).describe("The navigation action to perform"),
// open-note
path: z.string().optional().describe("Knowledge file path for open-note, e.g. knowledge/People/John.md"),
// open-view
view: z.enum(["bases", "graph"]).optional().describe("Which view to open (for open-view action)"),
// update-base-view
filters: z.object({
set: z.array(z.object({ category: z.string(), value: z.string() })).optional().describe("Replace all filters with these"),
add: z.array(z.object({ category: z.string(), value: z.string() })).optional().describe("Add these filters"),
remove: z.array(z.object({ category: z.string(), value: z.string() })).optional().describe("Remove these filters"),
clear: z.boolean().optional().describe("Clear all filters"),
}).optional().describe("Filter modifications (for update-base-view)"),
columns: z.object({
set: z.array(z.string()).optional().describe("Replace visible columns with these"),
add: z.array(z.string()).optional().describe("Add these columns"),
remove: z.array(z.string()).optional().describe("Remove these columns"),
}).optional().describe("Column modifications (for update-base-view)"),
sort: z.object({
field: z.string(),
dir: z.enum(["asc", "desc"]),
}).optional().describe("Sort configuration (for update-base-view)"),
search: z.string().optional().describe("Search query to filter notes (for update-base-view)"),
// get-base-state
base_name: z.string().optional().describe("Name of a saved base to inspect (for get-base-state). Omit for the current/default view."),
// create-base
name: z.string().optional().describe("Name for the saved base view (for create-base)"),
}),
execute: async (input: {
action: string;
[key: string]: unknown;
}) => {
switch (input.action) {
case 'open-note': {
const filePath = input.path as string;
try {
const result = await workspace.exists(filePath);
if (!result.exists) {
return { success: false, error: `File not found: ${filePath}` };
}
return { success: true, action: 'open-note', path: filePath };
} catch {
return { success: false, error: `Could not access file: ${filePath}` };
}
}
case 'open-view': {
const view = input.view as string;
return { success: true, action: 'open-view', view };
}
case 'update-base-view': {
const updates: Record<string, unknown> = {};
if (input.filters) updates.filters = input.filters;
if (input.columns) updates.columns = input.columns;
if (input.sort) updates.sort = input.sort;
if (input.search !== undefined) updates.search = input.search;
return { success: true, action: 'update-base-view', updates };
}
case 'get-base-state': {
// Scan knowledge/ files and extract frontmatter properties
try {
const { parseFrontmatter } = await import("@x/shared/dist/frontmatter.js");
const entries = await workspace.readdir("knowledge", { recursive: true, allowedExtensions: [".md"] });
const files = entries.filter(e => e.kind === 'file');
const properties = new Map<string, Set<string>>();
let noteCount = 0;
for (const file of files) {
try {
const { data } = await workspace.readFile(file.path);
const { fields } = parseFrontmatter(data);
noteCount++;
for (const [key, value] of Object.entries(fields)) {
if (!value) continue;
let set = properties.get(key);
if (!set) { set = new Set(); properties.set(key, set); }
const values = Array.isArray(value) ? value : [value];
for (const v of values) {
const trimmed = v.trim();
if (trimmed) set.add(trimmed);
}
}
} catch {
// skip unreadable files
}
}
const availableProperties: Record<string, string[]> = {};
for (const [key, values] of properties) {
availableProperties[key] = [...values].sort();
}
return {
success: true,
action: 'get-base-state',
noteCount,
availableProperties,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to read knowledge base',
};
}
}
case 'create-base': {
const name = input.name as string;
const safeName = name.replace(/[^a-zA-Z0-9_\- ]/g, '').trim();
if (!safeName) {
return { success: false, error: 'Invalid base name' };
}
const basePath = `bases/${safeName}.base`;
try {
const config = { name: safeName, filters: [], columns: [] };
await workspace.writeFile(basePath, JSON.stringify(config, null, 2), { mkdirp: true });
return { success: true, action: 'create-base', name: safeName, path: basePath };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create base',
};
}
}
default:
return { success: false, error: `Unknown action: ${input.action}` };
}
const account = composioAccountsRepo.getAccount('slack');
if (!account || account.status !== 'ACTIVE') {
return {
connected: false,
error: 'Slack is not connected. Please connect Slack from the settings.',
};
}
return {
connected: true,
accountId: account.id,
};
},
},
'slack-listAvailableTools': {
description: 'List available Slack tools from Composio. Use this to discover the correct tool slugs before executing actions. Call this first if other Slack tools return errors.',
inputSchema: z.object({
search: z.string().optional().describe('Optional search query to filter tools (e.g., "message", "channel", "user")'),
}),
execute: async ({ search }: { search?: string }) => {
if (!isComposioConfigured()) {
return { success: false, error: 'Composio is not configured' };
}
try {
const result = await listToolkitTools('slack', search || null);
return {
success: true,
tools: result.items,
count: result.items.length,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
},
'slack-executeAction': {
description: 'Execute a Slack action by its Composio tool slug. Use slack-listAvailableTools first to discover correct slugs. Pass the exact slug and the required input parameters.',
inputSchema: z.object({
toolSlug: z.string().describe('The exact Composio tool slug (e.g., "SLACKBOT_SEND_A_MESSAGE_TO_A_SLACK_CHANNEL")'),
input: z.record(z.string(), z.unknown()).describe('Input parameters for the tool (check the tool description for required fields)'),
}),
execute: async ({ toolSlug, input }: { toolSlug: string; input: Record<string, unknown> }) => {
const account = composioAccountsRepo.getAccount('slack');
if (!account || account.status !== 'ACTIVE') {
return { success: false, error: 'Slack is not connected' };
}
try {
const result = await executeComposioAction(toolSlug, account.id, input);
return result;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
},
'slack-sendMessage': {
description: 'Send a message to a Slack channel or user. Requires channel ID (starts with C for channels, D for DMs) or user ID.',
inputSchema: z.object({
channel: z.string().describe('Channel ID (e.g., C01234567) or user ID (e.g., U01234567) to send the message to'),
text: z.string().describe('The message text to send'),
}),
execute: async ({ channel, text }: { channel: string; text: string }) => {
return executeSlackTool("sendMessage", { channel, text });
},
},
'slack-listChannels': {
description: 'List Slack channels the user has access to. Returns channel IDs and names.',
inputSchema: z.object({
types: z.string().optional().describe('Comma-separated channel types: public_channel, private_channel, mpim, im (default: public_channel,private_channel)'),
limit: z.number().optional().describe('Maximum number of channels to return (default: 100)'),
}),
execute: async ({ types, limit }: { types?: string; limit?: number }) => {
return executeSlackTool("listConversations", {
types: types || "public_channel,private_channel",
limit: limit ?? 100,
});
},
},
'slack-getChannelHistory': {
description: 'Get recent messages from a Slack channel. Returns message history with timestamps and user IDs.',
inputSchema: z.object({
channel: z.string().describe('Channel ID to get history from (e.g., C01234567)'),
limit: z.number().optional().describe('Maximum number of messages to return (default: 20, max: 100)'),
}),
execute: async ({ channel, limit }: { channel: string; limit?: number }) => {
return executeSlackTool("getConversationHistory", {
channel,
limit: limit !== undefined ? Math.min(limit, 100) : 20,
});
},
},
'slack-listUsers': {
description: 'List users in the Slack workspace. Returns user IDs, names, and profile info.',
inputSchema: z.object({
limit: z.number().optional().describe('Maximum number of users to return (default: 100)'),
}),
execute: async ({ limit }: { limit?: number }) => {
return executeSlackTool("listUsers", { limit: limit ?? 100 });
},
},
'slack-getUserInfo': {
description: 'Get detailed information about a specific Slack user by their user ID.',
inputSchema: z.object({
user: z.string().describe('User ID to get info for (e.g., U01234567)'),
}),
execute: async ({ user }: { user: string }) => {
return executeSlackTool("getUserInfo", { user });
},
},
'slack-searchMessages': {
description: 'Search for messages in Slack. Find messages containing specific text across channels.',
inputSchema: z.object({
query: z.string().describe('Search query text'),
count: z.number().optional().describe('Maximum number of results (default: 20)'),
}),
execute: async ({ query, count }: { query: string; count?: number }) => {
return executeSlackTool("searchMessages", { query, count: count ?? 20 });
},
},
'slack-getDirectMessages': {
description: 'List direct message (DM) channels. Returns IDs of DM conversations with other users.',
inputSchema: z.object({
limit: z.number().optional().describe('Maximum number of DM channels to return (default: 50)'),
}),
execute: async ({ limit }: { limit?: number }) => {
return executeSlackTool("listConversations", { types: "im", limit: limit ?? 50 });
},
},
// ============================================================================
// Web Search (Brave Search API)
// Web Search (Exa Search API)
// ============================================================================
'web-search': {
description: 'Search the web using Brave Search. Returns web results with titles, URLs, and descriptions.',
inputSchema: z.object({
query: z.string().describe('The search query'),
count: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
freshness: z.string().optional().describe('Filter by freshness: pd (past day), pw (past week), pm (past month), py (past year)'),
}),
isAvailable: async () => {
try {
const homedir = process.env.HOME || process.env.USERPROFILE || '';
const braveConfigPath = path.join(homedir, '.rowboat', 'config', 'brave-search.json');
const raw = await fs.readFile(braveConfigPath, 'utf8');
const config = JSON.parse(raw);
return !!config.apiKey;
} catch {
return false;
}
},
execute: async ({ query, count, freshness }: { query: string; count?: number; freshness?: string }) => {
try {
// Read API key from config
const homedir = process.env.HOME || process.env.USERPROFILE || '';
const braveConfigPath = path.join(homedir, '.rowboat', 'config', 'brave-search.json');
let apiKey: string;
try {
const raw = await fs.readFile(braveConfigPath, 'utf8');
const config = JSON.parse(raw);
apiKey = config.apiKey;
} catch {
return {
success: false,
error: 'Brave Search API key not configured. Create ~/.rowboat/config/brave-search.json with { "apiKey": "<your-key>" }',
};
}
if (!apiKey) {
return {
success: false,
error: 'Brave Search API key is empty. Set "apiKey" in ~/.rowboat/config/brave-search.json',
};
}
// Build query params
const resultCount = Math.min(Math.max(count || 5, 1), 20);
const params = new URLSearchParams({
q: query,
count: String(resultCount),
});
if (freshness) {
params.set('freshness', freshness);
}
const url = `https://api.search.brave.com/res/v1/web/search?${params.toString()}`;
const response = await fetch(url, {
headers: {
'X-Subscription-Token': apiKey,
'Accept': 'application/json',
},
});
if (!response.ok) {
const body = await response.text();
return {
success: false,
error: `Brave Search API error (${response.status}): ${body}`,
};
}
const data = await response.json() as {
web?: { results?: Array<{ title?: string; url?: string; description?: string }> };
};
const results = (data.web?.results || []).map((r: { title?: string; url?: string; description?: string }) => ({
title: r.title || '',
url: r.url || '',
description: r.description || '',
}));
return {
success: true,
query,
results,
count: results.length,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
},
// ============================================================================
// Research Search (Exa Search API)
// ============================================================================
'research-search': {
description: 'Use this for finding articles, blog posts, papers, companies, people, or exploring a topic in depth. Best for discovery and research where you need quality sources, not a quick fact.',
description: 'Search the web for articles, blog posts, papers, companies, people, news, or explore a topic in depth. Returns rich results with full text, highlights, and metadata.',
inputSchema: z.object({
query: z.string().describe('The search query'),
numResults: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
category: z.enum(['company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Filter results by category'),
category: z.enum(['general', 'company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Search category. Defaults to "general" which searches the entire web. Only use a specific category when the query is clearly about that type (e.g. "research paper" for academic papers, "company" for company info). For everyday queries like weather, restaurants, prices, how-to, etc., use "general" or omit entirely.'),
}),
isAvailable: async () => {
if (await isSignedIn()) return true;
try {
const homedir = process.env.HOME || process.env.USERPROFILE || '';
const exaConfigPath = path.join(homedir, '.rowboat', 'config', 'exa-search.json');
const exaConfigPath = path.join(WorkDir, 'config', 'exa-search.json');
const raw = await fs.readFile(exaConfigPath, 'utf8');
const config = JSON.parse(raw);
return !!config.apiKey;
@ -1389,31 +1052,9 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
execute: async ({ query, numResults, category }: { query: string; numResults?: number; category?: string }) => {
try {
const homedir = process.env.HOME || process.env.USERPROFILE || '';
const exaConfigPath = path.join(homedir, '.rowboat', 'config', 'exa-search.json');
let apiKey: string;
try {
const raw = await fs.readFile(exaConfigPath, 'utf8');
const config = JSON.parse(raw);
apiKey = config.apiKey;
} catch {
return {
success: false,
error: 'Exa Search API key not configured. Create ~/.rowboat/config/exa-search.json with { "apiKey": "<your-key>" }',
};
}
if (!apiKey) {
return {
success: false,
error: 'Exa Search API key is empty. Set "apiKey" in ~/.rowboat/config/exa-search.json',
};
}
const resultCount = Math.min(Math.max(numResults || 5, 1), 20);
const body: Record<string, unknown> = {
const reqBody: Record<string, unknown> = {
query,
numResults: resultCount,
type: 'auto',
@ -1422,18 +1063,55 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
highlights: true,
},
};
if (category) {
body.category = category;
if (category && category !== 'general') {
reqBody.category = category;
}
const response = await fetch('https://api.exa.ai/search', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
let response: Response;
if (await isSignedIn()) {
// Use proxy
const accessToken = await getAccessToken();
response = await fetch(`${API_URL}/v1/search/exa`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(reqBody),
});
} else {
// Read API key from config
const exaConfigPath = path.join(WorkDir, 'config', 'exa-search.json');
let apiKey: string;
try {
const raw = await fs.readFile(exaConfigPath, 'utf8');
const config = JSON.parse(raw);
apiKey = config.apiKey;
} catch {
return {
success: false,
error: 'Exa Search API key not configured. Create ~/.rowboat/config/exa-search.json with { "apiKey": "<your-key>" }',
};
}
if (!apiKey) {
return {
success: false,
error: 'Exa Search API key is empty. Set "apiKey" in ~/.rowboat/config/exa-search.json',
};
}
response = await fetch('https://api.exa.ai/search', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(reqBody),
});
}
if (!response.ok) {
const text = await response.text();
@ -1477,4 +1155,173 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}
},
},
'save-to-memory': {
description: "Save a note about the user to the agent memory inbox. Use this when you observe something worth remembering — their preferences, communication patterns, relationship context, scheduling habits, or explicit instructions about how they want things done.",
inputSchema: z.object({
note: z.string().describe("The observation or preference to remember. Be specific and concise."),
}),
execute: async ({ note }: { note: string }) => {
const inboxPath = path.join(WorkDir, 'knowledge', 'Agent Notes', 'inbox.md');
const dir = path.dirname(inboxPath);
await fs.mkdir(dir, { recursive: true });
const timestamp = new Date().toISOString();
const entry = `\n- [${timestamp}] ${note}\n`;
await fs.appendFile(inboxPath, entry, 'utf-8');
return {
success: true,
message: `Saved to memory: ${note}`,
};
},
},
// ========================================================================
// Composio Meta-Tools
// ========================================================================
'composio-list-toolkits': {
description: 'List available Composio integrations (Gmail, Slack, GitHub, etc.) and their connection status. Use this to show the user what services they can connect to.',
inputSchema: z.object({
category: z.enum(['all', 'communication', 'productivity', 'development', 'crm', 'social', 'storage', 'support']).optional()
.describe('Filter by category. Defaults to "all".'),
}),
execute: async ({ category }: { category?: string }) => {
const toolkits = CURATED_TOOLKITS
.filter(t => !category || category === 'all' || t.category === category)
.map(t => ({
slug: t.slug,
name: t.displayName,
category: t.category,
isConnected: composioAccountsRepo.isConnected(t.slug),
}));
const connectedCount = toolkits.filter(t => t.isConnected).length;
return {
toolkits,
connectedCount,
totalCount: toolkits.length,
};
},
isAvailable: async () => isComposioConfigured(),
},
'composio-search-tools': {
description: 'Search for Composio tools by use case across connected services. Returns tool slugs, descriptions, and input schemas so you can call composio-execute-tool with the right parameters. Example: search "send email" to find Gmail tools, "create issue" to find GitHub/Jira tools.',
inputSchema: z.object({
query: z.string().describe('Natural language description of what you want to do (e.g., "send an email", "create a GitHub issue", "schedule a meeting")'),
toolkitSlug: z.string().optional().describe('Optional: limit search to a specific toolkit (e.g., "gmail", "github")'),
}),
execute: async ({ query, toolkitSlug }: { query: string; toolkitSlug?: string }) => {
try {
const toolkitFilter = toolkitSlug ? [toolkitSlug] : undefined;
const result = await searchComposioTools(query, toolkitFilter);
// Filter to curated toolkits only (skip if a specific toolkit was requested —
// the API already filtered server-side)
const filtered = toolkitSlug
? result.items
: result.items.filter(t => CURATED_TOOLKIT_SLUGS.has(t.toolkitSlug));
// Annotate with connection status
const tools = filtered.map(t => ({
slug: t.slug,
name: t.name,
description: t.description,
toolkitSlug: t.toolkitSlug,
isConnected: composioAccountsRepo.isConnected(t.toolkitSlug),
inputSchema: t.inputParameters,
}));
return {
tools,
resultCount: tools.length,
hint: tools.some(t => !t.isConnected)
? 'Some tools require connecting the toolkit first. Use composio-connect-toolkit to help the user authenticate.'
: undefined,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { tools: [], resultCount: 0, error: message };
}
},
isAvailable: async () => isComposioConfigured(),
},
'composio-execute-tool': {
description: 'Execute a Composio tool by its slug. You MUST pass the arguments field with all required parameters from the search results inputSchema. Example: composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })',
inputSchema: z.object({
toolSlug: z.string().describe('EXACT tool slug from search results (e.g., "GITHUB_ISSUES_LIST_FOR_REPO"). Copy it exactly — do not modify it.'),
toolkitSlug: z.string().describe('The toolkit slug (e.g., "gmail", "github")'),
arguments: z.record(z.string(), z.unknown()).describe('REQUIRED: Tool input parameters as key-value pairs. Get the required fields from the inputSchema returned by composio-search-tools. Never omit this.'),
}),
execute: async ({ toolSlug, toolkitSlug, arguments: args }: { toolSlug: string; toolkitSlug: string; arguments?: Record<string, unknown> }) => {
// Default arguments to {} if the LLM omits the field entirely
const toolArgs = args ?? {};
// Check connection
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (!account || account.status !== 'ACTIVE') {
return {
successful: false,
data: null,
error: `Toolkit "${toolkitSlug}" is not connected. Use composio-connect-toolkit to help the user connect it first.`,
};
}
try {
return await executeComposioAction(toolSlug, {
connected_account_id: account.id,
user_id: 'rowboat-user',
version: 'latest',
arguments: toolArgs,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[Composio] Tool execution failed for ${toolSlug}:`, message);
return {
successful: false,
data: null,
error: `Failed to execute ${toolSlug}: ${message}. If fields are missing, check the inputSchema and retry with the correct arguments.`,
};
}
},
isAvailable: async () => isComposioConfigured(),
},
'composio-connect-toolkit': {
description: 'Connect a Composio service (Gmail, Slack, GitHub, etc.) via OAuth. Shows a connect card for the user to authenticate.',
inputSchema: z.object({
toolkitSlug: z.string().describe('The toolkit slug to connect (e.g., "gmail", "github", "slack", "notion")'),
}),
execute: async ({ toolkitSlug }: { toolkitSlug: string }) => {
// Validate against curated list
if (!CURATED_TOOLKIT_SLUGS.has(toolkitSlug)) {
const available = CURATED_TOOLKITS.map(t => `${t.slug} (${t.displayName})`).join(', ');
return {
success: false,
error: `Unknown toolkit "${toolkitSlug}". Available toolkits: ${available}`,
};
}
// Check if already connected
if (composioAccountsRepo.isConnected(toolkitSlug)) {
return {
success: true,
message: `${toolkitSlug} is already connected. You can search for and execute its tools.`,
alreadyConnected: true,
};
}
// Return signal — the UI renders a ComposioConnectCard with a Connect button.
// OAuth only starts when the user clicks that button.
const toolkit = CURATED_TOOLKITS.find(t => t.slug === toolkitSlug);
return {
success: true,
message: `Please connect ${toolkit?.displayName ?? toolkitSlug} to continue.`,
};
},
isAvailable: async () => isComposioConfigured(),
},
};

View file

@ -4,6 +4,7 @@ import { getSecurityAllowList } from '../../config/security.js';
import { getExecutionShell } from '../assistant/runtime-context.js';
const execPromise = promisify(exec);
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/;
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);

View file

@ -3,14 +3,18 @@ import { UserMessageContent } from "@x/shared/dist/message.js";
import z from "zod";
export type UserMessageContentType = z.infer<typeof UserMessageContent>;
export type VoiceOutputMode = 'summary' | 'full';
type EnqueuedMessage = {
messageId: string;
message: UserMessageContentType;
voiceInput?: boolean;
voiceOutput?: VoiceOutputMode;
searchEnabled?: boolean;
};
export interface IMessageQueue {
enqueue(runId: string, message: UserMessageContentType): Promise<string>;
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise<string>;
dequeue(runId: string): Promise<EnqueuedMessage | null>;
}
@ -26,7 +30,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
this.idGenerator = idGenerator;
}
async enqueue(runId: string, message: UserMessageContentType): Promise<string> {
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise<string> {
if (!this.store[runId]) {
this.store[runId] = [];
}
@ -34,6 +38,9 @@ export class InMemoryMessageQueue implements IMessageQueue {
this.store[runId].push({
messageId: id,
message,
voiceInput,
voiceOutput,
searchEnabled,
});
return id;
}
@ -44,4 +51,4 @@ export class InMemoryMessageQueue implements IMessageQueue {
}
return this.store[runId].shift() ?? null;
}
}
}

View file

@ -46,13 +46,15 @@ export async function discoverConfiguration(
console.log(`[OAuth] Using cached configuration for ${issuerUrl}`);
return cached;
}
console.log(`[OAuth] Discovering authorization server metadata for ${issuerUrl}...`);
const config = await client.discovery(
new URL(issuerUrl),
clientId,
undefined, // no client_secret (PKCE flow)
client.None() // PKCE doesn't require client authentication
client.None(), // PKCE doesn't require client authentication
{
execute: [client.allowInsecureRequests],
}
);
configCache.set(cacheKey, config);
@ -110,7 +112,10 @@ export async function registerClient(
client_name: clientName,
scope: scopes.join(' '),
},
client.None()
client.None(),
{
execute: [client.allowInsecureRequests],
},
);
const metadata = config.clientMetadata();

View file

@ -1,4 +1,5 @@
import { z } from 'zod';
import { getRowboatConfig } from '../config/rowboat.js';
/**
* Discovery configuration - how to get OAuth endpoints
@ -51,6 +52,20 @@ export type ProviderConfigEntry = ProviderConfig[string];
* All configured OAuth providers
*/
const providerConfigs: ProviderConfig = {
rowboat: {
discovery: {
mode: 'issuer',
issuer: "TBD",
},
client: {
mode: 'dcr',
},
scopes: [
"openid",
"email",
"profile",
],
},
google: {
discovery: {
mode: 'issuer',
@ -83,21 +98,21 @@ const providerConfigs: ProviderConfig = {
/**
* Get provider configuration by name
*/
export function getProviderConfig(providerName: string): ProviderConfigEntry {
export async function getProviderConfig(providerName: string): Promise<ProviderConfigEntry> {
const config = providerConfigs[providerName];
if (!config) {
throw new Error(`Unknown OAuth provider: ${providerName}`);
}
if (providerName === 'rowboat') {
const rowboatConfig = await getRowboatConfig();
config.discovery = {
mode: 'issuer',
issuer: `${rowboatConfig.supabaseUrl}/auth/v1/.well-known/oauth-authorization-server`,
}
}
return config;
}
/**
* Get all provider configurations
*/
export function getAllProviderConfigs(): ProviderConfig {
return providerConfigs;
}
/**
* Get list of all configured OAuth providers
*/

View file

@ -0,0 +1,46 @@
import container from '../di/container.js';
import { IOAuthRepo } from './repo.js';
import { IClientRegistrationRepo } from './client-repo.js';
import { getProviderConfig } from './providers.js';
import * as oauthClient from './oauth-client.js';
export async function getAccessToken(): Promise<string> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const { tokens } = await oauthRepo.read('rowboat');
if (!tokens) {
throw new Error('Not signed into Rowboat');
}
if (!oauthClient.isTokenExpired(tokens)) {
return tokens.access_token;
}
if (!tokens.refresh_token) {
throw new Error('Rowboat token expired and no refresh token available. Please sign in again.');
}
const providerConfig = await getProviderConfig('rowboat');
if (providerConfig.discovery.mode !== 'issuer') {
throw new Error('Rowboat provider requires issuer discovery mode');
}
const clientRepo = container.resolve<IClientRegistrationRepo>('clientRegistrationRepo');
const registration = await clientRepo.getClientRegistration('rowboat');
if (!registration) {
throw new Error('Rowboat client not registered. Please sign in again.');
}
const config = await oauthClient.discoverConfiguration(
providerConfig.discovery.issuer,
registration.client_id,
);
const refreshed = await oauthClient.refreshTokens(
config,
tokens.refresh_token,
tokens.scopes,
);
await oauthRepo.upsert('rowboat', { tokens: refreshed });
return refreshed.access_token;
}

View file

@ -0,0 +1,46 @@
import { getAccessToken } from '../auth/tokens.js';
import { API_URL } from '../config/env.js';
export interface BillingInfo {
userEmail: string | null;
userId: string | null;
subscriptionPlan: string | null;
subscriptionStatus: string | null;
trialExpiresAt: string | null;
sanctionedCredits: number;
availableCredits: number;
}
export async function getBillingInfo(): Promise<BillingInfo> {
const accessToken = await getAccessToken();
const response = await fetch(`${API_URL}/v1/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
throw new Error(`Billing API failed: ${response.status}`);
}
const body = await response.json() as {
user: {
id: string;
email: string;
};
billing: {
plan: string | null;
status: string | null;
trialExpiresAt: string | null;
usage: {
sanctionedCredits: number;
availableCredits: number;
};
};
};
return {
userEmail: body.user.email ?? null,
userId: body.user.id ?? null,
subscriptionPlan: body.billing.plan,
subscriptionStatus: body.billing.status,
trialExpiresAt: body.billing.trialExpiresAt ?? null,
sanctionedCredits: body.billing.usage.sanctionedCredits,
availableCredits: body.billing.usage.availableCredits,
};
}

View file

@ -1,7 +1,6 @@
import { z } from "zod";
import fs from "fs";
import path from "path";
import { Composio } from "@composio/core";
import { WorkDir } from "../config/config.js";
import {
ZAuthConfig,
@ -12,33 +11,37 @@ import {
ZCreateConnectedAccountResponse,
ZDeleteOperationResponse,
ZErrorResponse,
ZExecuteActionRequest,
ZExecuteActionResponse,
ZListResponse,
ZSearchResultTool,
ZToolkit,
type NormalizedToolResult,
} from "./types.js";
import { isSignedIn } from "../account/account.js";
import { getAccessToken } from "../auth/tokens.js";
import { API_URL } from "../config/env.js";
const BASE_URL = 'https://backend.composio.dev/api/v3';
const COMPOSIO_BASE_URL = 'https://backend.composio.dev/api/v3';
const CONFIG_FILE = path.join(WorkDir, 'config', 'composio.json');
// Composio SDK client (lazily initialized)
let composioClient: Composio | null = null;
function getComposioClient(): Composio {
if (composioClient) {
return composioClient;
async function getBaseUrl(): Promise<string> {
if (await isSignedIn()) {
return `${API_URL}/v1/composio`;
}
return COMPOSIO_BASE_URL;
}
async function getAuthHeaders(): Promise<Record<string, string>> {
if (await isSignedIn()) {
const token = await getAccessToken();
return { 'Authorization': `Bearer ${token}` };
}
const apiKey = getApiKey();
if (!apiKey) {
throw new Error('Composio API key not configured');
}
composioClient = new Composio({ apiKey });
return composioClient;
}
function resetComposioClient(): void {
composioClient = null;
return { 'x-api-key': apiKey };
}
/**
@ -46,6 +49,8 @@ function resetComposioClient(): void {
*/
const ZComposioConfig = z.object({
apiKey: z.string().optional(),
use_composio_for_google: z.boolean().optional(),
use_composio_for_google_calendar: z.boolean().optional(),
});
type ComposioConfig = z.infer<typeof ZComposioConfig>;
@ -68,7 +73,7 @@ function loadConfig(): ComposioConfig {
/**
* Save Composio configuration
*/
export function saveConfig(config: ComposioConfig): void {
function saveConfig(config: ComposioConfig): void {
const dir = path.dirname(CONFIG_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
@ -91,38 +96,58 @@ export function setApiKey(apiKey: string): void {
const config = loadConfig();
config.apiKey = apiKey;
saveConfig(config);
resetComposioClient();
}
/**
* Check if Composio is configured
*/
export function isConfigured(): boolean {
export async function isConfigured(): Promise<boolean> {
if (await isSignedIn()) return true;
return !!getApiKey();
}
/**
* Check if Composio should be used for Google services (Gmail, etc.)
*/
export async function useComposioForGoogle(): Promise<boolean> {
if (await isSignedIn()) return true;
const config = loadConfig();
return config.use_composio_for_google === true;
}
/**
* Check if Composio should be used for Google Calendar
*/
export async function useComposioForGoogleCalendar(): Promise<boolean> {
if (await isSignedIn()) return true;
const config = loadConfig();
return config.use_composio_for_google_calendar === true;
}
/**
* Make an API call to Composio
*/
export async function composioApiCall<T extends z.ZodTypeAny>(
schema: T,
url: string,
path: string,
params: Record<string, string> = {},
options: RequestInit = {},
): Promise<z.infer<T>> {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error('Composio API key not configured');
}
const authHeaders = await getAuthHeaders();
const baseURL = await getBaseUrl();
const url = new URL(`${baseURL}${path}`);
console.log(`[Composio] ${options.method || 'GET'} ${url}`);
const startTime = Date.now();
try {
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
const response = await fetch(url, {
...options,
headers: {
...options.headers,
"x-api-key": apiKey,
...authHeaders,
...(options.method === 'POST' ? { "Content-Type": "application/json" } : {}),
},
});
@ -143,7 +168,15 @@ export async function composioApiCall<T extends z.ZodTypeAny>(
}
if (!response.ok) {
throw new Error(`Composio API error: ${response.status} ${response.statusText}`);
// Try to extract a human-readable message from the JSON body
let detail = '';
try {
const body = JSON.parse(rawText);
if (typeof body?.error === 'string') detail = body.error;
else if (typeof body?.message === 'string') detail = body.message;
} catch { /* body isn't JSON or has no message field */ }
const suffix = detail ? `: ${detail}` : '';
throw new Error(`Composio API error: ${response.status} ${response.statusText}${suffix}`);
}
if (!contentType.includes('application/json')) {
@ -158,7 +191,7 @@ export async function composioApiCall<T extends z.ZodTypeAny>(
throw new Error(`Failed to parse response: ${message}`);
}
if (typeof data === 'object' && data !== null && 'error' in data) {
if (typeof data === 'object' && data !== null && 'error' in data && data.error !== null && typeof data.error === 'object') {
const parsedError = ZErrorResponse.parse(data);
throw new Error(`Composio error (${parsedError.error.error_code}): ${parsedError.error.message}`);
}
@ -174,47 +207,20 @@ export async function composioApiCall<T extends z.ZodTypeAny>(
* List available toolkits
*/
export async function listToolkits(cursor: string | null = null): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>> {
const url = new URL(`${BASE_URL}/toolkits`);
url.searchParams.set("sort_by", "usage");
const params: Record<string, string> = {
sort_by: "usage",
};
if (cursor) {
url.searchParams.set("cursor", cursor);
params.cursor = cursor;
}
return composioApiCall(ZListResponse(ZToolkit), url.toString());
return composioApiCall(ZListResponse(ZToolkit), "/toolkits", params);
}
/**
* Get a specific toolkit
*/
export async function getToolkit(toolkitSlug: string): Promise<z.infer<typeof ZToolkit>> {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error('Composio API key not configured');
}
const url = `${BASE_URL}/toolkits/${toolkitSlug}`;
console.log(`[Composio] GET ${url}`);
const response = await fetch(url, {
headers: { "x-api-key": apiKey },
});
if (!response.ok) {
throw new Error(`Failed to fetch toolkit: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const no_auth = data.composio_managed_auth_schemes?.includes('NO_AUTH') ||
data.auth_config_details?.some((config: { mode: string }) => config.mode === 'NO_AUTH') ||
false;
return ZToolkit.parse({
...data,
no_auth,
meta: data.meta || { description: '', logo: '', tools_count: 0, triggers_count: 0 },
auth_schemes: data.auth_schemes || [],
composio_managed_auth_schemes: data.composio_managed_auth_schemes || [],
});
return composioApiCall(ZToolkit, `/toolkits/${toolkitSlug}`);
}
/**
@ -225,15 +231,16 @@ export async function listAuthConfigs(
cursor: string | null = null,
managedOnly: boolean = false
): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZAuthConfig>>>> {
const url = new URL(`${BASE_URL}/auth_configs`);
url.searchParams.set("toolkit_slug", toolkitSlug);
const params: Record<string, string> = {
toolkit_slug: toolkitSlug,
};
if (cursor) {
url.searchParams.set("cursor", cursor);
params.cursor = cursor;
}
if (managedOnly) {
url.searchParams.set("is_composio_managed", "true");
params.is_composio_managed = "true";
}
return composioApiCall(ZListResponse(ZAuthConfig), url.toString());
return composioApiCall(ZListResponse(ZAuthConfig), "/auth_configs", params);
}
/**
@ -242,31 +249,19 @@ export async function listAuthConfigs(
export async function createAuthConfig(
request: z.infer<typeof ZCreateAuthConfigRequest>
): Promise<z.infer<typeof ZCreateAuthConfigResponse>> {
const url = new URL(`${BASE_URL}/auth_configs`);
return composioApiCall(ZCreateAuthConfigResponse, url.toString(), {
return composioApiCall(ZCreateAuthConfigResponse, "/auth_configs", {}, {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Delete an auth config
*/
export async function deleteAuthConfig(authConfigId: string): Promise<z.infer<typeof ZDeleteOperationResponse>> {
const url = new URL(`${BASE_URL}/auth_configs/${authConfigId}`);
return composioApiCall(ZDeleteOperationResponse, url.toString(), {
method: 'DELETE',
});
}
/**
* Create a connected account
*/
export async function createConnectedAccount(
request: z.infer<typeof ZCreateConnectedAccountRequest>
): Promise<z.infer<typeof ZCreateConnectedAccountResponse>> {
const url = new URL(`${BASE_URL}/connected_accounts`);
return composioApiCall(ZCreateConnectedAccountResponse, url.toString(), {
return composioApiCall(ZCreateConnectedAccountResponse, "/connected_accounts", {}, {
method: 'POST',
body: JSON.stringify(request),
});
@ -276,84 +271,63 @@ export async function createConnectedAccount(
* Get a connected account
*/
export async function getConnectedAccount(connectedAccountId: string): Promise<z.infer<typeof ZConnectedAccount>> {
const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`);
return composioApiCall(ZConnectedAccount, url.toString());
return composioApiCall(ZConnectedAccount, `/connected_accounts/${connectedAccountId}`);
}
/**
* Delete a connected account
*/
export async function deleteConnectedAccount(connectedAccountId: string): Promise<z.infer<typeof ZDeleteOperationResponse>> {
const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`);
return composioApiCall(ZDeleteOperationResponse, url.toString(), {
return composioApiCall(ZDeleteOperationResponse, `/connected_accounts/${connectedAccountId}`, {}, {
method: 'DELETE',
});
}
/**
* List available tools for a toolkit
* Search for tools across all toolkits (or optionally filtered by specific toolkit slugs).
* Returns tools with full input_parameters so the agent knows what params to pass.
*
* Uses a limit of 50 (not 15) to avoid the curated-filter-after-limit problem where
* in-scope results at position 16+ would be discarded if earlier results are out-of-scope.
*/
export async function listToolkitTools(
toolkitSlug: string,
searchQuery: string | null = null,
): Promise<{ items: Array<{ slug: string; name: string; description: string }> }> {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error('Composio API key not configured');
}
const url = new URL(`${BASE_URL}/tools`);
url.searchParams.set('toolkit_slug', toolkitSlug);
url.searchParams.set('limit', '200');
if (searchQuery) {
url.searchParams.set('search', searchQuery);
}
console.log(`[Composio] Listing tools for toolkit: ${toolkitSlug}`);
const response = await fetch(url.toString(), {
headers: { "x-api-key": apiKey },
});
if (!response.ok) {
throw new Error(`Failed to list tools: ${response.status} ${response.statusText}`);
}
const data = await response.json() as { items?: Array<Record<string, unknown>> };
return {
items: (data.items || []).map((item) => ({
slug: String(item.slug ?? ''),
name: String(item.name ?? ''),
description: String(item.description ?? ''),
})),
export async function searchTools(
searchQuery: string,
toolkitSlugs?: string[],
): Promise<{ items: NormalizedToolResult[] }> {
const params: Record<string, string> = {
query: searchQuery,
limit: '50',
};
if (toolkitSlugs && toolkitSlugs.length === 1) {
params.toolkit_slug = toolkitSlugs[0];
}
const result = await composioApiCall(ZListResponse(ZSearchResultTool), "/tools", params);
const items: NormalizedToolResult[] = result.items.map((item) => ({
slug: item.slug,
name: item.name,
description: item.description,
toolkitSlug: item.toolkit.slug,
inputParameters: {
type: 'object' as const,
properties: item.input_parameters?.properties ?? {},
required: item.input_parameters?.required,
},
}));
return { items };
}
/**
* Execute a tool action using Composio SDK
* Execute a tool action
*/
export async function executeAction(
actionSlug: string,
connectedAccountId: string,
input: Record<string, unknown>
request: z.infer<typeof ZExecuteActionRequest>
): Promise<z.infer<typeof ZExecuteActionResponse>> {
console.log(`[Composio] Executing action: ${actionSlug} (account: ${connectedAccountId})`);
try {
const client = getComposioClient();
const result = await client.tools.execute(actionSlug, {
userId: 'rowboat-user',
arguments: input,
connectedAccountId,
dangerouslySkipVersionCheck: true,
});
console.log(`[Composio] Action completed successfully`);
return { success: true, data: result.data };
} catch (error) {
console.error(`[Composio] Action execution failed:`, JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}), 2));
const message = error instanceof Error ? error.message : (typeof error === 'object' ? JSON.stringify(error) : 'Unknown error');
return { success: false, data: null, error: message };
}
return composioApiCall(ZExecuteActionResponse, `/tools/execute/${actionSlug}`, {}, {
method: 'POST',
body: JSON.stringify(request),
});
}

View file

@ -1,4 +1,8 @@
import { z } from "zod";
import { ZToolkitMeta as ZSharedToolkitMeta, ZToolkitItem } from "@x/shared/dist/composio.js";
// Re-export the shared toolkit schemas so existing imports continue to work
export const ZToolkitMeta = ZSharedToolkitMeta;
/**
* Composio authentication schemes
@ -29,26 +33,9 @@ export const ZConnectedAccountStatus = z.enum([
]);
/**
* Toolkit metadata
* Toolkit schema same shape as ZToolkitItem from shared, re-exported for convenience.
*/
export const ZToolkitMeta = z.object({
description: z.string(),
logo: z.string(),
tools_count: z.number(),
triggers_count: z.number(),
});
/**
* Toolkit schema
*/
export const ZToolkit = z.object({
slug: z.string(),
name: z.string(),
meta: ZToolkitMeta,
no_auth: z.boolean(),
auth_schemes: z.array(ZAuthScheme),
composio_managed_auth_schemes: z.array(ZAuthScheme),
});
export const ZToolkit = ZToolkitItem;
/**
* Tool schema
@ -68,7 +55,7 @@ export const ZTool = z.object({
required: z.array(z.string()).optional(),
additionalProperties: z.boolean().optional(),
}),
no_auth: z.boolean(),
no_auth: z.boolean().optional(),
});
/**
@ -147,7 +134,7 @@ export const ZCreateConnectedAccountRequest = z.object({
*/
export const ZCreateConnectedAccountResponse = z.object({
id: z.string(),
connectionData: ZConnectionData,
connectionData: ZConnectionData.optional(),
});
/**
@ -200,18 +187,19 @@ export const ZListResponse = <T extends z.ZodTypeAny>(schema: T) => z.object({
* Execute action request
*/
export const ZExecuteActionRequest = z.object({
action: z.string(),
connected_account_id: z.string(),
input: z.record(z.string(), z.unknown()),
user_id: z.string(),
version: z.string(),
arguments: z.any().optional(),
});
/**
* Execute action response
*/
export const ZExecuteActionResponse = z.object({
success: z.boolean(),
data: z.unknown(),
error: z.string().optional(),
successful: z.boolean(),
error: z.string().nullable(),
});
/**
@ -226,12 +214,44 @@ export const ZLocalConnectedAccount = z.object({
lastUpdatedAt: z.string(),
});
export type AuthScheme = z.infer<typeof ZAuthScheme>;
export type ConnectedAccountStatus = z.infer<typeof ZConnectedAccountStatus>;
export type Toolkit = z.infer<typeof ZToolkit>;
export type Tool = z.infer<typeof ZTool>;
export type AuthConfig = z.infer<typeof ZAuthConfig>;
export type ConnectedAccount = z.infer<typeof ZConnectedAccount>;
export type LocalConnectedAccount = z.infer<typeof ZLocalConnectedAccount>;
export type ExecuteActionRequest = z.infer<typeof ZExecuteActionRequest>;
export type ExecuteActionResponse = z.infer<typeof ZExecuteActionResponse>;
export type ConnectedAccountStatus = z.infer<typeof ZConnectedAccountStatus>;
/**
* Tool schema for search results.
* Unlike ZTool, `toolkit` is optional because the Composio /tools search endpoint
* sometimes omits the toolkit object from results. `input_parameters` uses
* lenient defaults so tools with no params (e.g. LINKEDIN_GET_MY_INFO) parse cleanly.
*/
export const ZSearchResultTool = z.object({
slug: z.string(),
name: z.string(),
description: z.string(),
toolkit: z.object({
slug: z.string(),
name: z.string(),
logo: z.string(),
}),
input_parameters: z.object({
type: z.literal('object').optional().default('object'),
properties: z.record(z.string(), z.unknown()).optional().default({}),
required: z.array(z.string()).optional(),
}).optional().default({ type: 'object', properties: {} }),
}).passthrough();
/**
* Normalized tool result returned from searchTools().
*/
export const ZNormalizedToolResult = z.object({
slug: z.string(),
name: z.string(),
description: z.string(),
toolkitSlug: z.string(),
inputParameters: z.object({
type: z.literal('object'),
properties: z.record(z.string(), z.unknown()),
required: z.array(z.string()).optional(),
}),
});
export type NormalizedToolResult = z.infer<typeof ZNormalizedToolResult>;

View file

@ -4,7 +4,8 @@ import { homedir } from "os";
import { fileURLToPath } from "url";
// Resolve app root relative to compiled file location (dist/...)
export const WorkDir = path.join(homedir(), ".rowboat");
// Allow override via ROWBOAT_WORKDIR env var for standalone pipeline usage
export const WorkDir = process.env.ROWBOAT_WORKDIR || path.join(homedir(), ".rowboat");
// Get the directory of this file (for locating bundled assets)
const __filename = fileURLToPath(import.meta.url);
@ -23,75 +24,19 @@ function ensureDefaultConfigs() {
const noteCreationConfig = path.join(WorkDir, "config", "note_creation.json");
if (!fs.existsSync(noteCreationConfig)) {
fs.writeFileSync(noteCreationConfig, JSON.stringify({
strictness: "high",
strictness: "medium",
configured: false
}, null, 2));
}
}
// Welcome content inlined to work with bundled builds (esbuild changes __dirname)
const WELCOME_CONTENT = `# Welcome to Rowboat
This vault is your work memory.
Rowboat extracts context from your emails and meetings and turns it into long-lived, editable Markdown notes. The goal is not to store everything, but to preserve the context that stays useful over time.
---
## How it works
**Entity-based notes**
Notes represent people, projects, organizations, or topics that matter to your work.
**Auto-updating context**
As new emails and meetings come in, Rowboat adds decisions, commitments, and relevant context to the appropriate notes.
**Living notes**
These are not static summaries. Context accumulates over time, and notes evolve as your work evolves.
---
## Your AI coworker
Rowboat uses this shared memory to help with everyday work, such as:
- Drafting emails
- Preparing for meetings
- Summarizing the current state of a project
- Taking local actions when appropriate
The AI works with deep context, but you stay in control. All notes are visible, editable, and yours.
---
## Design principles
**Reduce noise**
Rowboat focuses on recurring contacts and active projects instead of trying to capture everything.
**Local and inspectable**
All data is stored locally as plain Markdown. You can read, edit, or delete any file at any time.
**Built to improve over time**
As you keep using Rowboat, context accumulates across notes instead of being reconstructed from scratch.
---
If something feels confusing or limiting, we'd love to hear about it.
Rowboat is still evolving, and your workflow matters.
`;
function ensureWelcomeFile() {
// Create Welcome.md in knowledge directory if it doesn't exist
const welcomeDest = path.join(WorkDir, "knowledge", "Welcome.md");
if (!fs.existsSync(welcomeDest)) {
fs.writeFileSync(welcomeDest, WELCOME_CONTENT);
}
}
ensureDirs();
ensureDefaultConfigs();
ensureWelcomeFile();
// Ensure default knowledge files exist
import('../knowledge/ensure_daily_note.js').then(m => m.ensureDailyNote()).catch(err => {
console.error('[DailyNote] Failed to ensure daily note:', err);
});
// Initialize version history repo (async, fire-and-forget on startup)
import('../knowledge/version_history.js').then(m => m.initRepo()).catch(err => {

View file

@ -0,0 +1,2 @@
export const API_URL =
process.env.API_URL || 'https://api.x.rowboatlabs.com';

View file

@ -11,7 +11,7 @@ interface NoteCreationConfig {
}
const CONFIG_FILE = path.join(WorkDir, 'config', 'note_creation.json');
const DEFAULT_STRICTNESS: NoteCreationStrictness = 'high';
const DEFAULT_STRICTNESS: NoteCreationStrictness = 'medium';
/**
* Read the full config file.

View file

@ -0,0 +1,15 @@
import { z } from "zod";
import { RowboatApiConfig } from "@x/shared/dist/rowboat-account.js";
import { API_URL } from "./env.js";
let cached: z.infer<typeof RowboatApiConfig> | null = null;
export async function getRowboatConfig(): Promise<z.infer<typeof RowboatApiConfig>> {
if (cached) {
return cached;
}
const response = await fetch(`${API_URL}/v1/config`);
const data = RowboatApiConfig.parse(await response.json());
cached = data;
return data;
}

View file

@ -6,15 +6,40 @@ import { WorkDir } from "./config.js";
export const SECURITY_CONFIG_PATH = path.join(WorkDir, "config", "security.json");
const DEFAULT_ALLOW_LIST = [
"agent-slack",
"awk",
"basename",
"cat",
"cut",
"date",
"df",
"diff",
"dirname",
"du",
"echo",
"env",
"file",
"find",
"grep",
"head",
"hostname",
"jq",
"ls",
"printenv",
"printf",
"pwd",
"yq",
"whoami"
"readlink",
"realpath",
"sort",
"stat",
"tail",
"tree",
"uname",
"uniq",
"wc",
"which",
"whoami",
"yq"
]
let cachedAllowList: string[] | null = null;

View file

@ -0,0 +1,44 @@
import fs from 'fs';
import path from 'path';
import { z } from 'zod';
import { WorkDir } from './config.js';
const USER_CONFIG_PATH = path.join(WorkDir, 'config', 'user.json');
export const UserConfig = z.object({
name: z.string().optional(),
email: z.string().email(),
domain: z.string().optional(),
});
export type UserConfig = z.infer<typeof UserConfig>;
export function loadUserConfig(): UserConfig | null {
try {
if (fs.existsSync(USER_CONFIG_PATH)) {
const content = fs.readFileSync(USER_CONFIG_PATH, 'utf-8');
const parsed = JSON.parse(content);
return UserConfig.parse(parsed);
}
} catch (error) {
console.error('[UserConfig] Error loading user config:', error);
}
return null;
}
export function saveUserConfig(config: UserConfig): void {
const dir = path.dirname(USER_CONFIG_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const validated = UserConfig.parse(config);
fs.writeFileSync(USER_CONFIG_PATH, JSON.stringify(validated, null, 2));
}
export function updateUserEmail(email: string): void {
const existing = loadUserConfig();
const config = existing
? { ...existing, email }
: { email };
saveUserConfig(config);
}

View file

@ -14,6 +14,7 @@ import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/re
import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js";
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
const container = createContainer({
injectionMode: InjectionMode.PROXY,
@ -37,6 +38,7 @@ container.register({
granolaConfigRepo: asClass<IGranolaConfigRepo>(FSGranolaConfigRepo).singleton(),
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
});
export default container;

View file

@ -9,3 +9,6 @@ export { initConfigs } from './config/initConfigs.js';
// Knowledge version history
export * as versionHistory from './knowledge/version_history.js';
// Voice mode (config + TTS)
export * as voice from './voice/voice.js';

View file

@ -0,0 +1,384 @@
import fs from 'fs';
import path from 'path';
import { google } from 'googleapis';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage } from '../runs/runs.js';
import { bus } from '../runs/bus.js';
import { serviceLogger } from '../services/service_logger.js';
import { loadUserConfig, updateUserEmail } from '../config/user_config.js';
import { GoogleClientFactory } from './google-client-factory.js';
import { useComposioForGoogle, executeAction } from '../composio/client.js';
import { composioAccountsRepo } from '../composio/repo.js';
import {
loadAgentNotesState,
saveAgentNotesState,
markEmailProcessed,
markRunProcessed,
type AgentNotesState,
} from './agent_notes_state.js';
const SYNC_INTERVAL_MS = 10 * 1000; // 10 seconds (for testing)
const EMAIL_BATCH_SIZE = 5;
const RUNS_BATCH_SIZE = 5;
const GMAIL_SYNC_DIR = path.join(WorkDir, 'gmail_sync');
const RUNS_DIR = path.join(WorkDir, 'runs');
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
const INBOX_FILE = path.join(AGENT_NOTES_DIR, 'inbox.md');
const AGENT_ID = 'agent_notes_agent';
// --- File helpers ---
function ensureAgentNotesDir(): void {
if (!fs.existsSync(AGENT_NOTES_DIR)) {
fs.mkdirSync(AGENT_NOTES_DIR, { recursive: true });
}
}
// --- Email scanning ---
function findUserSentEmails(
state: AgentNotesState,
userEmail: string,
limit: number,
): string[] {
if (!fs.existsSync(GMAIL_SYNC_DIR)) {
return [];
}
const results: { path: string; mtime: number }[] = [];
const userEmailLower = userEmail.toLowerCase();
function traverse(dir: string) {
const entries = fs.readdirSync(dir);
for (const entry of entries) {
const fullPath = path.join(dir, entry);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
if (entry !== 'attachments') {
traverse(fullPath);
}
} else if (stat.isFile() && entry.endsWith('.md')) {
if (state.processedEmails[fullPath]) {
continue;
}
try {
const content = fs.readFileSync(fullPath, 'utf-8');
const fromLines = content.match(/^### From:.*$/gm);
if (fromLines?.some(line => line.toLowerCase().includes(userEmailLower))) {
results.push({ path: fullPath, mtime: stat.mtimeMs });
}
} catch {
continue;
}
}
}
}
traverse(GMAIL_SYNC_DIR);
results.sort((a, b) => b.mtime - a.mtime);
return results.slice(0, limit).map(r => r.path);
}
function extractUserPartsFromEmail(content: string, userEmail: string): string | null {
const userEmailLower = userEmail.toLowerCase();
const sections = content.split(/^---$/m);
const userSections: string[] = [];
for (const section of sections) {
const fromMatch = section.match(/^### From:.*$/m);
if (fromMatch && fromMatch[0].toLowerCase().includes(userEmailLower)) {
userSections.push(section.trim());
}
}
return userSections.length > 0 ? userSections.join('\n\n---\n\n') : null;
}
// --- Inbox reading ---
function readInbox(): string[] {
if (!fs.existsSync(INBOX_FILE)) {
return [];
}
const content = fs.readFileSync(INBOX_FILE, 'utf-8').trim();
if (!content) {
return [];
}
return content.split('\n').filter(l => l.trim());
}
function clearInbox(): void {
if (fs.existsSync(INBOX_FILE)) {
fs.writeFileSync(INBOX_FILE, '');
}
}
// --- Copilot run scanning ---
function findNewCopilotRuns(state: AgentNotesState): string[] {
if (!fs.existsSync(RUNS_DIR)) {
return [];
}
const results: string[] = [];
const files = fs.readdirSync(RUNS_DIR).filter(f => f.endsWith('.jsonl'));
for (const file of files) {
if (state.processedRuns[file]) {
continue;
}
try {
const fullPath = path.join(RUNS_DIR, file);
const fd = fs.openSync(fullPath, 'r');
const buf = Buffer.alloc(512);
const bytesRead = fs.readSync(fd, buf, 0, 512, 0);
fs.closeSync(fd);
const firstLine = buf.subarray(0, bytesRead).toString('utf-8').split('\n')[0];
const event = JSON.parse(firstLine);
if (event.agentName === 'copilot') {
results.push(file);
}
} catch {
continue;
}
}
results.sort();
return results;
}
function extractConversationMessages(runFilePath: string): { role: string; text: string }[] {
const messages: { role: string; text: string }[] = [];
try {
const content = fs.readFileSync(runFilePath, 'utf-8');
const lines = content.split('\n').filter(l => l.trim());
for (const line of lines) {
try {
const event = JSON.parse(line);
if (event.type !== 'message') continue;
const msg = event.message;
if (!msg || (msg.role !== 'user' && msg.role !== 'assistant')) continue;
let text = '';
if (typeof msg.content === 'string') {
text = msg.content.trim();
} else if (Array.isArray(msg.content)) {
text = msg.content
.filter((p: { type: string }) => p.type === 'text')
.map((p: { text: string }) => p.text)
.join('\n')
.trim();
}
if (text) {
messages.push({ role: msg.role, text });
}
} catch {
continue;
}
}
} catch {
// ignore
}
return messages;
}
// --- Wait for agent run completion ---
async function waitForRunCompletion(runId: string): Promise<void> {
return new Promise(async (resolve) => {
const unsubscribe = await bus.subscribe('*', async (event) => {
if (event.type === 'run-processing-end' && event.runId === runId) {
unsubscribe();
resolve();
}
});
});
}
// --- User email resolution ---
async function ensureUserEmail(): Promise<string | null> {
const existing = loadUserConfig();
if (existing?.email) {
return existing.email;
}
// Try Composio (used when signed in or composio configured)
try {
if (await useComposioForGoogle()) {
const account = composioAccountsRepo.getAccount('gmail');
if (account && account.status === 'ACTIVE') {
const result = await executeAction('GMAIL_GET_PROFILE', {
connected_account_id: account.id,
user_id: 'rowboat-user',
version: 'latest',
arguments: { user_id: 'me' },
});
const email = (result.data as Record<string, unknown>)?.emailAddress as string | undefined;
if (email) {
updateUserEmail(email);
console.log(`[AgentNotes] Auto-populated user email via Composio: ${email}`);
return email;
}
}
}
} catch (error) {
console.log('[AgentNotes] Could not fetch email via Composio:', error instanceof Error ? error.message : error);
}
// Try direct Google OAuth
try {
const auth = await GoogleClientFactory.getClient();
if (auth) {
const gmail = google.gmail({ version: 'v1', auth });
const profile = await gmail.users.getProfile({ userId: 'me' });
if (profile.data.emailAddress) {
updateUserEmail(profile.data.emailAddress);
console.log(`[AgentNotes] Auto-populated user email: ${profile.data.emailAddress}`);
return profile.data.emailAddress;
}
}
} catch (error) {
console.log('[AgentNotes] Could not fetch Gmail profile for user email:', error instanceof Error ? error.message : error);
}
return null;
}
// --- Main processing ---
async function processAgentNotes(): Promise<void> {
ensureAgentNotesDir();
const state = loadAgentNotesState();
const userEmail = await ensureUserEmail();
// Collect all source material
const messageParts: string[] = [];
// 1. Emails (only if we have user email)
const emailPaths = userEmail
? findUserSentEmails(state, userEmail, EMAIL_BATCH_SIZE)
: [];
if (emailPaths.length > 0) {
messageParts.push(`## Emails sent by the user\n`);
for (const p of emailPaths) {
const content = fs.readFileSync(p, 'utf-8');
const userParts = extractUserPartsFromEmail(content, userEmail!);
if (userParts) {
messageParts.push(`---\n${userParts}\n---\n`);
}
}
}
// 2. Inbox entries
const inboxEntries = readInbox();
if (inboxEntries.length > 0) {
messageParts.push(`## Notes from the assistant (save-to-memory inbox)\n`);
messageParts.push(inboxEntries.join('\n'));
}
// 3. Copilot conversations
const newRuns = findNewCopilotRuns(state);
const runsToProcess = newRuns.slice(-RUNS_BATCH_SIZE);
if (runsToProcess.length > 0) {
let conversationText = '';
for (const runFile of runsToProcess) {
const messages = extractConversationMessages(path.join(RUNS_DIR, runFile));
if (messages.length === 0) continue;
conversationText += `\n--- Conversation ---\n`;
for (const msg of messages) {
conversationText += `${msg.role}: ${msg.text}\n\n`;
}
}
if (conversationText.trim()) {
messageParts.push(`## Recent copilot conversations\n${conversationText}`);
}
}
// Nothing to process
if (messageParts.length === 0) {
return;
}
const serviceRun = await serviceLogger.startRun({
service: 'agent_notes',
message: 'Processing agent notes',
trigger: 'timer',
});
try {
const timestamp = new Date().toISOString();
const message = `Current timestamp: ${timestamp}\n\nProcess the following source material and update the Agent Notes folder accordingly.\n\n${messageParts.join('\n\n')}`;
const agentRun = await createRun({ agentId: AGENT_ID });
await createMessage(agentRun.id, message);
await waitForRunCompletion(agentRun.id);
// Mark everything as processed
for (const p of emailPaths) {
markEmailProcessed(p, state);
}
for (const r of newRuns) {
markRunProcessed(r, state);
}
if (inboxEntries.length > 0) {
clearInbox();
}
state.lastRunTime = new Date().toISOString();
saveAgentNotesState(state);
await serviceLogger.log({
type: 'run_complete',
service: serviceRun.service,
runId: serviceRun.runId,
level: 'info',
message: 'Agent notes processing complete',
durationMs: Date.now() - serviceRun.startedAt,
outcome: 'ok',
summary: {
emails: emailPaths.length,
inboxEntries: inboxEntries.length,
copilotRuns: runsToProcess.length,
},
});
} catch (error) {
console.error('[AgentNotes] Error processing:', error);
await serviceLogger.log({
type: 'error',
service: serviceRun.service,
runId: serviceRun.runId,
level: 'error',
message: 'Error processing agent notes',
error: error instanceof Error ? error.message : String(error),
});
}
}
// --- Entry point ---
export async function init() {
console.log('[AgentNotes] Starting Agent Notes Service...');
console.log(`[AgentNotes] Will process every ${SYNC_INTERVAL_MS / 1000} seconds`);
// Initial run
await processAgentNotes();
// Periodic polling
while (true) {
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
try {
await processAgentNotes();
} catch (error) {
console.error('[AgentNotes] Error in main loop:', error);
}
}
}

View file

@ -0,0 +1,90 @@
export function getRaw(): string {
return `---
tools:
workspace-writeFile:
type: builtin
name: workspace-writeFile
workspace-readFile:
type: builtin
name: workspace-readFile
workspace-edit:
type: builtin
name: workspace-edit
workspace-readdir:
type: builtin
name: workspace-readdir
workspace-mkdir:
type: builtin
name: workspace-mkdir
---
# Agent Notes
You are the Agent Notes agent. You maintain a set of notes about the user in the \`knowledge/Agent Notes/\` folder. Your job is to process new source material and update the notes accordingly.
## Folder Structure
The Agent Notes folder contains markdown files that capture what you've learned about the user:
- **user.md** Facts about who the user IS: their identity, role, company, team, projects, relationships, life context. NOT how they write or what they prefer. Each fact is a timestamped bullet point.
- **preferences.md** General preferences and explicit rules (e.g., "don't use em-dashes", "no meetings before 11am"). These are injected into the assistant's system prompt on every chat.
- **style/email.md** Email writing style patterns, bucketed by recipient context, with examples from actual emails.
- Other files as needed If you notice preferences specific to a topic (e.g., presentations, meeting prep), create a dedicated file for them (e.g., \`presentations.md\`, \`meeting-prep.md\`).
## How to Process Source Material
You will receive a message containing some combination of:
1. **Emails sent by the user** Analyze their writing style and update \`style/email.md\`. Do NOT put style observations in \`user.md\`.
2. **Inbox entries** Notes the assistant saved during conversations via save-to-memory. Route each to the appropriate file. General preferences go to \`preferences.md\`. Topic-specific preferences get their own file.
3. **Copilot conversations** User and assistant messages from recent chats. Extract lasting facts about the user and append timestamped entries to \`user.md\`.
## What Goes Where Be Strict
### user.md ONLY identity and context facts
Good examples:
- Co-founded Rowboat Labs with Ramnique
- Team of 4 people
- Previously worked at Twitter
- Planning to fundraise after Product Hunt launch
- Based in Bangalore, travels to SF periodically
Bad examples (do NOT put these in user.md):
- "Uses concise, friendly scheduling replies" this is style, goes in style/email.md
- "Frequently replies with short confirmations" this is style, goes in style/email.md
- "Uses the abbreviation PFA" this is style, goes in style/email.md
- "Requested a children's story about a scientist grandmother" this is an ephemeral task, skip entirely
- "Prefers 30-minute meeting slots" this is a preference, goes in preferences.md
### style/email.md Writing patterns from emails
Organize by recipient context. Include concrete examples quoted from actual emails.
- Close team (very terse, no greeting/sign-off)
- External/investors (casual but structured)
- Formal/cold (concise, complete sentences)
### preferences.md Explicit rules and preferences
Things the user has stated they want or don't want.
### Other files Topic-specific persistent preferences ONLY
Create a new file ONLY for recurring preference themes where the user has expressed multiple lasting preferences about a specific skill or task type. Examples: \`presentations.md\` (if the user has stated preferences about slide design, deck structure, etc.), \`meeting-prep.md\` (if they have preferences about how meetings are prepared).
Do NOT create files for:
- One-off facts or transient situations (e.g., "looking for housing in SF" that's a user.md fact, not a preference file)
- Topics with only a single observation
- Things that are better captured in user.md or preferences.md
## Rules
- Always read a file before updating it so you know what's already there.
- For \`user.md\`: Format is \`- [ISO_TIMESTAMP] The fact\`. The timestamp indicates when the fact was last confirmed.
- **Add** new facts with the current timestamp.
- **Refresh** existing facts: if you would add a fact that's already there, update its timestamp to the current one so it stays fresh.
- **Remove** facts that are likely outdated. Use your judgment: time-bound facts (e.g., "planning to launch next week", "has a meeting with X on Friday") go stale quickly. Stable facts (e.g., "co-founded Rowboat with Ramnique", "previously worked at Twitter") persist. If a fact's timestamp is old and it describes something transient, remove it.
- For \`preferences.md\` and other preference files: you may reorganize and deduplicate, but preserve all existing preferences that are still relevant.
- **Deduplicate strictly.** Before adding anything, check if the same fact is already captured even if worded differently. Do NOT add a near-duplicate.
- **Skip ephemeral tasks.** If the user asked the assistant to do a one-off thing (draft an email, write a story, search for something), that is NOT a fact about the user. Skip it entirely.
- Be concise bullet points, not paragraphs.
- Capture context, not blanket rules. BAD: "User prefers casual tone". GOOD: "User prefers casual tone with internal team but formal with investors."
- **If there's nothing new to add to a file, do NOT touch it.** Do not create placeholder content, do not write "no preferences recorded", do not add explanatory notes about what the file is for. Leave it empty or leave it as-is.
- **Do NOT create files unless you have actual content for them.** An empty or boilerplate file is worse than no file.
- Create the \`style/\` directory if it doesn't exist yet and you have style content to write.
`;
}

View file

@ -0,0 +1,62 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../config/config.js';
const STATE_FILE = path.join(WorkDir, 'agent_notes_state.json');
export interface AgentNotesState {
processedEmails: Record<string, { processedAt: string }>;
processedRuns: Record<string, { processedAt: string }>;
lastRunTime: string;
}
export function loadAgentNotesState(): AgentNotesState {
if (fs.existsSync(STATE_FILE)) {
try {
const parsed = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
// Handle migration from older state without processedRuns
if (!parsed.processedRuns) {
parsed.processedRuns = {};
}
return parsed;
} catch (error) {
console.error('Error loading agent notes state:', error);
}
}
return {
processedEmails: {},
processedRuns: {},
lastRunTime: new Date(0).toISOString(),
};
}
export function saveAgentNotesState(state: AgentNotesState): void {
try {
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
} catch (error) {
console.error('Error saving agent notes state:', error);
throw error;
}
}
export function markEmailProcessed(filePath: string, state: AgentNotesState): void {
state.processedEmails[filePath] = {
processedAt: new Date().toISOString(),
};
}
export function markRunProcessed(runFile: string, state: AgentNotesState): void {
state.processedRuns[runFile] = {
processedAt: new Date().toISOString(),
};
}
export function resetAgentNotesState(): void {
const emptyState: AgentNotesState = {
processedEmails: {},
processedRuns: {},
lastRunTime: new Date().toISOString(),
};
saveAgentNotesState(emptyState);
}

View file

@ -1,7 +1,6 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../config/config.js';
import { autoConfigureStrictnessIfNeeded } from '../config/strictness_analyzer.js';
import { createRun, createMessage } from '../runs/runs.js';
import { bus } from '../runs/bus.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
@ -16,6 +15,7 @@ import {
import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js';
import { limitEventItems } from './limit_event_items.js';
import { commitAll } from './version_history.js';
import { getTagDefinitions } from './tag_system.js';
/**
* Build obsidian-style knowledge graph by running topic extraction
@ -26,16 +26,58 @@ const NOTES_OUTPUT_DIR = path.join(WorkDir, 'knowledge');
const NOTE_CREATION_AGENT = 'note_creation';
// Configuration for the graph builder service
const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
const SOURCE_FOLDERS = [
'gmail_sync',
'fireflies_transcripts',
'granola_notes',
path.join('knowledge', 'Meetings', 'fireflies'),
path.join('knowledge', 'Meetings', 'granola'),
];
// Voice memos are now created directly in knowledge/Voice Memos/<date>/
const VOICE_MEMOS_KNOWLEDGE_DIR = path.join(NOTES_OUTPUT_DIR, 'Voice Memos');
/**
* Check if email frontmatter contains any noise/skip filter tags.
* Returns true if the email should be skipped.
*/
function hasNoiseLabels(content: string): boolean {
if (!content.startsWith('---')) return false;
const endIdx = content.indexOf('---', 3);
if (endIdx === -1) return false;
const frontmatter = content.slice(3, endIdx);
const noiseTags = new Set(
getTagDefinitions()
.filter(t => t.type === 'noise')
.map(t => t.tag)
);
// Match list items under filter: key
const filterMatch = frontmatter.match(/filter:\s*\n((?:\s+-\s+.+\n?)*)/);
if (filterMatch) {
const filterLines = filterMatch[1].match(/^\s+-\s+(.+)$/gm);
if (filterLines) {
for (const line of filterLines) {
const tag = line.replace(/^\s+-\s+/, '').trim().replace(/['"]/g, '');
if (noiseTags.has(tag)) return true;
}
}
}
// Match inline array like filter: ['cold-outreach'] or filter: [cold-outreach]
const inlineMatch = frontmatter.match(/filter:\s*\[([^\]]*)\]/);
if (inlineMatch && inlineMatch[1].trim()) {
const tags = inlineMatch[1].split(',').map(t => t.trim().replace(/['"]/g, ''));
for (const tag of tags) {
if (noiseTags.has(tag)) return true;
}
}
return false;
}
function extractPathFromToolInput(input: string): string | null {
try {
const parsed = JSON.parse(input) as { path?: string };
@ -193,7 +235,9 @@ async function createNotesFromBatch(
// Add each file's content
message += `# Source Files to Process\n\n`;
files.forEach((file, idx) => {
message += `## Source File ${idx + 1}: ${path.basename(file.path)}\n\n`;
// Pass workspace-relative path so the agent can link back to meeting notes
const relativePath = path.relative(WorkDir, file.path);
message += `## Source File ${idx + 1}: ${relativePath}\n\n`;
message += file.content;
message += `\n\n---\n\n`;
});
@ -363,7 +407,26 @@ export async function buildGraph(sourceDir: string): Promise<void> {
console.log(`[buildGraph] State loaded. Previously processed: ${previouslyProcessedCount} files`);
// Get files that need processing (new or changed)
const filesToProcess = getFilesToProcess(sourceDir, state);
let filesToProcess = getFilesToProcess(sourceDir, state);
// For gmail_sync, only process emails that have been labeled AND don't have noise filter tags
if (sourceDir.endsWith('gmail_sync')) {
filesToProcess = filesToProcess.filter(filePath => {
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (!content.startsWith('---')) return false;
if (hasNoiseLabels(content)) {
console.log(`[buildGraph] Skipping noise email: ${path.basename(filePath)}`);
markFileAsProcessed(filePath, state);
return false;
}
return true;
} catch {
return false;
}
});
saveState(state);
}
if (filesToProcess.length === 0) {
console.log(`[buildGraph] No new or changed files to process in ${path.basename(sourceDir)}`);
@ -522,11 +585,9 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
/**
* Process all configured source directories
*/
async function processAllSources(): Promise<void> {
export async function processAllSources(): Promise<void> {
console.log('[GraphBuilder] Checking for new content in all sources...');
// Auto-configure strictness on first run if not already done
autoConfigureStrictnessIfNeeded();
let anyFilesProcessed = false;
@ -555,7 +616,26 @@ async function processAllSources(): Promise<void> {
}
try {
const filesToProcess = getFilesToProcess(sourceDir, state);
let filesToProcess = getFilesToProcess(sourceDir, state);
// For gmail_sync, only process emails that have been labeled AND don't have noise filter tags
if (folder === 'gmail_sync') {
filesToProcess = filesToProcess.filter(filePath => {
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (!content.startsWith('---')) return false;
if (hasNoiseLabels(content)) {
console.log(`[GraphBuilder] Skipping noise email: ${path.basename(filePath)}`);
markFileAsProcessed(filePath, state);
return false;
}
return true;
} catch {
return false;
}
});
saveState(state);
}
if (filesToProcess.length > 0) {
console.log(`[GraphBuilder] Found ${filesToProcess.length} new/changed files in ${folder}`);

View file

@ -0,0 +1,96 @@
# Page Capture Chrome Extension
A Chrome extension that captures web pages you visit and sends them to a local server for storage as markdown files.
## Structure
```
/extension
manifest.json # Chrome extension manifest (v3)
background.js # Service worker that captures pages
/server
server.py # Flask server for storing captures
captured_pages/ # Directory where pages are saved
```
## Setup
### 1. Install Server Dependencies
```bash
cd server
pip install flask flask-cors
```
### 2. Start the Server
```bash
cd server
python server.py
```
The server will run at `http://localhost:3001`.
### 3. Install the Chrome Extension
1. Open Chrome and navigate to `chrome://extensions/`
2. Enable "Developer mode" (toggle in top right)
3. Click "Load unpacked"
4. Select the `extension` folder
## Usage
Once both the server is running and the extension is installed, the extension will automatically capture pages as you browse:
- Every page load (http/https URLs only) triggers a capture
- Content is hashed with SHA-256 to avoid duplicate captures
- Pages are saved as markdown files with frontmatter metadata
## API Endpoints
### POST /capture
Receives captured page data.
**Request body:**
```json
{
"url": "https://example.com",
"content": "Page text content...",
"timestamp": 1706123456789,
"title": "Page Title"
}
```
**Response:**
```json
{"status": "captured", "filename": "1706123456789_example_com.md"}
```
### GET /status
Returns the count of captured pages.
**Response:**
```json
{"count": 42}
```
## File Format
Captured pages are saved as markdown with YAML frontmatter:
```markdown
---
url: https://example.com/page
title: Page Title
captured_at: 2024-01-24T12:34:56
---
Page content here...
```
## Debugging
- **Extension logs**: Open `chrome://extensions/`, find "Page Capture", click "Service worker" to view console logs
- **Server logs**: Check the terminal where `server.py` is running

View file

@ -0,0 +1,388 @@
const SERVER_URL = 'http://localhost:3001';
const contentHashMap = new Map();
let cachedConfig = null;
let serverReachable = true;
// Default config
const DEFAULT_CONFIG = {
mode: 'ask',
whitelist: [],
blacklist: [],
enabled: true
};
// Config management
async function loadConfig() {
try {
const response = await fetch(`${SERVER_URL}/browse/config`);
if (response.ok) {
cachedConfig = await response.json();
serverReachable = true;
} else {
throw new Error('Server returned error');
}
} catch (error) {
console.log(`[Page Capture] Failed to load config: ${error.message}`);
serverReachable = false;
cachedConfig = cachedConfig || DEFAULT_CONFIG;
}
return cachedConfig;
}
async function saveConfig(config) {
try {
const response = await fetch(`${SERVER_URL}/browse/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (response.ok) {
cachedConfig = config;
serverReachable = true;
return true;
}
} catch (error) {
console.log(`[Page Capture] Failed to save config: ${error.message}`);
serverReachable = false;
}
return false;
}
function getConfig() {
return cachedConfig || DEFAULT_CONFIG;
}
function extractDomain(url) {
try {
const parsed = new URL(url);
return parsed.hostname;
} catch {
return null;
}
}
function isWhitelisted(domain) {
const config = getConfig();
return config.whitelist.some(d => domain === d || domain.endsWith('.' + d));
}
function isBlacklisted(domain) {
const config = getConfig();
return config.blacklist.some(d => domain === d || domain.endsWith('.' + d));
}
function getDomainStatus(domain) {
const config = getConfig();
if (isBlacklisted(domain)) return 'blacklisted';
if (config.mode === 'all') return 'capturing';
if (isWhitelisted(domain)) return 'whitelisted';
return 'unknown';
}
function shouldCapture(domain) {
const config = getConfig();
if (!config.enabled) return false;
if (isBlacklisted(domain)) return false;
if (config.mode === 'all') return true;
return isWhitelisted(domain);
}
// Badge management
async function setBadge(tabId, type) {
try {
if (type === 'needs-approval') {
await chrome.action.setBadgeText({ tabId, text: '?' });
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#F59E0B' });
} else if (type === 'server-error') {
await chrome.action.setBadgeText({ tabId, text: '!' });
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#EF4444' });
} else {
await chrome.action.setBadgeText({ tabId, text: '' });
}
} catch (error) {
console.log(`[Page Capture] Failed to set badge: ${error.message}`);
}
}
async function updateBadgeForTab(tabId, url) {
if (!serverReachable) {
await setBadge(tabId, 'server-error');
return;
}
const domain = extractDomain(url);
if (!domain) {
await setBadge(tabId, 'clear');
return;
}
const status = getDomainStatus(domain);
if (status === 'unknown') {
await setBadge(tabId, 'needs-approval');
} else {
await setBadge(tabId, 'clear');
}
}
// Content hashing
async function hashContent(content) {
const encoder = new TextEncoder();
const data = encoder.encode(content);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
function isValidUrl(url) {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
async function capturePageContent(tabId) {
try {
const results = await chrome.scripting.executeScript({
target: { tabId },
func: () => document.body.innerText
});
return results[0]?.result || '';
} catch (error) {
console.log(`[Page Capture] Failed to capture content: ${error.message}`);
return null;
}
}
async function sendToServer(data) {
try {
const response = await fetch(`${SERVER_URL}/capture`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
serverReachable = response.ok;
return response.ok;
} catch (error) {
console.log(`[Page Capture] Failed to send to server: ${error.message}`);
serverReachable = false;
return false;
}
}
async function captureTab(tabId, tab) {
const content = await capturePageContent(tabId);
if (content === null) return false;
const hash = await hashContent(content);
const lastHash = contentHashMap.get(tab.url);
if (lastHash === hash) {
console.log(`[Page Capture] Content unchanged for: ${tab.url}`);
return true;
}
contentHashMap.set(tab.url, hash);
const payload = {
url: tab.url,
content,
timestamp: Date.now(),
title: tab.title || 'Untitled'
};
const success = await sendToServer(payload);
if (success) {
console.log(`[Page Capture] Captured: ${tab.url}`);
}
return success;
}
// Tab update listener
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (changeInfo.status !== 'complete') return;
if (!isValidUrl(tab.url)) {
console.log(`[Page Capture] Skipping non-http URL: ${tab.url}`);
return;
}
const domain = extractDomain(tab.url);
if (!domain) return;
await updateBadgeForTab(tabId, tab.url);
if (!shouldCapture(domain)) {
console.log(`[Page Capture] Skipping (not whitelisted): ${tab.url}`);
return;
}
await captureTab(tabId, tab);
});
// Tab activated listener - update badge
chrome.tabs.onActivated.addListener(async (activeInfo) => {
try {
const tab = await chrome.tabs.get(activeInfo.tabId);
if (tab.url && isValidUrl(tab.url)) {
await updateBadgeForTab(activeInfo.tabId, tab.url);
}
} catch (error) {
console.log(`[Page Capture] Failed to update badge on tab switch: ${error.message}`);
}
});
// Handle scroll capture messages from content script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'SCROLL_CAPTURE') {
const { url, content, timestamp, title, scrollY } = message;
const domain = extractDomain(url);
if (!shouldCapture(domain)) {
console.log(`[Page Capture] Skipping scroll capture (not whitelisted): ${url}`);
return;
}
console.log(`[Page Capture] Received scroll capture for: ${url}`);
hashContent(content).then(async (hash) => {
const lastHash = contentHashMap.get(url);
if (lastHash === hash) {
console.log(`[Page Capture] Hash unchanged, skipping: ${url}`);
return;
}
contentHashMap.set(url, hash);
const payload = { url, content, timestamp, title };
const success = await sendToServer(payload);
if (success) {
console.log(`[Page Capture] Scroll captured (y=${scrollY}): ${url}`);
}
});
return;
}
// Handle messages from popup
if (message.type === 'GET_CONFIG') {
loadConfig().then(config => {
sendResponse({ config, serverReachable });
});
return true;
}
if (message.type === 'SAVE_CONFIG') {
saveConfig(message.config).then(success => {
sendResponse({ success });
// Update badges on all tabs
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'GET_DOMAIN_STATUS') {
const domain = extractDomain(message.url);
const status = domain ? getDomainStatus(domain) : 'unknown';
sendResponse({ status, domain, serverReachable });
return true;
}
if (message.type === 'APPROVE_DOMAIN') {
const config = getConfig();
const domain = message.domain;
if (!config.whitelist.includes(domain)) {
config.whitelist.push(domain);
}
config.blacklist = config.blacklist.filter(d => d !== domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'REJECT_DOMAIN') {
const config = getConfig();
const domain = message.domain;
if (!config.blacklist.includes(domain)) {
config.blacklist.push(domain);
}
config.whitelist = config.whitelist.filter(d => d !== domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'CAPTURE_ONCE') {
chrome.tabs.query({ active: true, currentWindow: true }, async tabs => {
if (tabs[0]) {
const success = await captureTab(tabs[0].id, tabs[0]);
sendResponse({ success });
} else {
sendResponse({ success: false });
}
});
return true;
}
if (message.type === 'REMOVE_FROM_WHITELIST') {
const config = getConfig();
config.whitelist = config.whitelist.filter(d => d !== message.domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'REMOVE_FROM_BLACKLIST') {
const config = getConfig();
config.blacklist = config.blacklist.filter(d => d !== message.domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
});
// Load config on startup
loadConfig().then(() => {
console.log('[Page Capture] Config loaded');
});
console.log('[Page Capture] Service worker started');

View file

@ -0,0 +1,81 @@
const DEBOUNCE_MS = 800;
const MIN_SCROLL_PIXELS = 500;
const MIN_CONTENT_CHANGE = 100; // characters
let debounceTimer = null;
let lastCapturedContent = null;
let lastScrollTop = 0;
let scrollContainer = null;
function getScrollTop() {
if (!scrollContainer || scrollContainer === window) {
return window.scrollY;
}
if (scrollContainer === document) {
return document.documentElement.scrollTop;
}
return scrollContainer.scrollTop || 0;
}
function captureAndSend() {
const content = document.body.innerText;
// Skip if content unchanged or minimal change
if (lastCapturedContent) {
const lengthDiff = Math.abs(content.length - lastCapturedContent.length);
if (content === lastCapturedContent || lengthDiff < MIN_CONTENT_CHANGE) {
return;
}
}
lastCapturedContent = content;
lastScrollTop = getScrollTop();
chrome.runtime.sendMessage({
type: 'SCROLL_CAPTURE',
url: window.location.href,
title: document.title,
content: content,
timestamp: Date.now(),
scrollY: lastScrollTop
});
}
function onScroll() {
const currentScrollTop = getScrollTop();
const scrollDelta = Math.abs(currentScrollTop - lastScrollTop);
if (scrollDelta < MIN_SCROLL_PIXELS) {
return;
}
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
captureAndSend();
}, DEBOUNCE_MS);
}
function init() {
// Use document with capture to catch scroll events from any element
document.addEventListener('scroll', (e) => {
const target = e.target;
const scrollTop = target === document ? document.documentElement.scrollTop : target.scrollTop;
// Update scroll container if we found the real one
if (scrollTop > 0 && scrollContainer !== target) {
scrollContainer = target;
}
onScroll();
}, { capture: true, passive: true });
}
// Wait for page to be ready, then init
if (document.readyState === 'complete') {
init();
} else {
window.addEventListener('load', init);
}

Some files were not shown because too many files have changed in this diff Show more