diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml new file mode 100644 index 00000000..f7c25752 --- /dev/null +++ b/.github/workflows/electron-build.yml @@ -0,0 +1,122 @@ +name: Build Electron App + +on: + release: + types: [published] + +permissions: + contents: write # Required to upload release assets + +jobs: + build: + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + cache-dependency-path: 'apps/x/pnpm-lock.yaml' + + - name: Extract version from tag + id: version + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Extracted version: ${VERSION}" + + - name: Update package.json versions + run: | + node -e " + const fs = require('fs'); + const version = '${{ steps.version.outputs.version }}'; + + // Update apps/x/package.json + const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); + rootPackage.version = version; + fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); + + // Update apps/x/apps/main/package.json + const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); + mainPackage.version = version; + fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); + + console.log('Updated version to:', version); + " + + - name: Import Code Signing Certificate + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + # Create a temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + # Create keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Decode and import certificate + echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12 + security import $RUNNER_TEMP/certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + + # Allow codesign to access the keychain + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Add keychain to search list + security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain + + # Verify certificate was imported + security find-identity -v "$KEYCHAIN_PATH" + + # Clean up certificate file + rm -f $RUNNER_TEMP/certificate.p12 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: apps/x + + - name: Build and publish to S3 + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} + VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} + run: npm run publish + working-directory: apps/x/apps/main + + - name: Upload workflow artifacts + uses: actions/upload-artifact@v4 + with: + name: distributables + path: apps/x/apps/main/out/make/* + retention-days: 30 + + - name: Attach files to GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: apps/x/apps/main/out/make/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Cleanup keychain + if: always() + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + if [ -f "$KEYCHAIN_PATH" ]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi diff --git a/README.md b/README.md index 8dda9c59..09fd5f95 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ -![ui](https://github.com/user-attachments/assets/fdf0679f-b107-48ce-b326-04db752838d9) +Work knowledge graph

- rowboatlabs%2Frowboat | Trendshift + rowboatlabs/rowboat | Trendshift

- - Discord - - + Website + + Discord + Twitter @@ -23,134 +23,80 @@

-# RowboatX - Claude Code for Everyday Automations +# Rowboat +**An open-source, local-first AI coworker with memory for everyday work** +
-RowboatX is a local-first CLI for creating background AI agents with full shell access. +Rowboat connects your email and meeting notes, builds long-lived knowledge from them, and uses that knowledge to help get work done on your machine. -**Example agents you can create:** -- Research every person before your meetings (Exa search MCP + Google Calendar MCP) -- Daily podcast summarizing your saved articles (ElevenLabs MCP + ffmpeg) -- Auto-triage Slack DMs and draft responses while you sleep (Slack MCP) - -## Quick start -```bash -npx @rowboatlabs/rowboatx@latest -``` +--- ## Demo -[![Screenshot](https://github.com/user-attachments/assets/ab46ff8b-44bd-400e-beb0-801c6431033f)](https://www.youtube.com/watch?v=cyPBinQzicY&t) -## Examples -### Add and Manage MCP servers -`$ rowboatx` -- Add MCP: 'Add this MCP server config: \ ' -- Explore tools: 'What tools are there in \ ' +[![Demo video](https://github.com/user-attachments/assets/f378285b-4ef3-4a4b-aa20-7dbb664e496c)](https://www.youtube.com/watch?v=T2Bmiy05FrI) -### Create background agents -`$ rowboatx` -- 'Create agent to do X.' -- '... Attach the correct tools from \ to the agent' -- '... Allow the agent to run shell commands including ffmpeg' +--- -### Schedule and monitor agents -`$ rowboatx` -- 'Make agent \ run every day at 10 AM' -- 'What agents do I have scheduled to run and at what times' -- 'When was \ last run' -- 'Are any agents waiting for my input or confirmation' +## Quick start -### Run background agents manually -``` bash -rowboatx --agent= --input="xyz" --no-interactive=true -``` -```bash -rowboatx --agent= --run_id= # resume from a previous run -``` -## Models support -You can configure your models using: -```bash -rowboatx model-config -``` +**Download for Mac:** -Alternatively, you can directly edit `~/.rowboat/config/models.json` -```json -{ - "providers": { - "openai": { - "flavor": "openai" - }, - "lm-studio": { - "flavor": "openai-compatible", - "baseURL": "http://localhost:2000/...", - "apiKey": "...", - "headers": { - "foo": "bar" - } - }, - "anthropic": { - "flavor": "anthropic" - }, - "google": { - "flavor": "google" - }, - "ollama": { - "flavor": "ollama" - } - }, - "defaults": { - "provider": "lm-studio", - "model": "gpt-5" - } -} -``` -## Contributing +https://github.com/rowboatlabs/rowboat/releases/latest -We want help with: +## What it does -- **Agent templates** - Pre-built agents others can use (podcast generator, meeting prep, etc.) -- **MCP server integrations** - Add support for new tools -- **Platform support** - Windows improvements, Linux edge cases +Rowboat ingests your: +- **Email** (Gmail) +- **Meeting notes** (Granola, Fireflies) -```bash -git clone git@github.com:rowboatlabs/rowboat.git -cd rowboat -npm install -npm run build -npm link -rowboatx -``` +and organizes them into a local, Obsidian-compatible vault of plain Markdown files with backlinks. -Ping us on [Discord](https://discord.com/invite/rxB8pzHxaS) if you want to discuss before building. +This vault is not just for browsing or search. It becomes a working memory that Rowboat’s AI uses to take actions on your behalf. ---- -## Prefer a Web UI: Rowboat Studio +As new emails and meetings come in, the relevant notes update automatically, building persistent context across people, projects, organizations, and topics. -*Cursor for Multi-agent Workflows* +--- -⚡ Build AI agents instantly with natural language | 🔌 Connect tools with one-click integrations | 📂 Power with knowledge by adding documents for RAG | 🔄 Automate workflows by setting up triggers and actions | 🚀 Deploy anywhere via API or SDK

+## How it’s different -### Quick start -1. Set your OpenAI key - ```bash - export OPENAI_API_KEY=your-openai-api-key - ``` - -2. Clone the repository and start Rowboat (requires Docker) - ```bash - ./start.sh - ``` +Most AI tools reconstruct context on demand by searching transcripts or documents. -3. Access the app at [http://localhost:3000](http://localhost:3000). +Rowboat maintains **long-lived knowledge** instead: +- context accumulates over time +- relationships are explicit and inspectable +- notes are editable by you, not hidden inside a model +- everything lives on your machine as plain Markdown + +The result is memory that compounds, rather than retrieval that starts cold every time. + +--- + +## What you can do with it + +Rowboat uses this knowledge to help with everyday work, including: + +- Drafting emails using accumulated context +- Preparing for meetings from prior decisions and discussions +- Organizing files and project artifacts as work evolves +- Running shell commands or scripts as agent actions +- Extending capabilities via external tools and MCP servers + +Actions are explicit and grounded in the current state of your knowledge. + +--- + +## Local-first by design + +- All data is stored locally as plain Markdown +- No proprietary formats or hosted lock-in +- Works with local models via Ollama or LM Studio, or hosted models if you prefer +- You can inspect, edit, back up, or delete everything at any time -#### Create a multi-agent assistant with MCP tools by chatting with Rowboat -[![meeting-prep](https://github.com/user-attachments/assets/c8a41622-8e0e-459f-becb-767503489866)](https://youtu.be/KZTP4xZM2DY) -See [Docs](https://docs.rowboatlabs.com/) for more details. - ---
Made with ❤️ by the Rowboat team -[Discord](https://discord.gg/rxB8pzHxaS) · [Twitter](https://x.com/intent/user?screen_name=rowboatlabshq) +[Discord](https://discord.com/invite/htdKpBZF) · [Twitter](https://x.com/intent/user?screen_name=rowboatlabshq)
diff --git a/apps/x/apps/main/.gitignore b/apps/x/apps/main/.gitignore index 04c01ba7..28cae713 100644 --- a/apps/x/apps/main/.gitignore +++ b/apps/x/apps/main/.gitignore @@ -1,2 +1,5 @@ node_modules/ -dist/ \ No newline at end of file +dist/ +# Staging directory for Electron Forge packaging (contains bundled main process, copied preload/renderer) +.package/ +out/ \ No newline at end of file diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs new file mode 100644 index 00000000..2444e356 --- /dev/null +++ b/apps/x/apps/main/bundle.mjs @@ -0,0 +1,37 @@ +/** + * Bundles the compiled main process into a single JavaScript file. + * + * Why we bundle: + * - pnpm uses symlinks for workspace packages (@x/core, @x/shared) + * - Electron Forge's dependency walker (flora-colossus) cannot follow these symlinks + * - Bundling inlines all dependencies into a single file, eliminating node_modules + * + * This script is called by the generateAssets hook in forge.config.js before packaging. + */ + +import * as esbuild from 'esbuild'; + +// In CommonJS, import.meta.url doesn't exist. We need to polyfill it. +// The banner defines __import_meta_url at the top of the bundle, +// and we use define to replace all import.meta.url references with it. +const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`; + +await esbuild.build({ + entryPoints: ['./dist/main.js'], + bundle: true, + platform: 'node', + target: 'node20', + outfile: './.package/dist/main.cjs', + external: ['electron'], // Provided by Electron runtime + // Use CommonJS format - many dependencies use require() which doesn't work + // well with esbuild's ESM shim. CJS handles dynamic requires natively. + format: 'cjs', + // Inject the polyfill variable at the top + banner: { js: cjsBanner }, + // Replace import.meta.url directly with our polyfill variable + define: { + 'import.meta.url': '__import_meta_url', + }, +}); + +console.log('✅ Main process bundled to .package/dist-bundle/main.js'); diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs new file mode 100644 index 00000000..3a2b340f --- /dev/null +++ b/apps/x/apps/main/forge.config.cjs @@ -0,0 +1,144 @@ +// Electron Forge config file +// NOTE: Must be .cjs (CommonJS) because package.json has "type": "module" +// Forge loads configs with require(), which fails on ESM files + +const path = require('path'); + +module.exports = { + packagerConfig: { + name: 'Rowboat', + executableName: 'rowboat', + icon: './icons/icon', // .icns extension added automatically + appBundleId: 'com.rowboat.app', + appCategoryType: 'public.app-category.productivity', + osxSign: { + batchCodesignCalls: true, + }, + osxNotarize: { + appleId: process.env.APPLE_ID, + appleIdPassword: process.env.APPLE_PASSWORD, + teamId: process.env.APPLE_TEAM_ID + }, + // NOTE: Electron Forge ignores packagerConfig.dir and always packages from the + // config file's directory. We use packageAfterCopy hook instead to customize output. + // dir: path.join(__dirname, '.package'), // Not supported by Forge + // Since we bundle everything with esbuild, we don't need node_modules at all. + // These settings prevent Forge's dependency walker (flora-colossus) from trying + // to analyze/copy node_modules, which fails with pnpm's symlinked workspaces. + prune: false, + ignore: [ + /src\//, + /node_modules\//, + /.gitignore/, + /bundle\.mjs/, + /tsconfig.json/, + ], + }, + makers: [ + { + name: '@electron-forge/maker-dmg', + config: (arch) => ({ + format: 'ULFO', + name: `Rowboat-${arch}`, // Architecture-specific name to avoid conflicts + }) + }, + { + name: '@electron-forge/maker-zip', + platforms: ['darwin'], + // ZIP is used by Squirrel.Mac for auto-updates + config: (arch) => ({ + // Path must match S3 publisher's folder structure: releases/darwin/{arch} + macUpdateManifestBaseUrl: `https://rowboat-desktop-app-releases.s3.amazonaws.com/releases/darwin/${arch}` + }) + } + ], + publishers: [ + { + name: '@electron-forge/publisher-s3', + config: { + bucket: 'rowboat-desktop-app-releases', + region: 'us-east-1', + public: true, + folder: 'releases' // Creates structure: releases/darwin/{arch}/files (separate builds for arm64 and x64) + } + } + ], + hooks: { + // Hook signature: (forgeConfig, platform, arch) + // Note: Console output only shows if DEBUG or CI env vars are set + generateAssets: async (forgeConfig, platform, arch) => { + const { execSync } = require('child_process'); + const fs = require('fs'); + + const packageDir = path.join(__dirname, '.package'); + + // Clean staging directory (ensures fresh build every time) + console.log('Cleaning staging directory...'); + if (fs.existsSync(packageDir)) { + fs.rmSync(packageDir, { recursive: true }); + } + fs.mkdirSync(packageDir, { recursive: true }); + + // Build order matters! Dependencies must be built before dependents: + // shared → core → (renderer, preload, main) + + // Build shared (TypeScript compilation) - no dependencies + console.log('Building shared...'); + execSync('pnpm run build', { + cwd: path.join(__dirname, '../../packages/shared'), + stdio: 'inherit' + }); + + // Build core (TypeScript compilation) - depends on shared + console.log('Building core...'); + execSync('pnpm run build', { + cwd: path.join(__dirname, '../../packages/core'), + stdio: 'inherit' + }); + + // Build renderer (Vite build) - depends on shared + console.log('Building renderer...'); + execSync('pnpm run build', { + cwd: path.join(__dirname, '../renderer'), + stdio: 'inherit' + }); + + // Build preload (TypeScript compilation) - depends on shared + console.log('Building preload...'); + execSync('pnpm run build', { + cwd: path.join(__dirname, '../preload'), + stdio: 'inherit' + }); + + // Build main (TypeScript compilation) - depends on core, shared + console.log('Building main (tsc)...'); + execSync('pnpm run build', { + cwd: __dirname, + stdio: 'inherit' + }); + + // Bundle main process with esbuild (inlines all dependencies) + console.log('Bundling main process...'); + execSync('node bundle.mjs', { + cwd: __dirname, + stdio: 'inherit' + }); + + // Copy preload dist into staging directory + console.log('Copying preload...'); + const preloadSrc = path.join(__dirname, '../preload/dist'); + const preloadDest = path.join(packageDir, 'preload/dist'); + fs.mkdirSync(preloadDest, { recursive: true }); + fs.cpSync(preloadSrc, preloadDest, { recursive: true }); + + // Copy renderer dist into staging directory + console.log('Copying renderer...'); + const rendererSrc = path.join(__dirname, '../renderer/dist'); + const rendererDest = path.join(packageDir, 'renderer/dist'); + fs.mkdirSync(rendererDest, { recursive: true }); + fs.cpSync(rendererSrc, rendererDest, { recursive: true }); + + console.log('✅ All assets staged in .package/'); + }, + } +}; \ No newline at end of file diff --git a/apps/x/apps/main/icons/icon.icns b/apps/x/apps/main/icons/icon.icns new file mode 100644 index 00000000..3ea66b8f Binary files /dev/null and b/apps/x/apps/main/icons/icon.icns differ diff --git a/apps/x/apps/main/icons/icon.png b/apps/x/apps/main/icons/icon.png new file mode 100644 index 00000000..e2fd6386 Binary files /dev/null and b/apps/x/apps/main/icons/icon.png differ diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 0a69adec..676f7269 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -1,19 +1,31 @@ { - "name": "@x/main", + "name": "Rowboat", "type": "module", - "main": "dist/main.js", + "version": "0.1.0", + "main": ".package/dist/main.cjs", "scripts": { "start": "electron .", - "build": "rm -rf dist && tsc" + "build": "rm -rf dist && tsc && node bundle.mjs", + "package": "electron-forge package --arch=arm64,x64 --platform=darwin", + "make": "electron-forge make --arch=arm64,x64 --platform=darwin", + "publish": "electron-forge publish --arch=arm64,x64 --platform=darwin" }, "dependencies": { "@x/core": "workspace:*", "@x/shared": "workspace:*", "chokidar": "^4.0.3", + "update-electron-app": "^3.1.2", "zod": "^4.2.1" }, "devDependencies": { "@types/node": "^25.0.3", - "electron": "^39.2.7" + "electron": "^39.2.7", + "esbuild": "^0.24.2", + "@electron-forge/cli": "^7.10.2", + "@electron-forge/maker-deb": "^7.10.2", + "@electron-forge/maker-dmg": "^7.10.2", + "@electron-forge/maker-squirrel": "^7.10.2", + "@electron-forge/maker-zip": "^7.10.2", + "@electron-forge/publisher-s3": "^7.10.2" } } \ No newline at end of file diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index b65dcc5c..01644e90 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -18,6 +18,8 @@ import z from 'zod'; import { RunEvent } from 'packages/shared/dist/runs.js'; import container from '@x/core/dist/di/container.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; +import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; +import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -209,6 +211,15 @@ function emitRunEvent(event: z.infer): void { } } +export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string }): void { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('oauth:didConnect', event); + } + } +} + let runsWatcher: (() => void) | null = null; export async function startRunsWatcher(): Promise { if (runsWatcher) { @@ -316,6 +327,21 @@ export function setupIpcHandlers() { 'granola:setConfig': async (_event, args) => { const repo = container.resolve('granolaConfigRepo'); await repo.setConfig({ enabled: args.enabled }); + + // Trigger sync immediately when enabled + if (args.enabled) { + triggerGranolaSync(); + } + + return { success: true }; + }, + 'onboarding:getStatus': async () => { + // Show onboarding if it hasn't been completed yet + const complete = isOnboardingComplete(); + return { showOnboarding: !complete }; + }, + 'onboarding:markComplete': async () => { + markOnboardingComplete(); return { success: true }; }, }); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index f5580a34..7ae6ed46 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -1,8 +1,9 @@ -import { app, BrowserWindow } from "electron"; +import { app, BrowserWindow, protocol, net, shell } from "electron"; import path from "node:path"; import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { dirname } from "node:path"; +import { updateElectronApp, UpdateSourceType } from "update-electron-app"; import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js"; import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.js"; import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js"; @@ -13,13 +14,55 @@ import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const preloadPath = path.join(__dirname, "../../preload/dist/preload.js"); +// Path resolution differs between development and production: +const preloadPath = app.isPackaged + ? path.join(__dirname, "../preload/dist/preload.js") + : path.join(__dirname, "../../../preload/dist/preload.js"); console.log("preloadPath", preloadPath); +const rendererPath = app.isPackaged + ? path.join(__dirname, "../renderer/dist") // Production + : path.join(__dirname, "../../../renderer/dist"); // Development +console.log("rendererPath", rendererPath); + +// Register custom protocol for serving built renderer files in production. +// This keeps SPA routes working when users deep link into the packaged app. +function registerAppProtocol() { + protocol.handle("app", (request) => { + const url = new URL(request.url); + + // url.pathname starts with "/" + let urlPath = url.pathname; + + // If it's "/" or a SPA route (no extension), serve index.html + if (urlPath === "/" || !path.extname(urlPath)) { + urlPath = "/index.html"; + } + + const filePath = path.join(rendererPath, urlPath); + return net.fetch(pathToFileURL(filePath).toString()); + }); +} + +protocol.registerSchemesAsPrivileged([ + { + scheme: "app", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + allowServiceWorkers: true, + // optional but often helpful: + // stream: true, + }, + }, +]); + function createWindow() { const win = new BrowserWindow({ - width: 800, - height: 600, + width: 1280, + height: 800, webPreferences: { // IMPORTANT: keep Node out of renderer nodeIntegration: false, @@ -29,10 +72,47 @@ function createWindow() { }, }); - win.loadURL("http://localhost:5173"); // load the dev server + // Open external links in system browser (not sandboxed Electron window) + // This handles window.open() and target="_blank" links + win.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url); + return { action: "deny" }; + }); + + // Handle navigation to external URLs (e.g., clicking a link without target="_blank") + win.webContents.on("will-navigate", (event, url) => { + const isInternal = + url.startsWith("app://") || url.startsWith("http://localhost:5173"); + if (!isInternal) { + event.preventDefault(); + shell.openExternal(url); + } + }); + + if (app.isPackaged) { + win.loadURL("app://-/index.html"); + } else { + win.loadURL("http://localhost:5173"); + } } app.whenReady().then(() => { + // Register custom protocol before creating window (for production builds) + if (app.isPackaged) { + registerAppProtocol(); + } + + // Initialize auto-updater (only in production) + if (app.isPackaged) { + updateElectronApp({ + updateSource: { + type: UpdateSourceType.StaticStorage, + baseUrl: `https://rowboat-desktop-app-releases.s3.amazonaws.com/releases/${process.platform}/${process.arch}`, + }, + notifyUser: true, // Shows native dialog when update is available + }); + } + setupIpcHandlers(); createWindow(); @@ -44,7 +124,6 @@ app.whenReady().then(() => { // Only starts once (guarded in startWorkspaceWatcher) startWorkspaceWatcher(); - // start runs watcher startRunsWatcher(); @@ -66,7 +145,7 @@ app.whenReady().then(() => { // start pre-built agent runner initPreBuiltRunner(); - app.on('activate', () => { + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 9201bac1..3e694daa 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -1,4 +1,4 @@ -import { BrowserWindow } from 'electron'; +import { shell } from 'electron'; import { createAuthServer } from './auth-server.js'; import * as oauthClient from '@x/core/dist/auth/oauth-client.js'; import type { Configuration } from '@x/core/dist/auth/oauth-client.js'; @@ -6,6 +6,10 @@ import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/prov import container from '@x/core/dist/di/container.js'; import { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IClientRegistrationRepo } from '@x/core/dist/auth/client-repo.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'; +import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js'; +import { emitOAuthEvent } from './ipc.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; @@ -110,6 +114,17 @@ export async function connectProvider(provider: string): Promise<{ success: bool // Store flow state activeFlows.set(state, { codeVerifier, provider, config }); + // Build authorization URL + const authUrl = oauthClient.buildAuthorizationUrl(config, { + redirectUri: REDIRECT_URI, + scope: scopes.join(' '), + codeChallenge, + state, + }); + + // Declare timeout variable (will be set after server is created) + let cleanupTimeout: NodeJS.Timeout; + // Create callback server const { server } = await createAuthServer(8080, async (code, receivedState) => { // Validate state @@ -138,42 +153,43 @@ export async function connectProvider(provider: string): Promise<{ success: bool // Save tokens console.log(`[OAuth] Token exchange successful for ${provider}`); await oauthRepo.saveTokens(provider, tokens); + + // Trigger immediate sync for relevant providers + if (provider === 'google') { + triggerGmailSync(); + triggerCalendarSync(); + } else if (provider === 'fireflies-ai') { + triggerFirefliesSync(); + } + + // Emit success event to renderer + emitOAuthEvent({ provider, success: true }); } catch (error) { console.error('OAuth token exchange failed:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + emitOAuthEvent({ provider, success: false, error: errorMessage }); throw error; } finally { // Clean up activeFlows.delete(state); server.close(); + clearTimeout(cleanupTimeout); } }); - // Build authorization URL - const authUrl = oauthClient.buildAuthorizationUrl(config, { - redirectUri: REDIRECT_URI, - scope: scopes.join(' '), - codeChallenge, - state, - }); + // Set timeout to clean up abandoned flows (5 minutes) + // This prevents memory leaks if user never completes the OAuth flow + cleanupTimeout = setTimeout(() => { + if (activeFlows.has(state)) { + console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`); + activeFlows.delete(state); + server.close(); + emitOAuthEvent({ provider, success: false, error: 'OAuth flow timed out' }); + } + }, 5 * 60 * 1000); // 5 minutes - // Open browser window - const authWindow = new BrowserWindow({ - width: 600, - height: 700, - show: true, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - }, - }); - - authWindow.loadURL(authUrl.toString()); - - // Clean up on window close - authWindow.on('closed', () => { - activeFlows.delete(state); - server.close(); - }); + // Open in system browser (shares cookies/sessions with user's regular browser) + shell.openExternal(authUrl.toString()); // Wait for callback (server will handle it) return { success: true }; diff --git a/apps/x/apps/renderer/index.html b/apps/x/apps/renderer/index.html index 6a924131..1803a850 100644 --- a/apps/x/apps/renderer/index.html +++ b/apps/x/apps/renderer/index.html @@ -4,7 +4,7 @@ - RowboatX + Rowboat
diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index 5d882f88..f7ebb083 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -25,6 +25,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-use-controllable-state": "^1.2.2", "@tailwindcss/vite": "^4.1.18", + "@tiptap/extension-image": "^3.16.0", "@tiptap/extension-link": "^3.15.3", "@tiptap/extension-placeholder": "^3.15.3", "@tiptap/extension-task-item": "^3.15.3", @@ -41,8 +42,10 @@ "lucide-react": "^0.562.0", "motion": "^12.23.26", "nanoid": "^5.1.6", + "posthog-js": "^1.332.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "sonner": "^2.0.7", "streamdown": "^1.6.10", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index baf61102..4fd02863 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -1,6 +1,9 @@ @import "tailwindcss"; @import "tw-animate-css"; +/* Required for Streamdown markdown rendering (bullet points, lists, etc.) */ +@source "../node_modules/streamdown/dist/*.js"; + @custom-variant dark (&:is(.dark *)); #root { @@ -165,6 +168,66 @@ } } +/* Markdown content base styles for Streamdown/MessageResponse */ +@layer components { + /* Target elements inside MessageResponse wrapper */ + [data-slot="message-content"] ul, + [data-slot="message-content"] ol { + @apply my-2 pl-5; + } + [data-slot="message-content"] ul { + @apply list-disc; + } + [data-slot="message-content"] ol { + @apply list-decimal; + } + [data-slot="message-content"] li { + @apply my-1; + } + [data-slot="message-content"] p { + @apply my-2 first:mt-0 last:mb-0; + } + [data-slot="message-content"] h1 { + @apply my-4 text-2xl font-bold first:mt-0; + } + [data-slot="message-content"] h2 { + @apply my-3 text-xl font-semibold first:mt-0; + } + [data-slot="message-content"] h3 { + @apply my-3 text-lg font-semibold first:mt-0; + } + [data-slot="message-content"] h4, + [data-slot="message-content"] h5, + [data-slot="message-content"] h6 { + @apply my-2 font-semibold first:mt-0; + } + [data-slot="message-content"] code:not(pre code) { + @apply rounded bg-muted px-1.5 py-0.5 font-mono text-sm; + } + [data-slot="message-content"] pre { + @apply my-3 overflow-x-auto rounded-lg; + } + [data-slot="message-content"] blockquote { + @apply my-3 border-l-4 border-border pl-4 italic text-muted-foreground; + } + [data-slot="message-content"] hr { + @apply my-4 border-border; + } + [data-slot="message-content"] a { + @apply text-primary underline underline-offset-2 hover:text-primary/80; + } + [data-slot="message-content"] table { + @apply my-3 w-full border-collapse; + } + [data-slot="message-content"] th, + [data-slot="message-content"] td { + @apply border border-border px-3 py-2 text-left; + } + [data-slot="message-content"] th { + @apply bg-muted font-semibold; + } +} + .graph-view { background-color: var(--background); user-select: none; diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 78433229..3ac0b24f 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1,12 +1,13 @@ import * as React from 'react' import { useCallback, useEffect, useState, useRef } from 'react' import { workspace } from '@x/shared'; -import { RunEvent } from '@x/shared/src/runs.js'; -import type { ChatStatus, LanguageModelUsage, ToolUIPart } from 'ai'; +import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; +import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; import { Button } from './components/ui/button'; -import { CheckIcon, LoaderIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon, SquarePen } from 'lucide-react'; +import { cn } from '@/lib/utils'; import { MarkdownEditor } from './components/markdown-editor'; import { ChatInputBar } from './components/chat-button'; import { ChatSidebar } from './components/chat-sidebar'; @@ -19,7 +20,6 @@ import { Conversation, ConversationContent, ConversationEmptyState, - ConversationScrollButton, } from '@/components/ai-elements/conversation'; import { Message, @@ -27,28 +27,19 @@ import { MessageResponse, } from '@/components/ai-elements/message'; import { - PromptInput, - PromptInputBody, - PromptInputFooter, type PromptInputMessage, - PromptInputSubmit, + PromptInputProvider, PromptInputTextarea, - PromptInputTools, + usePromptInputController, + type FileMention, } from '@/components/ai-elements/prompt-input'; import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'; import { Shimmer } from '@/components/ai-elements/shimmer'; import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'; -import { - Context, - ContextCacheUsage, - ContextContent, - ContextContentBody, - ContextContentHeader, - ContextInputUsage, - ContextOutputUsage, - ContextReasoningUsage, - ContextTrigger, -} from '@/components/ai-elements/context'; +import { PermissionRequest } from '@/components/ai-elements/permission-request'; +import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; +import { Suggestions } from '@/components/ai-elements/suggestions'; +import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; import { SidebarInset, SidebarProvider, @@ -56,10 +47,13 @@ import { } from "@/components/ui/sidebar" import { TooltipProvider } from "@/components/ui/tooltip" import { Separator } from "@/components/ui/separator" +import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' +import { OnboardingModal } from '@/components/onboarding-modal' type DirEntry = z.infer type RunEventType = z.infer +type ListRunsResponseType = z.infer interface TreeNode extends DirEntry { children?: TreeNode[] @@ -92,11 +86,6 @@ type ConversationItem = ChatMessage | ToolCall | ReasoningBlock; type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'; -const estimateTokens = (text: string) => { - if (!text) return 0 - return Math.ceil(text.trim().length / 4) -} - const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item const isReasoningBlock = (item: ConversationItem): item is ReasoningBlock => @@ -133,6 +122,41 @@ const graphPalette = [ const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) +// Parse attached files from message content and return clean message + file paths +const parseAttachedFiles = (content: string): { message: string; files: string[] } => { + const attachedFilesRegex = /\s*([\s\S]*?)\s*<\/attached-files>/ + const match = content.match(attachedFilesRegex) + + if (!match) { + return { message: content, files: [] } + } + + // Extract file paths from the XML + const filesXml = match[1] + const filePathRegex = //g + const files: string[] = [] + let fileMatch + while ((fileMatch = filePathRegex.exec(filesXml)) !== null) { + files.push(fileMatch[1]) + } + + // Remove the attached-files block + let cleanMessage = content.replace(attachedFilesRegex, '').trim() + + // Also remove @mentions for the attached files (they're shown as pills) + for (const filePath of files) { + // Get the display name (last part of path without extension) + const fileName = filePath.split('/').pop()?.replace(/\.md$/i, '') || '' + if (fileName) { + // Remove @filename pattern (with optional trailing space) + const mentionRegex = new RegExp(`@${fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi') + cleanMessage = cleanMessage.replace(mentionRegex, '') + } + } + + return { message: cleanMessage.trim(), files } +} + const untitledBaseName = 'untitled' const getHeadingTitle = (markdown: string) => { @@ -140,7 +164,8 @@ const getHeadingTitle = (markdown: string) => { for (const line of lines) { const match = line.match(/^#\s+(.+)$/) if (match) return match[1].trim() - if (line.trim() !== '') return null + const trimmed = line.trim() + if (trimmed !== '') return trimmed } return null } @@ -251,6 +276,103 @@ const collectDirPaths = (nodes: TreeNode[]): string[] => const collectFilePaths = (nodes: TreeNode[]): string[] => nodes.flatMap(n => n.kind === 'file' ? [n.path] : (n.children ? collectFilePaths(n.children) : [])) +// Inner component that uses the controller to access mentions +interface ChatInputInnerProps { + onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + isProcessing: boolean + presetMessage?: string + onPresetMessageConsumed?: () => void +} + +function ChatInputInner({ + onSubmit, + isProcessing, + presetMessage, + onPresetMessageConsumed, +}: ChatInputInnerProps) { + const controller = usePromptInputController() + const message = controller.textInput.value + const canSubmit = Boolean(message.trim()) && !isProcessing + + // Handle preset message from suggestions + useEffect(() => { + if (presetMessage) { + controller.textInput.setInput(presetMessage) + onPresetMessageConsumed?.() + } + }, [presetMessage, controller.textInput, onPresetMessageConsumed]) + + const handleSubmit = useCallback(() => { + if (!canSubmit) return + onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions) + controller.textInput.clear() + controller.mentions.clearMentions() + }, [canSubmit, message, onSubmit, controller]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSubmit() + } + }, [handleSubmit]) + + return ( +
+ + +
+ ) +} + +// Wrapper component with PromptInputProvider +interface ChatInputWithMentionsProps { + knowledgeFiles: string[] + recentFiles: string[] + visibleFiles: string[] + onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + isProcessing: boolean + presetMessage?: string + onPresetMessageConsumed?: () => void +} + +function ChatInputWithMentions({ + knowledgeFiles, + recentFiles, + visibleFiles, + onSubmit, + isProcessing, + presetMessage, + onPresetMessageConsumed, +}: ChatInputWithMentionsProps) { + return ( + + + + ) +} + function App() { // File browser state (for Knowledge section) const [selectedPath, setSelectedPath] = useState(null) @@ -266,7 +388,7 @@ function App() { }) const [graphStatus, setGraphStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle') const [graphError, setGraphError] = useState(null) - const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(false) + const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true) // Auto-save state const [isSaving, setIsSaving] = useState(false) @@ -280,10 +402,29 @@ function App() { const [conversation, setConversation] = useState([]) const [currentAssistantMessage, setCurrentAssistantMessage] = useState('') const [currentReasoning, setCurrentReasoning] = useState('') - const [modelUsage, setModelUsage] = useState(null) + const [, setModelUsage] = useState(null) const [runId, setRunId] = useState(null) const [isProcessing, setIsProcessing] = useState(false) const [agentId] = useState('copilot') + const [presetMessage, setPresetMessage] = useState(undefined) + + // Runs history state + type RunListItem = { id: string; title?: string; createdAt: string; agentId: string } + const [runs, setRuns] = useState([]) + + // Pending requests state + const [pendingPermissionRequests, setPendingPermissionRequests] = useState>>(new Map()) + const [pendingAskHumanRequests, setPendingAskHumanRequests] = useState>>(new Map()) + // Track ALL permission requests (for rendering with response status) + const [allPermissionRequests, setAllPermissionRequests] = useState>>(new Map()) + // Track permission responses (toolCallId -> response) + const [permissionResponses, setPermissionResponses] = useState>(new Map()) + + // Workspace root for full paths + const [workspaceRoot, setWorkspaceRoot] = useState('') + + // Onboarding state + const [showOnboarding, setShowOnboarding] = useState(false) // Load directory tree const loadDirectory = useCallback(async () => { @@ -306,11 +447,30 @@ function App() { // Listen to workspace change events useEffect(() => { - const cleanup = window.ipc.on('workspace:didChange', () => { + const cleanup = window.ipc.on('workspace:didChange', async (event) => { loadDirectory().then(setTree) + + // Reload current file if it was changed externally + if (!selectedPath) return + + const changedPath = event.type === 'changed' ? event.path : null + const changedPaths = (event.type === 'bulkChanged' ? event.paths : []) ?? [] + + const isCurrentFileChanged = + changedPath === selectedPath || changedPaths.includes(selectedPath) + + if (isCurrentFileChanged) { + // Only reload if no unsaved edits + if (editorContent === initialContentRef.current) { + const result = await window.ipc.invoke('workspace:readFile', { path: selectedPath }) + setFileContent(result.data) + setEditorContent(result.data) + initialContentRef.current = result.data + } + } }) return cleanup - }, [loadDirectory]) + }, [loadDirectory, selectedPath, editorContent]) // Load file content when selected useEffect(() => { @@ -399,6 +559,168 @@ function App() { saveFile() }, [debouncedContent, selectedPath]) + // Load runs list (all pages) + const loadRuns = useCallback(async () => { + try { + const allRuns: RunListItem[] = [] + let cursor: string | undefined = undefined + + // Fetch all pages + do { + const result: ListRunsResponseType = await window.ipc.invoke('runs:list', { cursor }) + allRuns.push(...result.runs) + cursor = result.nextCursor + } while (cursor) + + // Filter for copilot runs only + const copilotRuns = allRuns.filter((run: RunListItem) => run.agentId === 'copilot') + setRuns(copilotRuns) + } catch (err) { + console.error('Failed to load runs:', err) + } + }, []) + + // Load runs on mount + useEffect(() => { + loadRuns() + }, [loadRuns]) + + // Load a specific run and populate conversation + const loadRun = useCallback(async (id: string) => { + try { + const run = await window.ipc.invoke('runs:fetch', { runId: id }) + + // Parse the log events into conversation items + const items: ConversationItem[] = [] + const toolCallMap = new Map() + + for (const event of run.log) { + switch (event.type) { + case 'message': { + const msg = event.message + if (msg.role === 'user' || msg.role === 'assistant') { + // Extract text content from message + let textContent = '' + if (typeof msg.content === 'string') { + textContent = msg.content + } else if (Array.isArray(msg.content)) { + // Extract text parts + textContent = msg.content + .filter((part: { type: string }) => part.type === 'text') + .map((part: { type: string; text?: string }) => part.text || '') + .join('') + + // Also extract tool-call parts from assistant messages + if (msg.role === 'assistant') { + for (const part of msg.content) { + if (part.type === 'tool-call') { + const toolCall: ToolCall = { + id: part.toolCallId, + name: part.toolName, + input: normalizeToolInput(part.arguments), + status: 'pending', + timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(), + } + toolCallMap.set(toolCall.id, toolCall) + items.push(toolCall) + } + } + } + } + if (textContent) { + items.push({ + id: event.messageId, + role: msg.role, + content: textContent, + timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(), + }) + } + } + break + } + case 'tool-invocation': { + // Update existing tool call status or create new one + const existingTool = event.toolCallId ? toolCallMap.get(event.toolCallId) : null + if (existingTool) { + existingTool.input = normalizeToolInput(event.input) + existingTool.status = 'running' + } else { + const toolCall: ToolCall = { + id: event.toolCallId || `tool-${Date.now()}-${Math.random()}`, + name: event.toolName, + input: normalizeToolInput(event.input), + status: 'running', + timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(), + } + toolCallMap.set(toolCall.id, toolCall) + items.push(toolCall) + } + break + } + case 'tool-result': { + const existingTool = event.toolCallId ? toolCallMap.get(event.toolCallId) : null + if (existingTool) { + existingTool.result = event.result + existingTool.status = 'completed' + } + break + } + case 'llm-stream-event': { + // We don't need to reconstruct streaming events for history + // Reasoning is captured in the final message + break + } + } + } + + // Track permission requests and responses from history + const allPermissionRequests = new Map>() + const permResponseMap = new Map() + const askHumanRequests = new Map>() + const respondedAskHumanIds = new Set() + + for (const event of run.log) { + if (event.type === 'tool-permission-request') { + allPermissionRequests.set(event.toolCall.toolCallId, event) + } else if (event.type === 'tool-permission-response') { + permResponseMap.set(event.toolCallId, event.response) + } else if (event.type === 'ask-human-request') { + askHumanRequests.set(event.toolCallId, event) + } else if (event.type === 'ask-human-response') { + respondedAskHumanIds.add(event.toolCallId) + } + } + + // Separate pending vs responded permission requests + const pendingPerms = new Map>() + for (const [id, req] of allPermissionRequests.entries()) { + if (!permResponseMap.has(id)) { + pendingPerms.set(id, req) + } + } + + const pendingAsks = new Map>() + for (const [id, req] of askHumanRequests.entries()) { + if (!respondedAskHumanIds.has(id)) { + pendingAsks.set(id, req) + } + } + + // Set the conversation and runId + setConversation(items) + setRunId(id) + setCurrentAssistantMessage('') + setCurrentReasoning('') + setMessage('') + setPendingPermissionRequests(pendingPerms) + setPendingAskHumanRequests(pendingAsks) + setAllPermissionRequests(allPermissionRequests) + setPermissionResponses(permResponseMap) + } catch (err) { + console.error('Failed to load run:', err) + } + }, []) + // Listen to run events useEffect(() => { const cleanup = window.ipc.on('runs:events', ((event: unknown) => { @@ -550,6 +872,54 @@ function App() { break } + case 'tool-permission-request': { + const key = event.toolCall.toolCallId + setPendingPermissionRequests(prev => { + const next = new Map(prev) + next.set(key, event) + return next + }) + setAllPermissionRequests(prev => { + const next = new Map(prev) + next.set(key, event) + return next + }) + break + } + + case 'tool-permission-response': { + setPendingPermissionRequests(prev => { + const next = new Map(prev) + next.delete(event.toolCallId) + return next + }) + setPermissionResponses(prev => { + const next = new Map(prev) + next.set(event.toolCallId, event.response) + return next + }) + break + } + + case 'ask-human-request': { + const key = event.toolCallId + setPendingAskHumanRequests(prev => { + const next = new Map(prev) + next.set(key, event) + return next + }) + break + } + + case 'ask-human-response': { + setPendingAskHumanRequests(prev => { + const next = new Map(prev) + next.delete(event.toolCallId) + return next + }) + break + } + case 'error': setIsProcessing(false) console.error('Run error:', event.error) @@ -557,9 +927,10 @@ function App() { } } - const handlePromptSubmit = async ({ text }: PromptInputMessage) => { + const handlePromptSubmit = async (message: PromptInputMessage, mentions?: FileMention[]) => { if (isProcessing) return + const { text } = message; const userMessage = text.trim() if (!userMessage) return @@ -575,23 +946,96 @@ function App() { try { let currentRunId = runId + let isNewRun = false if (!currentRunId) { const run = await window.ipc.invoke('runs:create', { agentId, }) currentRunId = run.id setRunId(currentRunId) + isNewRun = true + } + + // Read mentioned file contents and format message with XML context + let formattedMessage = userMessage + if (mentions && mentions.length > 0) { + const attachedFiles = await Promise.all( + mentions.map(async (m) => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: m.path }) + return { path: m.path, content: result.data as string } + } catch (err) { + console.error('Failed to read mentioned file:', m.path, err) + return { path: m.path, content: `[Error reading file: ${m.path}]` } + } + }) + ) + + if (attachedFiles.length > 0) { + const filesXml = attachedFiles + .map(f => `\n${f.content}\n`) + .join('\n') + formattedMessage = `\n${filesXml}\n\n\n${userMessage}` + } } await window.ipc.invoke('runs:createMessage', { runId: currentRunId, - message: userMessage, + message: formattedMessage, }) + + // Refresh runs list after message is sent (so title is available) + if (isNewRun) { + loadRuns() + } } catch (error) { console.error('Failed to send message:', error) } } + const handlePermissionResponse = useCallback(async (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => { + if (!runId) return + + // Optimistically update the UI immediately + setPermissionResponses(prev => { + const next = new Map(prev) + next.set(toolCallId, response) + return next + }) + setPendingPermissionRequests(prev => { + const next = new Map(prev) + next.delete(toolCallId) + return next + }) + + try { + await window.ipc.invoke('runs:authorizePermission', { + runId, + authorization: { subflow, toolCallId, response } + }) + } catch (error) { + console.error('Failed to authorize permission:', error) + // Revert the optimistic update on error + setPermissionResponses(prev => { + const next = new Map(prev) + next.delete(toolCallId) + return next + }) + } + }, [runId]) + + const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => { + if (!runId) return + try { + await window.ipc.invoke('runs:provideHumanInput', { + runId, + reply: { subflow, toolCallId, response } + }) + } catch (error) { + console.error('Failed to provide human input:', error) + } + }, [runId]) + const handleNewChat = useCallback(() => { setConversation([]) setCurrentAssistantMessage('') @@ -599,14 +1043,74 @@ function App() { setRunId(null) setMessage('') setModelUsage(null) + setPendingPermissionRequests(new Map()) + setPendingAskHumanRequests(new Map()) + setAllPermissionRequests(new Map()) + setPermissionResponses(new Map()) }, []) const handleChatInputSubmit = (text: string) => { setIsChatSidebarOpen(true) // Submit immediately - the sidebar will open and show the message - handlePromptSubmit({ text }) + handlePromptSubmit({ text, files: [] }) } + const handleOpenFullScreenChat = useCallback(() => { + setSelectedPath(null) + setIsGraphOpen(false) + }, []) + + // Handle image upload for the markdown editor + const handleImageUpload = useCallback(async (file: File): Promise => { + try { + // Read file as data URL (includes mime type) + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = reject + reader.readAsDataURL(file) + }) + + // Also save to .assets folder for persistence + const timestamp = Date.now() + const extension = file.name.split('.').pop() || 'png' + const filename = `image-${timestamp}.${extension}` + const assetsPath = 'knowledge/.assets' + const imagePath = `${assetsPath}/${filename}` + + try { + // Extract base64 data (remove data URL prefix) + const base64Data = dataUrl.split(',')[1] + await window.ipc.invoke('workspace:writeFile', { + path: imagePath, + data: base64Data, + opts: { encoding: 'base64', mkdirp: true } + }) + } catch (err) { + console.error('Failed to save image to disk:', err) + // Continue anyway - image will still display via data URL + } + + // Return data URL for immediate display in editor + return dataUrl + } catch (error) { + console.error('Failed to upload image:', error) + return null + } + }, []) + + // Keyboard shortcut: Ctrl+L to open main chat view + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'l') { + e.preventDefault() + handleOpenFullScreenChat() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [handleOpenFullScreenChat]) + const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { setSelectedPath(path) @@ -623,9 +1127,9 @@ function App() { setExpandedPaths(newExpanded) } - // Handle sidebar section changes - switch to chat view for agents + // Handle sidebar section changes - switch to chat view for tasks const handleSectionChange = useCallback((section: ActiveSection) => { - if (section === 'agents') { + if (section === 'tasks') { setSelectedPath(null) setIsGraphOpen(false) } @@ -644,14 +1148,61 @@ function App() { }, []) ), [knowledgeFiles]) - // Get workspace root for full paths - const [workspaceRoot, setWorkspaceRoot] = useState('') + // Compute visible files (files whose parent directories are expanded) + const visibleKnowledgeFiles = React.useMemo(() => { + const visible: string[] = [] + const isPathVisible = (path: string) => { + const parts = path.split('/') + // Root level files in knowledge are always visible + if (parts.length <= 2) return true + // Check if all parent directories are expanded + for (let i = 1; i < parts.length - 1; i++) { + const parentPath = parts.slice(0, i + 1).join('/') + if (!expandedPaths.has(parentPath)) return false + } + return true + } + + for (const file of knowledgeFiles) { + const fullPath = toKnowledgePath(file) + if (fullPath && isPathVisible(fullPath)) { + visible.push(file) + } + } + return visible + }, [knowledgeFiles, expandedPaths]) + + // Load workspace root on mount useEffect(() => { window.ipc.invoke('workspace:getRoot', null).then(result => { setWorkspaceRoot(result.root) }) }, []) + // Check onboarding status on mount + useEffect(() => { + async function checkOnboarding() { + try { + const result = await window.ipc.invoke('onboarding:getStatus', null) + setShowOnboarding(result.showOnboarding) + } catch (err) { + console.error('Failed to check onboarding status:', err) + } + } + checkOnboarding() + }, []) + + // Handler for onboarding completion + const handleOnboardingComplete = useCallback(async () => { + try { + await window.ipc.invoke('onboarding:markComplete', null) + setShowOnboarding(false) + } catch (err) { + console.error('Failed to mark onboarding complete:', err) + setShowOnboarding(false) + } + }, []) + const knowledgeActions = React.useMemo(() => ({ createNote: async (parentPath: string = 'knowledge') => { try { @@ -876,14 +1427,32 @@ function App() { const renderConversationItem = (item: ConversationItem) => { if (isChatMessage(item)) { + if (item.role === 'user') { + const { message, files } = parseAttachedFiles(item.content) + return ( + + + {files.length > 0 && ( +
+ {files.map((filePath, index) => ( + + @{wikiLabel(filePath)} + + ))} +
+ )} + {message} +
+
+ ) + } return ( - {item.role === 'assistant' ? ( - {item.content} - ) : ( - item.content - )} + {item.content} ) @@ -922,44 +1491,10 @@ function App() { return null } - const chatMessages = conversation.filter(isChatMessage) - const reasoningBlocks = conversation.filter(isReasoningBlock) - const estimatedInputTokens = chatMessages - .filter((item) => item.role === 'user') - .reduce((total, item) => total + estimateTokens(item.content), 0) - const estimatedOutputTokens = chatMessages - .filter((item) => item.role === 'assistant') - .reduce((total, item) => total + estimateTokens(item.content), 0) - + estimateTokens(currentAssistantMessage) - const estimatedReasoningTokens = reasoningBlocks - .reduce((total, item) => total + estimateTokens(item.content), 0) - + estimateTokens(currentReasoning) - const estimatedTotalTokens = estimatedInputTokens + estimatedOutputTokens + estimatedReasoningTokens - const maxTokens = 128_000 - const estimatedUsage = { - inputTokens: estimatedInputTokens, - outputTokens: estimatedOutputTokens, - totalTokens: estimatedTotalTokens, - cachedInputTokens: 0, - reasoningTokens: estimatedReasoningTokens, - } as LanguageModelUsage - const effectiveUsage = modelUsage ?? estimatedUsage - const effectiveTotalTokens = effectiveUsage.totalTokens - ?? (effectiveUsage.inputTokens ?? 0) - + (effectiveUsage.outputTokens ?? 0) - + (effectiveUsage.reasoningTokens ?? 0) - const usedTokens = Math.min(effectiveTotalTokens, maxTokens) - const contextUsage = { - ...effectiveUsage, - totalTokens: effectiveTotalTokens, - } as LanguageModelUsage - const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning const conversationContentClassName = hasConversation ? "mx-auto w-full max-w-4xl pb-28" : "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0" - const submitStatus: ChatStatus = isProcessing ? 'streaming' : 'ready' - const canSubmit = Boolean(message.trim()) && !isProcessing const headerTitle = selectedPath ? selectedPath : (isGraphOpen ? 'Graph View' : 'Chat') return ( @@ -985,17 +1520,23 @@ function App() { expandedPaths={expandedPaths} onSelectFile={toggleExpand} knowledgeActions={knowledgeActions} + runs={runs} + currentRunId={runId} + tasksActions={{ + onNewChat: handleNewChat, + onSelectRun: loadRun, + }} /> - - {/* Header with sidebar trigger */} + + {/* Header with sidebar triggers */}
- + {headerTitle} {selectedPath && ( -
+
{isSaving ? ( <> @@ -1009,16 +1550,46 @@ function App() { ) : null}
)} + {!isGraphOpen && ( + + )} {!selectedPath && isGraphOpen && ( )} + {(selectedPath || isGraphOpen) && ( + <> + + + + )}
{isGraphOpen ? ( @@ -1042,6 +1613,7 @@ function App() { onChange={setEditorContent} placeholder="Start writing..." wikiLinks={wikiLinkConfig} + onImageUpload={handleImageUpload} /> ) : ( @@ -1058,12 +1630,49 @@ function App() { {!hasConversation ? (
- RowboatX + Rowboat +
+
+ + L + to open chat from anywhere
) : ( <> - {conversation.map(item => renderConversationItem(item))} + {conversation.map(item => { + const rendered = renderConversationItem(item) + // If this is a tool call, check for permission request (pending or responded) + if (isToolCall(item)) { + const permRequest = allPermissionRequests.get(item.id) + if (permRequest) { + const response = permissionResponses.get(item.id) || null + return ( + + {rendered} + handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} + onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} + isProcessing={isProcessing} + response={response} + /> + + ) + } + } + return rendered + })} + + {/* Render pending ask-human requests */} + {Array.from(pendingAskHumanRequests.values()).map((request) => ( + handleAskHumanResponse(request.toolCallId, request.subflow, response)} + isProcessing={isProcessing} + /> + ))} {currentReasoning && ( @@ -1090,46 +1699,23 @@ function App() { )} - -
-
+
+
- - - setMessage(e.target.value)} - placeholder="Type your message..." - disabled={isProcessing} - /> - - - - - - - - - - - - - - - - - - - + {!hasConversation && ( + + )} + setPresetMessage(undefined)} + />
@@ -1137,11 +1723,12 @@ function App() { {/* Chat sidebar - shown when viewing files/graph */} - {isChatSidebarOpen && (selectedPath || isGraphOpen) && ( + {(selectedPath || isGraphOpen) && ( setIsChatSidebarOpen(false)} + isOpen={isChatSidebarOpen} onNewChat={handleNewChat} + onOpenFullScreen={handleOpenFullScreenChat} conversation={conversation} currentAssistantMessage={currentAssistantMessage} currentReasoning={currentReasoning} @@ -1149,9 +1736,16 @@ function App() { message={message} onMessageChange={setMessage} onSubmit={handlePromptSubmit} - contextUsage={contextUsage} - maxTokens={maxTokens} - usedTokens={usedTokens} + knowledgeFiles={knowledgeFiles} + recentFiles={recentWikiFiles} + visibleFiles={visibleKnowledgeFiles} + selectedPath={selectedPath} + pendingPermissionRequests={pendingPermissionRequests} + pendingAskHumanRequests={pendingAskHumanRequests} + allPermissionRequests={allPermissionRequests} + permissionResponses={permissionResponses} + onPermissionResponse={handlePermissionResponse} + onAskHumanResponse={handleAskHumanResponse} /> )} @@ -1165,6 +1759,11 @@ function App() { )}
+ + ) } diff --git a/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx b/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx new file mode 100644 index 00000000..2e92e2ca --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { MessageCircleIcon, ArrowUpIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { useState, useRef, useEffect } from "react"; + +export type AskHumanRequestProps = ComponentProps<"div"> & { + query: string; + onResponse: (response: string) => void; + isProcessing?: boolean; +}; + +export const AskHumanRequest = ({ + className, + query, + onResponse, + isProcessing = false, + ...props +}: AskHumanRequestProps) => { + const [response, setResponse] = useState(""); + const textareaRef = useRef(null); + + useEffect(() => { + // Auto-focus the textarea when component mounts + textareaRef.current?.focus(); + }, []); + + const handleSubmit = () => { + const trimmed = response.trim(); + if (trimmed && !isProcessing) { + onResponse(trimmed); + setResponse(""); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + const canSubmit = Boolean(response.trim()) && !isProcessing; + + return ( +
+
+
+ +
+
+

+ Question from Agent +

+

+ {query} +

+
+
+