diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index 787e28e6..6566f105 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -16,14 +16,14 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@v4 with: - version: 10 + version: 9 - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 24.15.0 + node-version: 24 cache: 'pnpm' cache-dependency-path: 'apps/x/pnpm-lock.yaml' @@ -39,17 +39,17 @@ jobs: 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); " @@ -61,25 +61,25 @@ jobs: # 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-keychains -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 @@ -111,7 +111,6 @@ jobs: with: name: distributables path: apps/x/apps/main/out/make/* - if-no-files-found: error retention-days: 30 build-linux: @@ -122,14 +121,14 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@v4 with: - version: 10 + version: 9 - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 24.15.0 + node-version: 24 cache: 'pnpm' cache-dependency-path: 'apps/x/pnpm-lock.yaml' @@ -145,17 +144,17 @@ jobs: 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); " @@ -176,7 +175,6 @@ jobs: with: name: distributables-linux path: apps/x/apps/main/out/make/* - if-no-files-found: error retention-days: 30 build-windows: @@ -187,14 +185,14 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@v4 with: - version: 10 + version: 9 - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 24.15.0 + node-version: 24 cache: 'pnpm' cache-dependency-path: 'apps/x/pnpm-lock.yaml' @@ -212,17 +210,17 @@ jobs: 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); " @@ -243,5 +241,4 @@ jobs: with: name: distributables-windows path: apps/x/apps/main/out/make/* - if-no-files-found: error retention-days: 30 diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 2d9816d0..572e9a6f 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -16,8 +16,6 @@ ## Event catalog -All PostHog events include `app_version` automatically. Main-process events add it in `packages/core/src/analytics/posthog.ts`; renderer events get it from the `analytics:bootstrap` IPC payload and an initialization-time `before_send` hook. - ### `llm_usage` Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run). @@ -103,7 +101,6 @@ Persistent across sessions for the same user. Set via `posthog.people.set` or as | `email` | main on identify | From `/v1/me`; powers PostHog cohort match + integrations | | `plan`, `status` | main on identify | Subscription state | | `api_url` | both processes (init + identify) | Distinguishes prod / staging / custom — assign meaning in PostHog dashboard. `https://api.x.rowboatlabs.com` = production | -| `app_version` | both processes (init + identify) | Electron app version; also included automatically on every event | | `signed_in` | renderer | `true` while rowboat OAuth is connected | | `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` | | `total_notes` | renderer (init) | Workspace size signal | diff --git a/apps/x/LIVE_NOTE.md b/apps/x/LIVE_NOTE.md index d8a157d7..2fc43786 100644 --- a/apps/x/LIVE_NOTE.md +++ b/apps/x/LIVE_NOTE.md @@ -70,7 +70,7 @@ The `once` trigger from the prior model has been **dropped** — it didn't fit t Two paths, both producing identical on-disk YAML: 1. **Hand-written** — type the `live:` block directly into the note's frontmatter. The scheduler picks it up on its next 15-second tick. -2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `file-editText`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet. +2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `workspace-edit`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet. When the note is **already live** and the user asks to track something new, Copilot extends the existing `live.objective` in natural-language prose. It does not create a second `live:` block. @@ -92,8 +92,8 @@ When a trigger fires, the live-note agent receives a short message: - For event runs only: the matching `eventMatchCriteria` text and the event payload, with a Pass-2 decision directive ("only edit if the event genuinely warrants it"). The agent's system prompt tells it to: -1. Call `file-readText` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh). -2. Make small, **patch-style** edits with `file-editText` — change one region, re-read, change the next region — rather than one-shot rewrites. +1. Call `workspace-readFile` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh). +2. Make small, **patch-style** edits with `workspace-edit` — change one region, re-read, change the next region — rather than one-shot rewrites. 3. Follow default body structure unless the objective overrides: H1 stays the title; a 1-3 sentence rolling summary at the top; H2 sub-topic sections below, freshest first. 4. Never modify YAML frontmatter — that's owned by the user and the runtime. 5. End with a 1-2 sentence summary stored as `lastRunSummary`. @@ -115,7 +115,7 @@ Backend (main process) ├─ Event processor (5 s) ──┼──► runLiveNoteAgent() ──► live-note-agent └─ Builtin tool │ │ run-live-note-agent ────┘ ▼ - file-readText / -edit + workspace-readFile / -edit │ ▼ body region(s) rewritten on disk @@ -175,7 +175,7 @@ Internal trigger enum (`LiveNoteTriggerType`) is `'manual' | 'cron' | 'window' | `buildMessage` always emits a `**Trigger:**` paragraph in the agent's run message — one paragraph per kind. `manual` and the two timed variants (`cron`, `window`) include any optional `context` as a `**Context:**` block. `event` includes the eventMatchCriteria + payload + Pass 2 decision directive (no `**Context:**`; the payload *is* the context). -This lets the user-authored objective branch on trigger kind when warranted (for example, an email digest can scan `gmail_sync/` from scratch on cron/window runs, while event runs integrate just the new thread). The skill teaches the pattern under "Per-trigger guidance (advanced)". +This lets the user-authored objective branch on trigger kind when warranted (the canonical example is the Today.md emails section: cron/window scans `gmail_sync/` from scratch, event integrates the new thread). The skill teaches the pattern under "Per-trigger guidance (advanced)". ### Run flow (`runLiveNoteAgent`) @@ -249,20 +249,22 @@ The contract (defined in the run-agent system prompt — `packages/core/src/know - Then content organized by sub-topic under H2 headings, freshest/most-important first. - Tightness over decoration. - **Override** — if the objective specifies a different layout (e.g. "show the top 5 stories at the top, with a one-paragraph summary above them"), follow that exactly. -- **Patch-style updates** — make small, incremental `file-editText` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable. +- **Patch-style updates** — make small, incremental `workspace-edit` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable. - **Boundaries**: never modify the frontmatter; the agent is the sole writer of the body below the H1. --- -## Default Note Policy +## Daily-Note Template & Migrations -Rowboat no longer creates a default `Today.md` live dashboard for new users. Live notes are user-created notes with an explicit `live:` frontmatter block. +`Today.md` is the canonical demo of what a live note can do. It ships with one objective covering an Overview / Calendar / Emails / What you missed / Priorities layout — driven by three windows and an event-match criterion for in-day signals. -**Deprecated Today.md migration** — `packages/core/src/knowledge/deprecate_today_note.ts` runs once per workspace on app start: +**Versioning** — `packages/core/src/knowledge/ensure_daily_note.ts` carries a `CANONICAL_DAILY_NOTE_VERSION` constant and a `templateVersion` scalar in the frontmatter. On app start, `ensureDailyNote()`: -- File missing → mark processed and do nothing. -- File present → set `live.active: false` if a `live:` block exists, prepend a user-facing deprecation notice once, and preserve the note body. -- Future launches → no-op via `config/today-note-deprecation.json`, so a user who re-enables the note is not paused again. +- File missing → fresh write at canonical version. +- File at-or-above canonical → no-op. +- File below canonical → rename existing to `Today.md.bkp.` (which doesn't end in `.md`, so the scheduler/event router skip it), then write the canonical template body-from-scratch (live notes regenerate their own body). + +The bump from v1 (the old `track:` array model) to v2 (the live-note rewrite) is handled by this same path. Pre-v2 notes get backed up and replaced. --- @@ -316,7 +318,7 @@ Every LLM-facing prompt in the feature, with file pointers. After any edit: `cd - **Purpose**: the user message seeded into each agent run. - **File**: `packages/core/src/knowledge/live-note/runner.ts` (`buildMessage`). - **Inputs**: `filePath` (presented as `knowledge/${filePath}` in the message), `live.objective`, `live.triggers?.eventMatchCriteria` (only on event runs), `trigger`, optional `context`, plus `localNow` / `tz`. -- **Behavior**: tells the agent to call `file-readText` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits. +- **Behavior**: tells the agent to call `workspace-readFile` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits. Three branches by `trigger`: - **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-live-note-agent` tool uses this path for both plain refreshes and context-biased backfills. @@ -391,7 +393,7 @@ Conventions: | Run orchestrator (`runLiveNoteAgent`, `buildMessage`) | `packages/core/src/knowledge/live-note/runner.ts` | | Live-note agent definition (`LIVE_NOTE_AGENT_INSTRUCTIONS`, `buildLiveNoteAgent`) | `packages/core/src/knowledge/live-note/agent.ts` | | Live-note bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/live-note/bus.ts` | -| Deprecated Today.md one-time migration | `packages/core/src/knowledge/deprecate_today_note.ts` | +| Daily-note template + version migration | `packages/core/src/knowledge/ensure_daily_note.ts` | | Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` | | Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` | | Copilot skill | `packages/core/src/application/assistant/skills/live-note/skill.ts` | diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index 976e8db3..9ae77e0e 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -10,13 +10,11 @@ */ import * as esbuild from 'esbuild'; -import { readFile } from 'node:fs/promises'; // 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;`; -const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8')); await esbuild.build({ entryPoints: ['./dist/main.js'], @@ -38,7 +36,6 @@ await esbuild.build({ // Empty strings disable analytics gracefully. 'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''), 'process.env.POSTHOG_HOST': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'), - 'process.env.ROWBOAT_APP_VERSION': JSON.stringify(pkg.version ?? ''), }, }); diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 7806f6cd..ad639a86 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -56,7 +56,6 @@ module.exports = { description: 'AI coworker with memory', name: `Rowboat-win32-${arch}`, setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`, - setupIcon: path.join(__dirname, 'icons/icon.ico'), }) }, { @@ -67,9 +66,7 @@ module.exports = { bin: "rowboat", description: 'AI coworker with memory', maintainer: 'rowboatlabs', - homepage: 'https://rowboatlabs.com', - icon: path.join(__dirname, 'icons/icon.png'), - mimeType: ['x-scheme-handler/rowboat'], + homepage: 'https://rowboatlabs.com' } }) }, @@ -80,9 +77,7 @@ module.exports = { name: `Rowboat-linux`, bin: "rowboat", description: 'AI coworker with memory', - homepage: 'https://rowboatlabs.com', - icon: path.join(__dirname, 'icons/icon.png'), - mimeType: ['x-scheme-handler/rowboat'], + homepage: 'https://rowboatlabs.com' } } }, diff --git a/apps/x/apps/main/icons/icon.ico b/apps/x/apps/main/icons/icon.ico deleted file mode 100644 index 0e5ac870..00000000 Binary files a/apps/x/apps/main/icons/icon.ico and /dev/null differ diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 3330c3c0..74cb1598 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -13,8 +13,6 @@ "make": "electron-forge make" }, "dependencies": { - "@agentclientprotocol/claude-agent-acp": "^0.39.0", - "@agentclientprotocol/codex-acp": "^0.0.44", "@x/core": "workspace:*", "@x/shared": "workspace:*", "chokidar": "^4.0.3", diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts index 5c46ca3f..ad184451 100644 --- a/apps/x/apps/main/src/auth-server.ts +++ b/apps/x/apps/main/src/auth-server.ts @@ -2,8 +2,7 @@ import { createServer, Server } from 'http'; import { URL } from 'url'; const OAUTH_CALLBACK_PATH = '/oauth/callback'; -export const DEFAULT_PORT = 8080; -export const PORT_RANGE_SIZE = 10; +const DEFAULT_PORT = 8080; /** Escape HTML special characters to prevent XSS */ function escapeHtml(str: string): string { @@ -20,8 +19,13 @@ export interface AuthServerResult { port: number; } -function tryBindPort( - port: number, +/** + * Create a local HTTP server to handle OAuth callback + * Listens on http://localhost:8080/oauth/callback + * Passes the full callback URL (including iss, scope, etc.) so openid-client validation succeeds. + */ +export function createAuthServer( + port: number = DEFAULT_PORT, onCallback: (callbackUrl: URL) => void | Promise ): Promise { return new Promise((resolve, reject) => { @@ -33,7 +37,7 @@ function tryBindPort( } const url = new URL(req.url, `http://localhost:${port}`); - + if (url.pathname === OAUTH_CALLBACK_PATH) { const error = url.searchParams.get('error'); @@ -92,10 +96,8 @@ function tryBindPort( }); server.on('error', (err: NodeJS.ErrnoException) => { - server.close(); - if (err.code === 'EADDRINUSE' || err.code === 'EACCES') { - // Signal caller to try next port - reject(Object.assign(new Error(err.code), { code: err.code })); + if (err.code === 'EADDRINUSE') { + reject(new Error(`Port ${port} is already in use`)); } else { reject(err); } @@ -103,51 +105,3 @@ function tryBindPort( }); } -/** - * Create a local HTTP server to handle OAuth callback. - * - * Defaults to fixed-port behaviour: only `port` is tried, and a clear error is - * thrown if it cannot be bound. This is the right behaviour for any provider - * whose redirect URI is pre-registered (Google BYOK, Composio, etc.) — those - * callers must keep using the exact port they've handed to the provider. - * - * Opt into `{ fallback: true }` only when the caller is prepared to use the - * port returned in `AuthServerResult` (i.e. the redirect URI is built from the - * actual bound port, not hard-coded). With fallback enabled, scans `port` - * through `port + PORT_RANGE_SIZE - 1` and binds the first available, handling - * both EADDRINUSE and EACCES (the latter is common on Windows when - * Hyper-V/WSL2 reserve the port). - */ -export async function createAuthServer( - port: number = DEFAULT_PORT, - onCallback: (callbackUrl: URL) => void | Promise, - opts: { fallback?: boolean } = {}, -): Promise { - const fallback = opts.fallback === true; - const limit = fallback ? port + PORT_RANGE_SIZE - 1 : port; - - for (let p = port; p <= limit; p++) { - try { - return await tryBindPort(p, onCallback); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (fallback && (code === 'EADDRINUSE' || code === 'EACCES') && p < limit) { - console.warn(`[OAuth] Port ${p} unavailable (${code}), trying ${p + 1}…`); - continue; - } - if (!fallback) { - const reason = code === 'EACCES' || code === 'EADDRINUSE' - ? `Port ${port} is unavailable (${code}). This port must be free for sign-in to work — close any app using it and try again.` - : (err instanceof Error ? err.message : String(err)); - throw new Error(reason); - } - throw new Error( - `No available port found in range ${port}–${limit}. Free a port in that range and try again.` - ); - } - } - - // Unreachable — loop always returns or throws — but satisfies TypeScript - throw new Error(`No available port found in range ${port}–${limit}.`); -} - diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index e5d407f8..a667b512 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1,4 +1,4 @@ -import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app } 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'; @@ -8,7 +8,6 @@ import { listProviders, } from './oauth-handler.js'; import { watcher as watcherCore, workspace } from '@x/core'; -import { WorkDir } from '@x/core/dist/config/config.js'; import { workspace as workspaceShared } from '@x/shared'; import * as mcpCore from '@x/core/dist/mcp/mcp.js'; import * as runsCore from '@x/core/dist/runs/runs.js'; @@ -31,10 +30,6 @@ 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 { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js'; -import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js'; -import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js'; -import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.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'; @@ -52,7 +47,6 @@ 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'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; -import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { API_URL } from '@x/core/dist/config/env.js'; @@ -63,16 +57,6 @@ import { deleteLiveNote, listLiveNotes, } from '@x/core/dist/knowledge/live-note/fileops.js'; -import { runBackgroundTask } from '@x/core/dist/background-tasks/runner.js'; -import { backgroundTaskBus } from '@x/core/dist/background-tasks/bus.js'; -import { - fetchTask, - patchTask, - createTask, - deleteTask, - listTasks, - readRunIds as readTaskRunIds, -} from '@x/core/dist/background-tasks/fileops.js'; import { browserIpcHandlers } from './browser/ipc.js'; /** @@ -405,19 +389,6 @@ export function startLiveNoteAgentWatcher(): void { }); } -let backgroundTaskAgentWatcher: (() => void) | null = null; -export function startBackgroundTaskAgentWatcher(): void { - if (backgroundTaskAgentWatcher) return; - backgroundTaskAgentWatcher = backgroundTaskBus.subscribe((event) => { - const windows = BrowserWindow.getAllWindows(); - for (const win of windows) { - if (!win.isDestroyed() && win.webContents) { - win.webContents.send('bg-task-agent:events', event); - } - } - }); -} - export function stopRunsWatcher(): void { if (runsWatcher) { runsWatcher(); @@ -456,7 +427,6 @@ export function setupIpcHandlers() { return { installationId: getInstallationId(), apiUrl: API_URL, - appVersion: app.getVersion(), }; }, 'workspace:getRoot': async () => { @@ -489,38 +459,6 @@ export function setupIpcHandlers() { 'workspace:remove': async (_event, args) => { return workspace.remove(args.path, args.opts); }, - 'gmail:getImportant': async (_event, args) => { - return listImportantThreads({ cursor: args.cursor, limit: args.limit }); - }, - 'gmail:getEverythingElse': async (_event, args) => { - return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit }); - }, - 'gmail:triggerSync': async () => { - triggerGmailSync(); - return {}; - }, - 'gmail:sendReply': async (_event, args) => { - return sendThreadReply(args); - }, - 'gmail:getConnectionStatus': async () => { - return getGmailConnectionStatus(); - }, - 'gmail:getAccountEmail': async () => { - return { email: await getAccountEmail() }; - }, - 'gmail:archiveThread': async (_event, args) => { - return archiveThread(args.threadId); - }, - 'gmail:trashThread': async (_event, args) => { - return trashThread(args.threadId); - }, - 'gmail:markThreadRead': async (_event, args) => { - return markThreadRead(args.threadId); - }, - 'gmail:saveMessageHeight': async (_event, args) => { - saveMessageBodyHeight(args.threadId, args.messageId, args.height); - return {}; - }, 'mcp:listTools': async (_event, args) => { return mcpCore.listTools(args.serverName, args.cursor); }, @@ -531,17 +469,12 @@ export function setupIpcHandlers() { return runsCore.createRun(args); }, 'runs:createMessage': async (_event, args) => { - return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) }; + return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext) }; }, 'runs:authorizePermission': async (_event, args) => { await runsCore.authorizePermission(args.runId, args.authorization); return { success: true }; }, - 'codeRun:resolvePermission': async (_event, args) => { - const registry = container.resolve('codePermissionRegistry'); - registry.resolve(args.requestId, args.decision); - return { success: true }; - }, 'runs:provideHumanInput': async (_event, args) => { await runsCore.replyToHumanInputRequest(args.runId, args.reply); return { success: true }; @@ -560,35 +493,6 @@ export function setupIpcHandlers() { await runsCore.deleteRun(args.runId); return { success: true }; }, - 'runs:downloadLog': async (event, args) => { - const runFileName = `${args.runId}.jsonl`; - if (path.basename(runFileName) !== runFileName) { - return { success: false, error: 'Invalid run id' }; - } - - const sourcePath = path.join(WorkDir, 'runs', runFileName); - const win = BrowserWindow.fromWebContents(event.sender); - const result = await dialog.showSaveDialog(win!, { - defaultPath: `${runFileName}.log`, - filters: [ - { name: 'Chat Log', extensions: ['log'] }, - { name: 'JSONL', extensions: ['jsonl'] }, - { name: 'All Files', extensions: ['*'] }, - ], - }); - - if (result.canceled || !result.filePath) { - return { success: false }; - } - - try { - await fs.copyFile(sourcePath, result.filePath); - return { success: true }; - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to download chat log'; - return { success: false, error: message }; - } - }, 'models:list': async () => { if (await isSignedIn()) { return await listGatewayModels(); @@ -640,20 +544,6 @@ export function setupIpcHandlers() { const config = await repo.getConfig(); return { enabled: config.enabled }; }, - 'codeMode:getConfig': async () => { - const repo = container.resolve('codeModeConfigRepo'); - const config = await repo.getConfig(); - return { enabled: config.enabled, approvalPolicy: config.approvalPolicy }; - }, - 'codeMode:setConfig': async (_event, args) => { - const repo = container.resolve('codeModeConfigRepo'); - await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy }); - invalidateCopilotInstructionsCache(); - return { success: true }; - }, - 'codeMode:checkAgentStatus': async () => { - return await checkCodeModeAgentStatus(); - }, 'granola:setConfig': async (_event, args) => { const repo = container.resolve('granolaConfigRepo'); await repo.setConfig({ enabled: args.enabled }); @@ -766,11 +656,6 @@ export function setupIpcHandlers() { const error = await shell.openPath(filePath); return { error: error || undefined }; }, - 'shell:showItemInFolder': async (_event, args) => { - const filePath = resolveShellPath(args.path); - shell.showItemInFolder(filePath); - return { success: true }; - }, 'shell:readFileBase64': async (_event, args) => { const filePath = resolveShellPath(args.path); const stat = await fs.stat(filePath); @@ -981,73 +866,6 @@ export function setupIpcHandlers() { const notes = await listLiveNotes(); return { notes }; }, - // Bg-task handlers - 'bg-task:run': async (_event, args) => { - const result = await runBackgroundTask(args.slug, 'manual', args.context); - return { - success: !result.error, - runId: result.runId, - summary: result.summary, - error: result.error, - }; - }, - 'bg-task:get': async (_event, args) => { - try { - const task = await fetchTask(args.slug); - return { success: true, task }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - }, - 'bg-task:patch': async (_event, args) => { - try { - const task = await patchTask(args.slug, args.partial); - return { success: true, task }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - }, - 'bg-task:create': async (_event, args) => { - try { - const { slug } = await createTask({ - name: args.name, - instructions: args.instructions, - ...(args.triggers ? { triggers: args.triggers } : {}), - ...(args.model ? { model: args.model } : {}), - ...(args.provider ? { provider: args.provider } : {}), - }); - return { success: true, slug }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - }, - 'bg-task:delete': async (_event, args) => { - try { - await deleteTask(args.slug); - return { success: true }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - }, - 'bg-task:stop': async (_event, args) => { - try { - const task = await fetchTask(args.slug); - if (!task?.lastRunId) { - return { success: false, error: 'No active run for this task' }; - } - await runsCore.stop(task.lastRunId, false); - return { success: true }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - }, - 'bg-task:list': async (_event, args) => { - return listTasks(args); - }, - 'bg-task:listRunIds': async (_event, args) => { - const runIds = await readTaskRunIds(args.slug, args.limit); - return { runIds }; - }, // Billing handler 'billing:getInfo': async () => { return await getBillingInfo(); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index f4415b5d..accc971e 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -5,7 +5,6 @@ import { startRunsWatcher, startServicesWatcher, startLiveNoteAgentWatcher, - startBackgroundTaskAgentWatcher, startWorkspaceWatcher, stopRunsWatcher, stopServicesWatcher, @@ -26,10 +25,7 @@ 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 { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js"; import { init as initLiveNoteScheduler } from "@x/core/dist/knowledge/live-note/scheduler.js"; -import { init as initEventProcessor, registerConsumer } from "@x/core/dist/events/init.js"; -import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js"; -import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js"; -import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js"; +import { init as initLiveNoteEventProcessor } from "@x/core/dist/knowledge/live-note/events.js"; import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js"; import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js"; import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js"; @@ -40,8 +36,7 @@ import started from "electron-squirrel-startup"; import { execSync, exec, execFileSync } from "node:child_process"; import { promisify } from "node:util"; import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; -import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js"; -import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js"; +import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js"; import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js"; import { setupBrowserEventForwarding } from "./browser/ipc.js"; import { ElectronBrowserControlService } from "./browser/control-service.js"; @@ -52,7 +47,6 @@ import { extractDeepLinkFromArgv, setMainWindowForDeepLinks, } from "./deeplink.js"; -import { disconnectGoogleIfScopesStale } from "./oauth-handler.js"; const execAsync = promisify(exec); @@ -64,7 +58,7 @@ if (started) app.quit(); // Single-instance lock: route a second launch (e.g. clicking a rowboat:// link) // back into the existing process via the 'second-instance' event. -if (app.isPackaged && !app.requestSingleInstanceLock()) { +if (!app.requestSingleInstanceLock()) { console.error('[Main] Another Rowboat instance is already running; exiting this process.'); app.quit(); process.exit(0); @@ -221,7 +215,6 @@ function createWindow() { backgroundColor: "#252525", // Prevent white flash (matches dark mode) titleBarStyle: "hiddenInset", trafficLightPosition: { x: 12, y: 12 }, - icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined, webPreferences: { // IMPORTANT: keep Node out of renderer nodeIntegration: false, @@ -338,26 +331,11 @@ app.whenReady().then(async () => { // start live-note agent event watcher (forwards bus → renderer) startLiveNoteAgentWatcher(); - // start bg-task agent event watcher (forwards bus → renderer) - startBackgroundTaskAgentWatcher(); - // start live-note scheduler (cron / window) initLiveNoteScheduler(); - // start bg-task scheduler (cron / window) - initBackgroundTaskScheduler(); - - // register event consumers and start the shared event processor - // (consumes $WorkDir/events/pending/, routes events to all consumers - // concurrently for Pass-1, then fires each consumer's candidates in parallel) - registerConsumer(liveNoteEventConsumer); - registerConsumer(backgroundTaskEventConsumer); - initEventProcessor(); - - // If the stored Google grant predates a scope change (only old scopes), - // disconnect it now so the user re-connects with the current scopes before - // any Google sync runs against the stale grant. - await disconnectGoogleIfScopesStale(); + // start live-note event processor (consumes events/pending/, routes to matching live notes) + initLiveNoteEventProcessor(); // start gmail sync initGmailSync(); @@ -418,12 +396,6 @@ app.on("before-quit", () => { stopWorkspaceWatcher(); stopRunsWatcher(); stopServicesWatcher(); - // Tear down any live ACP coding-agent adapter processes so they don't outlive the app. - try { - container.resolve('codeModeManager').disposeAll(); - } catch { - // nothing live to dispose - } shutdownLocalSites().catch((error) => { console.error('[LocalSites] Failed to shut down cleanly:', error); }); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 1048d9b8..f61b59cc 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -1,7 +1,6 @@ import { shell } from 'electron'; import type { Server } from 'http'; import { createAuthServer } from './auth-server.js'; -import { DEFAULT_CALLBACK_PORT } from '@x/core/dist/auth/client-repo.js'; import * as oauthClient from '@x/core/dist/auth/oauth-client.js'; import type { Configuration } from '@x/core/dist/auth/oauth-client.js'; import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js'; @@ -18,9 +17,7 @@ import { isSignedIn } from '@x/core/dist/account/account.js'; import { getWebappUrl } from '@x/core/dist/config/remote-config.js'; import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js'; -function buildRedirectUri(port: number): string { - return `http://localhost:${port}/oauth/callback`; -} +const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; /** Top-level openid-client messages that often wrap a more specific cause. */ const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']); @@ -117,15 +114,9 @@ function getClientRegistrationRepo(): IClientRegistrationRepo { } /** - * Get or create OAuth configuration for a provider. - * `redirectUri` is required for DCR providers — it is the actual callback URI - * (including port) that was just bound, so the registration and auth URL stay in sync. + * Get or create OAuth configuration for a provider */ -async function getProviderConfiguration( - provider: string, - redirectUri: string = buildRedirectUri(DEFAULT_CALLBACK_PORT), - credentialsOverride?: { clientId: string; clientSecret: string }, -): Promise { +async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise { const config = await getProviderConfig(provider); const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => { if (config.client.mode === 'static' && config.client.clientId) { @@ -157,7 +148,7 @@ async function getProviderConfiguration( console.log(`[OAuth] ${provider}: Discovery from issuer with DCR`); const clientRepo = getClientRegistrationRepo(); const existingRegistration = await clientRepo.getClientRegistration(provider); - + if (existingRegistration) { console.log(`[OAuth] ${provider}: Using existing DCR registration`); return await oauthClient.discoverConfiguration( @@ -166,21 +157,18 @@ async function getProviderConfiguration( ); } - // Register new client with the actual redirect URI (port already bound) + // Register new client const scopes = config.scopes || []; const { config: oauthConfig, registration } = await oauthClient.registerClient( config.discovery.issuer, - [redirectUri], + [REDIRECT_URI], scopes ); - - // Parse port from redirectUri (e.g. "http://localhost:8081/...") and save - const boundPort = new URL(redirectUri).port - ? parseInt(new URL(redirectUri).port, 10) - : DEFAULT_CALLBACK_PORT; - await clientRepo.saveClientRegistration(provider, registration, boundPort); - console.log(`[OAuth] ${provider}: DCR registration saved (port ${boundPort})`); - + + // Save registration for future use + await clientRepo.saveClientRegistration(provider, registration); + console.log(`[OAuth] ${provider}: DCR registration saved`); + return oauthConfig; } } else { @@ -188,7 +176,7 @@ async function getProviderConfiguration( if (config.client.mode !== 'static') { throw new Error('DCR requires discovery mode "issuer", not "static"'); } - + console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`); const { clientId, clientSecret } = await resolveClientCredentials(); return oauthClient.createStaticConfiguration( @@ -201,37 +189,6 @@ async function getProviderConfiguration( } } -/** - * Determine which port to start the OAuth callback server on for a DCR provider. - * - * If the provider has an existing registration, probes the port it was registered - * on. If that port is still available, returns it so the existing client_id keeps - * working. If it is blocked, clears the stale registration (forcing re-registration - * on the next available port) and returns DEFAULT_CALLBACK_PORT as the scan base. - * - * Exported for unit testing. - */ -export async function resolveStartPort( - provider: string, - clientRepo: IClientRegistrationRepo, -): Promise { - const existingReg = await clientRepo.getClientRegistration(provider); - if (!existingReg) return DEFAULT_CALLBACK_PORT; - - const registeredPort = await clientRepo.getRegisteredPort(provider); - try { - // Probe — fixed-port (no fallback) so we know whether the exact registered port is free - const probe = await createAuthServer(registeredPort, () => { /* probe */ }); - probe.server.close(); - console.log(`[OAuth] ${provider}: registered port ${registeredPort} still available`); - return registeredPort; - } catch { - console.log(`[OAuth] ${provider}: registered port ${registeredPort} blocked, clearing DCR registration`); - await clientRepo.clearClientRegistration(provider); - return DEFAULT_CALLBACK_PORT; - } -} - /** * Initiate OAuth flow for a provider */ @@ -268,188 +225,154 @@ export async function connectProvider(provider: string, credentials?: { clientId } } - // For static-client providers (Google BYOK) the redirect URI is pre-registered - // at the OAuth provider console on a fixed port — we must not scan. - // For DCR providers, resolveStartPort handles the re-registration trap. - const isStaticClient = providerConfig.client.mode === 'static'; - const startPort = isStaticClient - ? DEFAULT_CALLBACK_PORT - : await resolveStartPort(provider, getClientRegistrationRepo()); + // Get or create OAuth configuration + const config = await getProviderConfiguration(provider, credentials); - // --- Callback server --- - // Declare `state` before the closure so the callback can close over its binding. - // The variable is assigned below, before shell.openExternal, so it is always - // set by the time any browser request arrives. - let state = ''; + // Generate PKCE codes + const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE(); + const state = oauthClient.generateState(); + + // Get scopes from config + const scopes = providerConfig.scopes || []; + + // Store flow state + activeFlows.set(state, { codeVerifier, provider, config }); + + // Build authorization URL + const authUrl = oauthClient.buildAuthorizationUrl(config, { + redirect_uri: REDIRECT_URI, + scope: scopes.join(' '), + code_challenge: codeChallenge, + state, + }); + + // Create callback server let callbackHandled = false; - - const { server, port: boundPort } = await createAuthServer( - startPort, - async (callbackUrl) => { - // Guard against duplicate callbacks (browser may send multiple requests) - if (callbackHandled) return; - callbackHandled = true; - const receivedState = callbackUrl.searchParams.get('state'); - if (receivedState == null || receivedState === '') { - throw new Error( - 'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.' - ); - } - if (receivedState !== state) { - throw new Error('Invalid state parameter - possible CSRF attack'); - } - - const flow = activeFlows.get(state); - if (!flow || flow.provider !== provider) { - throw new Error('Invalid OAuth flow state'); - } - - try { - // Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds - console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); - const tokens = await oauthClient.exchangeCodeForTokens( - flow.config, - callbackUrl, - flow.codeVerifier, - state - ); - - // Save tokens and credentials. For Google, BYOK is the only path - // that reaches this token exchange (rowboat path returns above - // before any local server runs); stamp mode: 'byok' so a future - // refresh / reconnect can't get confused with a rowboat entry. - console.log(`[OAuth] Token exchange successful for ${provider}`); - await oauthRepo.upsert(provider, { - tokens, - ...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}), - ...(provider === 'google' ? { mode: 'byok' as const } : {}), - error: null, - }); - - // Trigger immediate sync for relevant providers - if (provider === 'google') { - triggerGmailSync(); - triggerCalendarSync(); - } else if (provider === 'fireflies-ai') { - triggerFirefliesSync(); - } - - // For Rowboat sign-in, ensure user + Stripe customer exist before - // notifying the renderer. Without this, parallel API calls from - // multiple renderer hooks race to create the user, causing duplicates. - let signedInUserId: string | undefined; - if (provider === 'rowboat') { - try { - const billing = await getBillingInfo(); - if (billing.userId) { - signedInUserId = billing.userId; - analyticsIdentify(billing.userId, { - ...(billing.userEmail ? { email: billing.userEmail } : {}), - plan: billing.subscriptionPlan, - status: billing.subscriptionStatus, - }); - analyticsCapture('user_signed_in', { - plan: billing.subscriptionPlan, - status: billing.subscriptionStatus, - }); - } - } catch (meError) { - console.error('[OAuth] Failed to initialize user via /v1/me:', meError); - } - } - - // Emit success event to renderer - emitOAuthEvent({ - provider, - success: true, - ...(signedInUserId ? { userId: signedInUserId } : {}), - }); - } catch (error) { - console.error('OAuth token exchange failed:', error); - // Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError) - let cause: unknown = error; - while (cause != null && typeof cause === 'object' && 'cause' in cause) { - cause = (cause as { cause?: unknown }).cause; - if (cause != null) { - console.error('[OAuth] Caused by:', cause); - } - } - const errorMessage = getOAuthErrorMessage(error); - emitOAuthEvent({ provider, success: false, error: errorMessage }); - throw error; - } finally { - // Clean up - activeFlows.delete(state); - if (activeFlow && activeFlow.state === state) { - clearTimeout(activeFlow.cleanupTimeout); - activeFlow.server.close(); - activeFlow = null; - } - } - }, - // Static providers (Google BYOK) keep fixed-port behaviour to match the - // pre-registered redirect URI at the provider's console. DCR providers - // can fall back since we register the actual bound port below. - { fallback: !isStaticClient }, - ); - - // Server is bound. Any throw between here and `activeFlow = ...` would - // leak the port — `cancelActiveFlow` only closes it once activeFlow is set. - try { - // TOCTOU guard: resolveStartPort probed the registered port and found it - // free, but the port could have been grabbed between probe and real bind, - // causing fallback to a different port. The cached client_id is registered - // for the old port — clear it so getProviderConfiguration re-registers - // with the actual bound port. - if (!isStaticClient && boundPort !== startPort) { - console.log(`[OAuth] ${provider}: bound port ${boundPort} differs from start port ${startPort}, clearing stale DCR registration`); - await getClientRegistrationRepo().clearClientRegistration(provider); + const { server } = await createAuthServer(8080, async (callbackUrl) => { + // Guard against duplicate callbacks (browser may send multiple requests) + if (callbackHandled) return; + callbackHandled = true; + const receivedState = callbackUrl.searchParams.get('state'); + if (receivedState == null || receivedState === '') { + throw new Error( + 'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.' + ); + } + if (receivedState !== state) { + throw new Error('Invalid state parameter - possible CSRF attack'); } - const redirectUri = buildRedirectUri(boundPort); - const config = await getProviderConfiguration(provider, redirectUri, credentials); + const flow = activeFlows.get(state); + if (!flow || flow.provider !== provider) { + throw new Error('Invalid OAuth flow state'); + } - const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE(); - state = oauthClient.generateState(); + try { + // Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds + console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); + const tokens = await oauthClient.exchangeCodeForTokens( + flow.config, + callbackUrl, + flow.codeVerifier, + state + ); - const scopes = providerConfig.scopes || []; - activeFlows.set(state, { codeVerifier, provider, config }); + // Save tokens and credentials. For Google, BYOK is the only path + // that reaches this token exchange (rowboat path returns above + // before any local server runs); stamp mode: 'byok' so a future + // refresh / reconnect can't get confused with a rowboat entry. + console.log(`[OAuth] Token exchange successful for ${provider}`); + await oauthRepo.upsert(provider, { + tokens, + ...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}), + ...(provider === 'google' ? { mode: 'byok' as const } : {}), + error: null, + }); - const authUrl = oauthClient.buildAuthorizationUrl(config, { - redirect_uri: redirectUri, - scope: scopes.join(' '), - code_challenge: codeChallenge, - state, - }); - - // Set timeout to clean up abandoned flows (2 minutes) - const cleanupTimeout = setTimeout(() => { - if (activeFlow?.state === state) { - console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`); - cancelActiveFlow('timed_out'); + // Trigger immediate sync for relevant providers + if (provider === 'google') { + triggerGmailSync(); + triggerCalendarSync(); + } else if (provider === 'fireflies-ai') { + triggerFirefliesSync(); } - }, 2 * 60 * 1000); - activeFlow = { - provider, - state, - server, - cleanupTimeout, - }; + // For Rowboat sign-in, ensure user + Stripe customer exist before + // notifying the renderer. Without this, parallel API calls from + // multiple renderer hooks race to create the user, causing duplicates. + let signedInUserId: string | undefined; + if (provider === 'rowboat') { + try { + const billing = await getBillingInfo(); + if (billing.userId) { + signedInUserId = billing.userId; + analyticsIdentify(billing.userId, { + ...(billing.userEmail ? { email: billing.userEmail } : {}), + plan: billing.subscriptionPlan, + status: billing.subscriptionStatus, + }); + analyticsCapture('user_signed_in', { + plan: billing.subscriptionPlan, + status: billing.subscriptionStatus, + }); + } + } catch (meError) { + console.error('[OAuth] Failed to initialize user via /v1/me:', meError); + } + } - // Open in system browser (shares cookies/sessions with user's regular browser) - shell.openExternal(authUrl.toString()); - - return { success: true }; - } catch (setupError) { - // Post-bind setup failed — close the server so the port is released and - // a retry isn't blocked by our own zombie listener. - server.close(); - if (state) { + // Emit success event to renderer + emitOAuthEvent({ + provider, + success: true, + ...(signedInUserId ? { userId: signedInUserId } : {}), + }); + } catch (error) { + console.error('OAuth token exchange failed:', error); + // Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError) + let cause: unknown = error; + while (cause != null && typeof cause === 'object' && 'cause' in cause) { + cause = (cause as { cause?: unknown }).cause; + if (cause != null) { + console.error('[OAuth] Caused by:', cause); + } + } + const errorMessage = getOAuthErrorMessage(error); + emitOAuthEvent({ provider, success: false, error: errorMessage }); + throw error; + } finally { + // Clean up activeFlows.delete(state); + if (activeFlow && activeFlow.state === state) { + clearTimeout(activeFlow.cleanupTimeout); + activeFlow.server.close(); + activeFlow = null; + } } - throw setupError; - } + }); + + // Set timeout to clean up abandoned flows (2 minutes) + // This prevents memory leaks if user never completes the OAuth flow + const cleanupTimeout = setTimeout(() => { + if (activeFlow?.state === state) { + console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`); + cancelActiveFlow('timed_out'); + } + }, 2 * 60 * 1000); // 2 minutes + + // Store complete flow state for cleanup + activeFlow = { + provider, + state, + server, + cleanupTimeout, + }; + + // 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 }; } catch (error) { console.error('OAuth connection failed:', error); return { @@ -508,7 +431,7 @@ export async function disconnectProvider(provider: string): Promise<{ success: b if (connection.mode === 'rowboat' && connection.tokens?.access_token) { try { const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`; - const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) }); + const res = await fetch(revokeUrl, { method: 'POST' }); if (!res.ok) { console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`); } @@ -532,81 +455,6 @@ export async function disconnectProvider(provider: string): Promise<{ success: b } } -/** - * Startup migration for Google scope changes. When a connected Google grant was - * issued before a scope was added (e.g. old installs on gmail.readonly that - * never received gmail.modify), invalidate it so the user is prompted to - * reconnect and re-grant with the current scopes. The currently-requested - * scopes in the provider config are the source of truth: a grant missing any - * of them is treated as stale. - * - * We revoke + clear the stale token but DELIBERATELY keep the provider entry - * with an `error` set rather than calling disconnectProvider (which deletes the - * whole entry). The renderer's reconnect prompts — the sidebar "Reconnect your - * accounts" alert and the connectors "Reconnect" row — key off this `error` - * field, not off the connected flag. A fully deleted entry has no error and is - * indistinguishable from "never connected", so no prompt would ever appear. - * - * Tokens with no recorded scopes (very old installs that never persisted them) - * are also treated as stale. Safe to call on every startup — it's a no-op once - * the grant covers all current scopes, and once invalidated the early return on - * the missing token keeps it from re-running until the user reconnects. - */ -export async function disconnectGoogleIfScopesStale(): Promise { - try { - const oauthRepo = getOAuthRepo(); - const connection = await oauthRepo.read('google'); - - // Not connected (or already invalidated) — nothing to migrate. - if (!connection.tokens) { - return; - } - - const providerConfig = await getProviderConfig('google'); - const requiredScopes = providerConfig.scopes ?? []; - if (requiredScopes.length === 0) { - return; - } - - const granted = new Set(connection.tokens.scopes ?? []); - const missingScopes = requiredScopes.filter((scope) => !granted.has(scope)); - if (missingScopes.length === 0) { - return; - } - - console.log( - `[OAuth] Google grant is missing current scopes [${missingScopes.join(', ')}]; ` + - 'invalidating it so the user is prompted to reconnect with the new scopes.' - ); - - // Best-effort revoke at Google for rowboat-mode grants (mirrors disconnectProvider). - if (connection.mode === 'rowboat' && connection.tokens.access_token) { - try { - const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`; - const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) }); - if (!res.ok) { - console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local invalidation`); - } - } catch (error) { - console.warn('[OAuth] Google revoke failed; continuing with local invalidation:', error); - } - } - - // Drop the stale token but keep the entry with an error so the reconnect - // prompt fires (see the note above). - await oauthRepo.upsert('google', { - tokens: null, - error: 'Google permissions changed. Please reconnect to continue.', - }); - - // Nudge any already-open window to re-read state. The renderer's initial - // mount also re-reads, so the prompt shows even if no window is up yet. - emitOAuthEvent({ provider: 'google', success: false }); - } catch (error) { - console.error('[OAuth] Google scope migration check failed:', error); - } -} - /** * Get access token for a provider (internal use only) * Refreshes token if expired diff --git a/apps/x/apps/renderer/DESIGN_LANGUAGE.md b/apps/x/apps/renderer/DESIGN_LANGUAGE.md deleted file mode 100644 index cf412593..00000000 --- a/apps/x/apps/renderer/DESIGN_LANGUAGE.md +++ /dev/null @@ -1,41 +0,0 @@ -# Rowboat Design Language - -Rowboat should feel like a command center for people who live in notes, agents, email, meetings, and files all day. The launch direction is quiet, fast, and prosumer: dense enough for repeated work, warm enough to feel personal, and explicit about what the AI is doing. - -## Principles - -1. **Calm density** - Keep the interface compact and scannable. Use tighter rows, restrained borders, and low-contrast panels so users can keep many contexts open without the app feeling heavy. - -2. **Command first** - Primary actions should feel like instant commands, not marketing CTAs. Side navigation, search, model selection, and composer controls use compact icon-led affordances with clear hover and selected states. - -3. **Visible work state** - AI actions, sync, saving, meeting capture, and background tasks need clear status surfaces. Prefer small persistent indicators over large banners. - -4. **Notes as the canvas** - The editor and conversation stay visually dominant. Chrome is supportive, not decorative. Avoid nested cards and oversized empty states in work surfaces. - -5. **Neutral precision** - The palette follows the dev color system: white and graphite surfaces, black/white primary actions, neutral command tools, and reserved semantic colors for destructive and chart states. - -## Tokens - -- Radius: `8px` for controls and cards, smaller where density matters. -- Backgrounds: dev defaults in light and dark mode. -- Borders: one-step darker than surfaces, quiet enough to separate panels without tinting them. -- Shadows: reserved for the composer, menus, dialogs, and active segmented controls. -- Type: system sans with tabular-feeling OpenType features enabled; no negative tracking. -- Accent use: primary and command affordances use the neutral dev palette. Extra hues are reserved for semantic states and charts. - -## Core Surfaces - -- **Sidebar:** persistent workflow switcher with calm selected states. Quick-action icons use neutral ink from the dev palette. -- **Titlebar/tabs:** slim, scan-first navigation. Active tabs get a bottom signal line, not a bulky filled pill. -- **Composer:** the highest-emphasis control outside the active canvas. It is slightly raised, flat, bordered by the primary tone, and sharp enough to feel like an input terminal. -- **Messages:** user messages are compact structured blocks; assistant messages remain full-width and readable. -- **Status:** sync, saving, recording, and task activity stay small but always visible near the surface they affect. - -## Launch Positioning - -The visual story is: **Rowboat is the personal AI workspace for people whose work already spans meetings, mail, notes, browser tasks, and agents.** It should feel closer to a focused desktop tool than a chat website. diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index 67876189..a193b3f1 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -9,7 +9,6 @@ "preview": "vite preview" }, "dependencies": { - "@eigenpal/docx-editor-react": "^1.0.3", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", @@ -47,15 +46,6 @@ "motion": "^12.23.26", "nanoid": "^5.1.6", "posthog-js": "^1.332.0", - "prosemirror-commands": "^1.7.1", - "prosemirror-dropcursor": "^1.8.2", - "prosemirror-history": "^1.5.0", - "prosemirror-keymap": "^1.2.3", - "prosemirror-model": "^1.25.7", - "prosemirror-state": "^1.4.4", - "prosemirror-tables": "^1.8.5", - "prosemirror-transform": "^1.12.0", - "prosemirror-view": "^1.41.8", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 86c6535d..5c1eabb2 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -35,30 +35,6 @@ } } -/* Radix Collapsible expand/collapse — animate height (via the radix CSS var) - plus a subtle fade. Used by the web search card. */ -@keyframes collapsible-down { - from { - height: 0; - opacity: 0; - } - to { - height: var(--radix-collapsible-content-height); - opacity: 1; - } -} - -@keyframes collapsible-up { - from { - height: var(--radix-collapsible-content-height); - opacity: 1; - } - to { - height: 0; - opacity: 0; - } -} - @media (prefers-reduced-motion: no-preference) { a:nth-of-type(2) .logo { animation: logo-spin infinite 20s linear; @@ -82,1047 +58,6 @@ background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px); } -.gmail-shell { - --gm-bg: #0f0f12; - --gm-bg-card: #131317; - --gm-bg-input: #1c1c20; - --gm-bg-row-hover: #16161a; - --gm-bg-row-selected: #1a1620; - --gm-bg-row-selected-hover: #1d1825; - --gm-bg-iframe: #fafafa; - --gm-bg-pill: #1c1c20; - --gm-bg-pill-hover: #232328; - --gm-text: #e4e4e7; - --gm-text-strong: #fafafa; - --gm-text-muted: #71717a; - --gm-text-faint: #52525b; - --gm-text-body: #d4d4d8; - --gm-border: #1f1f24; - --gm-border-strong: #2e2e35; - --gm-accent: #a78bfa; - --gm-accent-hover: #b9a6ff; - --gm-accent-glow: rgba(167, 139, 250, 0.45); - --gm-accent-fg: #18181b; - --gm-icon-hover-bg: #1f1f24; - --gm-placeholder: #52525b; - - display: flex; - height: 100%; - min-height: 0; - width: 100%; - overflow: hidden; - background: var(--gm-bg); - color: var(--gm-text); - font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - font-feature-settings: "ss01", "cv11"; -} - -.light .gmail-shell { - --gm-bg: #ffffff; - --gm-bg-card: #ffffff; - --gm-bg-input: #f4f4f7; - --gm-bg-row-hover: #f7f7f9; - --gm-bg-row-selected: #f5f0ff; - --gm-bg-row-selected-hover: #ece4ff; - --gm-bg-iframe: #ffffff; - --gm-bg-pill: #ffffff; - --gm-bg-pill-hover: #f4f4f7; - --gm-text: #27272a; - --gm-text-strong: #09090b; - --gm-text-muted: #71717a; - --gm-text-faint: #a1a1aa; - --gm-text-body: #3f3f46; - --gm-border: #e4e4e7; - --gm-border-strong: #d4d4d8; - --gm-accent: #7c3aed; - --gm-accent-hover: #6d28d9; - --gm-accent-glow: rgba(124, 58, 237, 0.3); - --gm-accent-fg: #ffffff; - --gm-icon-hover-bg: #f4f4f7; - --gm-placeholder: #a1a1aa; -} - -.gmail-main { - display: flex; - min-width: 0; - min-height: 0; - flex: 1; - flex-direction: column; - padding: 0; -} - -.gmail-topbar { - display: flex; - align-items: center; - gap: 12px; - height: 52px; - padding: 0 20px; - border-bottom: 1px solid var(--gm-border); -} - -.gmail-search { - display: flex; - align-items: center; - gap: 10px; - width: min(520px, 100%); - height: 32px; - padding: 0 12px; - border-radius: 8px; - background: var(--gm-bg-input); - color: var(--gm-text-muted); -} - -.gmail-search input { - flex: 1; - border: none; - outline: none; - background: transparent; - color: var(--gm-text); - font-size: 13px; - letter-spacing: 0.01em; -} - -.gmail-search input::placeholder { - color: var(--gm-placeholder); -} - -.gmail-icon-button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - border: none; - border-radius: 6px; - background: transparent; - color: var(--gm-text-muted); - cursor: pointer; - transition: background 120ms ease, color 120ms ease; -} - -.gmail-icon-button:hover { - background: var(--gm-icon-hover-bg); - color: var(--gm-text); -} - -.gmail-list { - display: flex; - flex-direction: column; - min-width: 0; - min-height: 0; - flex: 1; - overflow: auto; - background: var(--gm-bg); - border: none; - border-radius: 0; -} - -.gmail-row-group { - display: flex; - flex-direction: column; -} - -.gmail-list-header { - position: sticky; - top: 0; - z-index: 1; - display: flex; - justify-content: space-between; - height: 32px; - padding: 0 24px; - align-items: center; - background: var(--gm-bg); - border-bottom: 1px solid var(--gm-border); - color: var(--gm-text-faint); - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.08em; -} - -.gmail-section { - display: flex; - flex-direction: column; -} - -.gmail-section + .gmail-section { - margin-top: 28px; -} - -.gmail-section-sentinel { - display: flex; - align-items: center; - justify-content: center; - height: 28px; - color: var(--gm-text-faint); -} - -.gmail-row-shell { - position: relative; -} - -.gmail-row { - display: grid; - grid-template-columns: 12px minmax(140px, 0.22fr) minmax(0, 1fr) 60px; - align-items: center; - gap: 16px; - width: 100%; - min-height: 40px; - padding: 0 24px; - border: none; - background: transparent; - color: var(--gm-text-muted); - text-align: left; - cursor: pointer; - font-family: inherit; - transition: background 120ms ease; -} - -.gmail-row-actions { - position: absolute; - right: 16px; - top: 50%; - transform: translateY(-50%); - display: flex; - align-items: center; - gap: 2px; - opacity: 0; - pointer-events: none; - transition: opacity 120ms ease; -} - -.gmail-row-shell:hover .gmail-row-actions { - opacity: 1; - pointer-events: auto; -} - -.gmail-row-shell:hover .gmail-row-date { - visibility: hidden; -} - -.gmail-row-action { - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - border: none; - border-radius: 4px; - background: transparent; - color: var(--gm-text-muted); - cursor: pointer; - transition: background 120ms ease, color 120ms ease; -} - -.gmail-row-action:hover { - background: var(--gm-bg-pill-hover); - color: var(--gm-text-strong); -} - -.gmail-row-action-danger:hover { - color: #e8453c; -} - -.gmail-row:hover { - background: var(--gm-bg-row-hover); - box-shadow: none; -} - -.gmail-row-selected { - background: var(--gm-bg-row-selected); - box-shadow: inset 2px 0 0 var(--gm-accent); -} - -.gmail-row-selected:hover { - background: var(--gm-bg-row-selected-hover); -} - -.gmail-row-unread { - color: var(--gm-text); -} - -.gmail-row-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: transparent; -} - -.gmail-row-unread .gmail-row-dot { - background: var(--gm-accent); - box-shadow: 0 0 8px var(--gm-accent-glow); -} - -.gmail-row-sender, -.gmail-row-content strong, -.gmail-row-date { - font-size: 13px; - font-weight: 400; - letter-spacing: -0.005em; -} - -.gmail-row-unread .gmail-row-sender, -.gmail-row-unread .gmail-row-content strong { - font-weight: 600; - color: var(--gm-text-strong); -} - -.gmail-row-unread .gmail-row-date { - color: var(--gm-text); -} - -.gmail-row-sender, -.gmail-row-content, -.gmail-row-content span { - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.gmail-row-content { - display: flex; - gap: 8px; - color: var(--gm-text-faint); - font-size: 13px; -} - -.gmail-row-content strong { - flex-shrink: 0; - color: inherit; - font-weight: 400; -} - -.gmail-row-date { - justify-self: end; - color: var(--gm-text-faint); - white-space: nowrap; - font-variant-numeric: tabular-nums; -} - -.gmail-detail { - display: flex; - min-width: 0; - flex-direction: column; - background: transparent; -} - -.gmail-detail-inline { - background: var(--gm-bg-card); - border-top: 1px solid var(--gm-border); - border-bottom: 1px solid var(--gm-border); - box-shadow: inset 2px 0 0 var(--gm-accent); -} - -.gmail-detail-hidden { - display: none; -} - -.gmail-detail-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - height: 48px; - padding: 0 24px; - border-bottom: 1px solid var(--gm-border); - background: transparent; -} - -.gmail-thread-subject-inline { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--gm-text-strong); - font-size: 15px; - font-weight: 500; - letter-spacing: -0.01em; -} - -.gmail-thread-body { - padding: 20px 24px 28px; - background: transparent; -} - -.gmail-thread-summary { - margin-bottom: 20px; - padding-bottom: 16px; - border-bottom: 1px solid var(--gm-border); -} - -.gmail-thread-summary-label { - display: block; - margin-bottom: 6px; - color: var(--gm-text-faint); - font-size: 10px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.gmail-thread-summary-text { - display: block; - color: var(--gm-text); - font-size: 13px; - line-height: 1.55; -} - -.gmail-message-stack { - display: flex; - flex-direction: column; - gap: 12px; -} - -.gmail-message { - display: grid; - grid-template-columns: 28px minmax(0, 1fr); - gap: 12px; - padding: 12px 0; - border-top: 1px solid var(--gm-border); -} - -.gmail-message:first-child { - border-top: 0; - padding-top: 4px; -} - -.gmail-message-header { - display: block; - width: 100%; - padding: 0; - margin: 0; - border: none; - background: transparent; - color: inherit; - font: inherit; - text-align: left; - cursor: pointer; -} - -.gmail-message-snippet { - margin-top: 2px; - color: var(--gm-text-muted); - font-size: 12px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.gmail-message:not(.gmail-message-expanded) .gmail-message-header:hover .gmail-message-from strong { - color: var(--gm-accent); -} - -.gmail-message-avatar { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 50%; - color: #fafafa; - font-weight: 600; - font-size: 11px; - letter-spacing: 0.02em; -} - -.gmail-message-main { - min-width: 0; -} - -.gmail-message-meta { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; -} - -.gmail-message-from { - display: flex; - align-items: baseline; - gap: 8px; - min-width: 0; -} - -.gmail-message-from strong, -.gmail-message-from span { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.gmail-message-from strong { - font-size: 13px; - font-weight: 600; - color: var(--gm-text-strong); - letter-spacing: -0.005em; -} - -.gmail-message-from span, -.gmail-message-to, -.gmail-message-cc, -.gmail-message-date { - color: var(--gm-text-muted); - font-size: 12px; -} - -.gmail-message-date { - flex-shrink: 0; - font-variant-numeric: tabular-nums; -} - -.gmail-message-iframe { - display: block; - width: 100%; - max-width: 820px; - margin-top: 12px; - border: 0; - background: var(--gm-bg-iframe); - border-radius: 6px; -} - -.gmail-message-iframe-adaptive { - background: var(--gm-bg-card); -} - -.gmail-message-plain { - max-width: 820px; - margin-top: 12px; - padding: 10px 14px; - background: var(--gm-bg-iframe); - border-radius: 6px; - color: var(--gm-text); -} - -.gmail-message-pre { - margin: 0; - font: 14px/1.6 Arial, sans-serif; - white-space: pre-wrap; - word-wrap: break-word; -} - -.gmail-message-pre-quoted { - margin-top: 12px; - color: var(--gm-text-muted); -} - -.gmail-quote-toggle { - display: inline-flex; - align-items: center; - justify-content: center; - margin-top: 8px; - height: 22px; - padding: 0 10px; - border: 1px solid var(--gm-border-strong); - border-radius: 4px; - background: var(--gm-bg-pill); - color: var(--gm-text-muted); - font: inherit; - font-size: 12px; - letter-spacing: 0.04em; - cursor: pointer; - transition: background 120ms ease, color 120ms ease, border-color 120ms ease; -} - -.gmail-quote-toggle:hover { - background: var(--gm-bg-pill-hover); - color: var(--gm-text-strong); - border-color: var(--gm-border-strong); -} - -.gmail-quote-toggle[aria-expanded="true"] { - background: var(--gm-bg-row-selected); - color: var(--gm-accent); - border-color: var(--gm-accent); -} - -.gmail-message-attachments { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 14px; - max-width: 820px; -} - -.gmail-attachment { - display: inline-flex; - align-items: center; - gap: 8px; - max-width: 320px; - padding: 6px 10px; - border: 1px solid var(--gm-border-strong); - border-radius: 6px; - background: var(--gm-bg-pill); - color: var(--gm-text); - font: inherit; - font-size: 12px; - cursor: pointer; - transition: background 120ms ease, border-color 120ms ease, color 120ms ease; -} - -.gmail-attachment:hover { - background: var(--gm-bg-pill-hover); - border-color: var(--gm-accent); - color: var(--gm-accent); -} - -.gmail-attachment-name { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - font-weight: 500; -} - -.gmail-attachment-size { - flex-shrink: 0; - color: var(--gm-text-muted); - font-size: 11px; - font-variant-numeric: tabular-nums; -} - -.gmail-thread-actions { - display: flex; - gap: 8px; - margin: 20px 0 12px 40px; -} - -.gmail-thread-actions button { - display: inline-flex; - align-items: center; - gap: 8px; - height: 30px; - padding: 0 14px; - border: 1px solid var(--gm-border-strong); - border-radius: 6px; - background: var(--gm-bg-pill); - color: var(--gm-text-body); - font: inherit; - font-size: 12px; - cursor: pointer; - transition: background 120ms ease, border-color 120ms ease; -} - -.gmail-thread-actions button:hover { - background: var(--gm-bg-pill-hover); - border-color: var(--gm-border-strong); -} - -.gmail-compose-card { - max-width: 720px; - margin-left: 40px; - border: 1px solid var(--gm-border-strong); - border-radius: 8px; - overflow: hidden; - background: var(--gm-bg-card); -} - -.gmail-compose-header { - display: flex; - align-items: center; - justify-content: space-between; - height: 32px; - padding: 0 12px; - background: var(--gm-bg-input); - color: var(--gm-text-body); - font-size: 12px; - font-weight: 500; - letter-spacing: 0.01em; - text-transform: uppercase; -} - -.gmail-compose-header button, -.gmail-compose-link { - border: none; - background: transparent; - color: var(--gm-text-muted); - cursor: pointer; -} - -.gmail-compose-line { - display: flex; - align-items: center; - gap: 8px; - min-height: 32px; - padding: 0 12px; - border-bottom: 1px solid var(--gm-border); - color: var(--gm-text-muted); - font-size: 12px; -} - -.gmail-compose-line input { - min-width: 0; - flex: 1; - border: none; - outline: none; - background: transparent; - color: var(--gm-text); - font: inherit; -} - -.gmail-compose-label { - flex: none; - min-width: 28px; - color: var(--gm-text-muted); -} - -.gmail-compose-subject-input { - min-width: 0; - flex: 1; - border: none; - outline: none; - background: transparent; - color: var(--gm-text); - font: inherit; -} - -/* Recipient (To / Cc / Bcc) rows with editable chips */ -.gmail-recipient-row { - display: flex; - align-items: flex-start; - gap: 8px; - min-height: 34px; - padding: 5px 12px; - border-bottom: 1px solid var(--gm-border); - font-size: 13px; -} - -.gmail-recipient-label { - flex: none; - min-width: 28px; - padding-top: 5px; - color: var(--gm-text-muted); -} - -.gmail-recipient-field { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px; - flex: 1; - min-width: 0; -} - -.gmail-recipient-chip { - display: inline-flex; - align-items: center; - gap: 4px; - max-width: 100%; - height: 24px; - padding: 0 4px 0 10px; - border-radius: 12px; - background: var(--gm-bg-pill); - color: var(--gm-text); - font-size: 12px; - line-height: 1; -} - -.gmail-recipient-chip-label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 240px; -} - -.gmail-recipient-chip-remove { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - border: none; - border-radius: 50%; - background: transparent; - color: var(--gm-text-muted); - font-size: 14px; - line-height: 1; - cursor: pointer; -} - -.gmail-recipient-chip-remove:hover { - background: var(--gm-bg-pill-hover); - color: var(--gm-text); -} - -.gmail-recipient-input { - flex: 1 1 80px; - min-width: 80px; - height: 24px; - border: none; - outline: none; - background: transparent; - color: var(--gm-text); - font: inherit; - font-size: 13px; -} - -.gmail-recipient-trailing { - flex: none; - padding-top: 5px; -} - -.gmail-recipient-toggles { - display: flex; - gap: 10px; -} - -.gmail-recipient-toggles button { - border: none; - background: transparent; - color: var(--gm-text-muted); - font: inherit; - font-size: 12px; - cursor: pointer; -} - -.gmail-recipient-toggles button:hover { - color: var(--gm-text); - text-decoration: underline; -} - -.gmail-compose-toolbar { - display: flex; - align-items: center; - gap: 2px; - flex: 1; - min-width: 0; - justify-content: center; -} - -.gmail-compose-link-popover { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 12px; - border-top: 1px solid var(--gm-border); - background: var(--gm-bg-input); -} - -.gmail-compose-link-popover input { - flex: 1; - min-width: 0; - height: 28px; - padding: 0 8px; - border: 1px solid var(--gm-border-strong); - border-radius: 4px; - background: var(--gm-bg-card); - color: var(--gm-text); - font: inherit; - font-size: 12px; - outline: none; -} - -.gmail-compose-link-popover input:focus { - border-color: var(--gm-accent); -} - -.gmail-compose-link-popover button { - height: 26px; - padding: 0 10px; - border: 1px solid var(--gm-border-strong); - border-radius: 4px; - background: var(--gm-bg-pill); - color: var(--gm-text); - font: inherit; - font-size: 12px; - cursor: pointer; -} - -.gmail-compose-link-popover button:hover { - background: var(--gm-bg-pill-hover); -} - -.gmail-compose-link-popover-apply { - background: var(--gm-accent) !important; - border-color: var(--gm-accent) !important; - color: var(--gm-accent-fg) !important; - font-weight: 600; -} - -.gmail-compose-link-popover-apply:hover { - background: var(--gm-accent-hover) !important; - border-color: var(--gm-accent-hover) !important; -} - -.gmail-compose-tool { - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - border: none; - border-radius: 4px; - background: transparent; - color: var(--gm-text-muted); - cursor: pointer; - transition: background 120ms ease, color 120ms ease; -} - -.gmail-compose-tool:hover { - background: var(--gm-bg-pill-hover); - color: var(--gm-text); -} - -.gmail-compose-tool.is-active { - background: var(--gm-bg-pill-hover); - color: var(--gm-accent); -} - -.gmail-compose-tool-sep { - display: inline-block; - width: 1px; - height: 18px; - margin: 0 6px; - background: var(--gm-border-strong); -} - -.gmail-compose-editor { - display: block; - width: 100%; - max-height: 360px; - overflow-y: auto; -} - -.gmail-compose-content { - outline: none; - min-height: 120px; - padding: 12px; - background: transparent; - color: var(--gm-text); - font: 13px/1.55 inherit; -} - -.gmail-compose-content p { - margin: 0; -} - -.gmail-compose-content p + p, -.gmail-compose-content p + ul, -.gmail-compose-content p + ol, -.gmail-compose-content p + blockquote { - margin-top: 8px; -} - -.gmail-compose-content ul, -.gmail-compose-content ol { - margin: 0; - padding-left: 22px; -} - -.gmail-compose-content ul { - list-style: disc; -} - -.gmail-compose-content ol { - list-style: decimal; -} - -.gmail-compose-content li { - margin: 2px 0; -} - -.gmail-compose-content li > p { - margin: 0; -} - -.gmail-compose-content blockquote { - margin: 4px 0; - padding-left: 12px; - border-left: 2px solid var(--gm-border-strong); - color: var(--gm-text-muted); -} - -.gmail-compose-content a { - color: var(--gm-accent); - text-decoration: underline; -} - -.gmail-compose-content code { - padding: 1px 4px; - border-radius: 3px; - background: var(--gm-bg-pill-hover); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 12px; -} - -.gmail-compose-content p.is-editor-empty:first-child::before { - content: attr(data-placeholder); - color: var(--gm-placeholder); - float: left; - height: 0; - pointer-events: none; -} - -.gmail-compose-actions { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 12px; - border-top: 1px solid var(--gm-border); -} - -.gmail-compose-actions-primary { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; -} - -.gmail-refine-button { - display: inline-flex; - align-items: center; - gap: 8px; - box-sizing: border-box; - height: 30px; - padding: 0 14px; - border: 1px solid var(--gm-border-strong); - border-radius: 6px; - background: var(--gm-bg-pill); - color: var(--gm-text); - font: inherit; - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: background 120ms ease, border-color 120ms ease, color 120ms ease; -} - -.gmail-refine-button:hover { - background: var(--gm-bg-pill-hover); - border-color: var(--gm-accent); - color: var(--gm-accent); -} - -.gmail-send-button { - display: inline-flex; - align-items: center; - gap: 8px; - box-sizing: border-box; - height: 30px; - padding: 0 14px; - border: 1px solid transparent; - border-radius: 6px; - background: var(--gm-accent); - color: var(--gm-accent-fg); - font: inherit; - font-size: 12px; - font-weight: 600; - cursor: pointer; -} - -.gmail-send-button:hover { - background: var(--gm-accent-hover); -} - -.gmail-empty-state { - display: flex; - align-items: center; - justify-content: center; - flex: 1; - min-height: 0; - background: transparent; - color: var(--gm-text-faint); - font-size: 13px; -} - @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); @@ -1165,7 +100,7 @@ } :root { - --radius: 0.5rem; + --radius: 0.625rem; --background: var(--bg-color, oklch(1 0 0)); --foreground: var(--text-color, oklch(0.145 0 0)); --card: var(--bg-color, oklch(1 0 0)); @@ -1193,25 +128,13 @@ --sidebar-foreground: var(--text-color, oklch(0.145 0 0)); --sidebar-primary: var(--main-color, oklch(0.205 0 0)); --sidebar-primary-foreground: var(--bg-color, oklch(0.985 0 0)); - --sidebar-accent: var(--sub-color, oklch(0.9 0 0)); + --sidebar-accent: var(--sub-color, oklch(0.90 0 0)); --sidebar-accent-foreground: var(--text-color, oklch(0.205 0 0)); --sidebar-border: var(--sub-alt-color, oklch(0.922 0 0)); --sidebar-ring: var(--main-color, oklch(0.708 0 0)); --scrollbar-track: oklch(0.95 0 0); --scrollbar-thumb: oklch(0.75 0 0); --scrollbar-thumb-hover: oklch(0.65 0 0); - /* Subtle raised-card surface: tints toward foreground, so it reads a hair - darker than the background in light mode and a hair lighter in dark mode. - Shared by the web search card and tool-call group. */ - --card-surface: color-mix(in oklab, var(--background) 98.5%, var(--foreground)); - --rowboat-panel: oklch(0.97 0 0); - --rowboat-raised: oklch(1 0 0); - --rowboat-wash: color-mix(in oklab, var(--background) 88%, var(--primary) 12%); - --rowboat-hairline: color-mix(in oklab, var(--border) 78%, var(--foreground) 22%); - --rowboat-command: oklch(0.205 0 0); - --rowboat-attention: oklch(0.646 0.222 41.116); - --rowboat-shadow: 0 1px 2px rgb(31 38 48 / 0.07), 0 18px 40px rgb(31 38 48 / 0.08); - --rowboat-shadow-soft: 0 1px 2px rgb(31 38 48 / 0.05), 0 8px 24px rgb(31 38 48 / 0.06); } .dark { @@ -1249,14 +172,6 @@ --scrollbar-track: oklch(0.2 0 0); --scrollbar-thumb: oklch(0.4 0 0); --scrollbar-thumb-hover: oklch(0.5 0 0); - --rowboat-panel: oklch(0.269 0 0); - --rowboat-raised: oklch(0.205 0 0); - --rowboat-wash: color-mix(in oklab, var(--background) 80%, var(--primary) 20%); - --rowboat-hairline: color-mix(in oklab, var(--border) 70%, var(--foreground) 30%); - --rowboat-command: oklch(0.922 0 0); - --rowboat-attention: oklch(0.769 0.188 70.08); - --rowboat-shadow: 0 1px 2px rgb(0 0 0 / 0.24), 0 22px 44px rgb(0 0 0 / 0.28); - --rowboat-shadow-soft: 0 1px 2px rgb(0 0 0 / 0.2), 0 12px 28px rgb(0 0 0 / 0.18); } @layer base { @@ -1268,9 +183,6 @@ body { @apply bg-background text-foreground; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - font-feature-settings: "cv02", "cv03", "cv04", "cv11"; - text-rendering: optimizeLegibility; } ::-webkit-scrollbar { @@ -1292,94 +204,6 @@ } } -.rowboat-shell { - background: var(--background); -} - -.rowboat-sidebar [data-sidebar="sidebar"] { - background: var(--sidebar); - border-right: 1px solid var(--sidebar-border); -} - -.rowboat-sidebar [data-sidebar="header"] { - gap: 0.625rem; - border-bottom: 1px solid color-mix(in oklab, var(--sidebar-border) 76%, transparent); - background: var(--sidebar); -} - -.rowboat-section-switcher { - border: 1px solid color-mix(in oklab, var(--sidebar-border) 78%, transparent); - background: color-mix(in oklab, var(--sidebar-accent) 58%, var(--sidebar) 42%); - box-shadow: inset 0 1px 0 color-mix(in oklab, var(--foreground) 9%, transparent); -} - -.rowboat-section-switcher button { - letter-spacing: 0; -} - -.rowboat-section-switcher button[class*="shadow-sm"] { - background: var(--rowboat-raised); - box-shadow: var(--rowboat-shadow-soft); -} - -.rowboat-quick-actions button { - min-height: 2rem; -} - -.rowboat-quick-actions button svg { - color: color-mix(in oklab, var(--rowboat-command) 72%, var(--sidebar-foreground) 28%); -} - -.rowboat-tabbar { - background: color-mix(in oklab, var(--background) 78%, var(--muted) 22%); -} - -.rowboat-titlebar { - background: var(--background); - border-bottom-color: color-mix(in oklab, var(--border) 82%, transparent); -} - -.rowboat-tab { - min-height: 2.5rem; - border-bottom: 1px solid transparent; -} - -.rowboat-tab[class*="bg-background"] { - border-bottom-color: var(--primary); - background: var(--rowboat-raised); - box-shadow: inset 0 1px 0 color-mix(in oklab, var(--foreground) 8%, transparent); -} - -.rowboat-composer-dock { - box-shadow: 0 -20px 44px color-mix(in oklab, var(--background) 78%, transparent); -} - -.rowboat-chat-input { - border-color: color-mix(in oklab, var(--border) 74%, var(--primary) 26%); - background: var(--rowboat-raised); - box-shadow: var(--rowboat-shadow); -} - -.rowboat-chat-input:focus-within { - border-color: color-mix(in oklab, var(--ring) 76%, var(--border) 24%); - box-shadow: - 0 0 0 1px color-mix(in oklab, var(--ring) 38%, transparent), - var(--rowboat-shadow); -} - -[data-slot="message-content"] { - line-height: 1.62; -} - -.is-user [data-slot="message-content"] { - background: color-mix(in oklab, var(--secondary) 88%, var(--rowboat-wash) 12%); - border: 1px solid color-mix(in oklab, var(--border) 68%, transparent); -} - -.is-assistant [data-slot="message-content"] { - color: color-mix(in oklab, var(--foreground) 94%, var(--muted-foreground) 6%); -} - /* Markdown content base styles for Streamdown/MessageResponse */ @layer components { /* Target elements inside MessageResponse wrapper */ diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index b850b57f..5ec8476b 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,20 +5,17 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; -import { ChatHeader } from './components/chat-header'; -import { ChatEmptyState } from './components/chat-empty-state'; -import { ChatInputWithMentions, type PermissionMode, type StagedAttachment } from './components/chat-input-with-mentions'; +import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view'; import { ImageFileViewer } from '@/components/image-file-viewer'; import { VideoFileViewer } from '@/components/video-file-viewer'; import { AudioFileViewer } from '@/components/audio-file-viewer'; -import { DocxFileViewer } from '@/components/docx-file-viewer'; import { PersistentViewerCache } from '@/components/persistent-viewer-cache'; import { UnsupportedFileViewer } from '@/components/unsupported-file-viewer'; import { getViewerType, isCacheableViewerPath } from '@/lib/file-types'; @@ -26,18 +23,11 @@ import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; import { SuggestedTopicsView } from '@/components/suggested-topics-view'; import { LiveNotesView } from '@/components/live-notes-view'; -import { BgTasksView } from '@/components/bg-tasks-view'; -import { EmailView } from '@/components/email-view'; -import { WorkspaceView } from '@/components/workspace-view'; -import { CodingRunBlock } from '@/components/coding-run'; -import { KnowledgeView } from '@/components/knowledge-view'; -import { ChatHistoryView } from '@/components/chat-history-view'; -import { HomeView } from '@/components/home-view'; -import { MeetingsView } from '@/components/meetings-view'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { Conversation, ConversationContent, + ConversationEmptyState, ConversationScrollButton, } from '@/components/ai-elements/conversation'; import { @@ -57,10 +47,10 @@ import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; -import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'; import { TerminalOutput } from '@/components/terminal-output'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; -import { ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; +import { Suggestions } from '@/components/ai-elements/suggestions'; +import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; import { SidebarInset, SidebarProvider, @@ -70,14 +60,12 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Toaster } from "@/components/ui/sonner" -import { BillingErrorDialog } from "@/components/billing-error-dialog" -import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error" -import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' +import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { extractConferenceLink } from '@/lib/calendar-event' import { OnboardingModal } from '@/components/onboarding' import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal' -import { CommandPalette, type CommandPaletteMention, type SearchType } from '@/components/search-dialog' +import { CommandPalette, type CommandPaletteMention } from '@/components/search-dialog' import { LiveNoteSidebar } from '@/components/live-note-sidebar' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { BrowserPane } from '@/components/browser-pane/BrowserPane' @@ -118,7 +106,6 @@ import { useVoiceTTS } from '@/hooks/useVoiceTTS' import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity' import * as analytics from '@/lib/analytics' -import { useTheme } from '@/contexts/theme-context' type DirEntry = z.infer type RunEventType = z.infer @@ -167,7 +154,6 @@ function AutoScrollPre({ className, children }: { className?: string; children: } const DEFAULT_SIDEBAR_WIDTH = 256 -const DEFAULT_CHAT_PANE_WIDTH = 460 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ { hue: 210, sat: 72, light: 52 }, @@ -189,15 +175,7 @@ const TITLEBAR_BUTTONS_COLLAPSED = 1 const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0 const GRAPH_TAB_PATH = '__rowboat_graph_view__' const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__' -const MEETINGS_TAB_PATH = '__rowboat_meetings__' const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__' -const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__' -const EMAIL_TAB_PATH = '__rowboat_email__' -const WORKSPACE_TAB_PATH = '__rowboat_workspace__' -const WORKSPACE_ROOT = 'knowledge/Workspace' -const KNOWLEDGE_VIEW_TAB_PATH = '__rowboat_knowledge_view__' -const CHAT_HISTORY_TAB_PATH = '__rowboat_chat_history__' -const HOME_TAB_PATH = '__rowboat_home__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => @@ -327,14 +305,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => { const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH -const isMeetingsTabPath = (path: string) => path === MEETINGS_TAB_PATH const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH -const isBgTasksTabPath = (path: string) => path === BG_TASKS_TAB_PATH -const isEmailTabPath = (path: string) => path === EMAIL_TAB_PATH -const isWorkspaceTabPath = (path: string) => path === WORKSPACE_TAB_PATH -const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PATH -const isChatHistoryTabPath = (path: string) => path === CHAT_HISTORY_TAB_PATH -const isHomeTabPath = (path: string) => path === HOME_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const getSuggestedTopicTargetFolder = (category?: string) => { @@ -394,12 +365,6 @@ const buildSuggestedTopicExplorePrompt = ({ const buildLiveNoteSetupPrompt = () => 'I want to set up a Live note / task.' -const buildBgTaskSetupPrompt = (description: string) => - `Create a background task for me. Here's what I want it to do:\n\n${description}` - -const buildBgTaskEditPrompt = (slug: string) => - `Let's tweak the background task \`${slug}\`. Please load the \`background-task\` skill first, read the task's current \`bg-tasks/${slug}/task.yaml\`, then ask me what I want to change.` - const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => { if (!usage) return null const hasNumbers = Object.values(usage).some((value) => typeof value === 'number') @@ -582,21 +547,13 @@ type ViewState = | { type: 'graph' } | { type: 'task'; name: string } | { type: 'suggested-topics' } - | { type: 'meetings' } | { type: 'live-notes' } - | { type: 'email' } - | { type: 'workspace'; path?: string } - | { type: 'knowledge-view'; folderPath?: string } - | { type: 'chat-history' } - | { type: 'home' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false if (a.type === 'chat' && b.type === 'chat') return a.runId === b.runId if (a.type === 'file' && b.type === 'file') return a.path === b.path if (a.type === 'task' && b.type === 'task') return a.name === b.name - if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '') - if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '') return true // both graph } @@ -604,13 +561,12 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { * Parse a rowboat:// deep link into a ViewState. Returns null if the URL is * malformed or names an unknown target. * - * Shape: rowboat://open?type=&... + * Shape: rowboat://open?type=&... * file: ?type=file&path=knowledge/foo.md * chat: ?type=chat&runId=abc123 (runId optional) * graph: ?type=graph * task: ?type=task&name=daily-brief * suggested-topics: ?type=suggested-topics - * meetings: ?type=meetings * live-notes: ?type=live-notes */ function parseDeepLink(input: string): ViewState | null { @@ -636,22 +592,8 @@ function parseDeepLink(input: string): ViewState | null { } case 'suggested-topics': return { type: 'suggested-topics' } - case 'meetings': - return { type: 'meetings' } case 'live-notes': return { type: 'live-notes' } - case 'workspace': { - const path = params.get('path') - return { type: 'workspace', path: path ?? undefined } - } - case 'knowledge-view': { - const folderPath = params.get('folderPath') - return { type: 'knowledge-view', folderPath: folderPath ?? undefined } - } - case 'chat-history': - return { type: 'chat-history' } - case 'home': - return { type: 'home' } default: return null } @@ -665,7 +607,7 @@ function FixedSidebarToggle({ }) { const { toggleSidebar } = useSidebar() return ( -
+