diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index ec60096f..e68ce4f4 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -16,9 +16,9 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v6 @@ -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 @@ -122,9 +122,9 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v6 @@ -145,17 +145,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); " @@ -163,12 +163,25 @@ jobs: run: pnpm install --frozen-lockfile working-directory: apps/x + - name: Build node-pty native binary for Linux + working-directory: apps/x + run: | + # node-pty ships prebuilt binaries only for darwin/win32; compile the + # linux-x64 binary so bundle.mjs can stage it into the package. Without + # this the Linux app crashes on launch (missing prebuilds/linux-x64/pty.node). + PTY="node_modules/.pnpm/node-pty@1.1.0/node_modules/node-pty" + cd "$PTY" + npx node-gyp rebuild + mkdir -p prebuilds/linux-x64 + cp build/Release/pty.node prebuilds/linux-x64/ + - name: Build electron app env: VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npx electron-forge publish --arch=x64,arm64 --platform=linux + ROWBOAT_SKIP_PACMAN: '1' # Arch Linux package is built locally only, never in CI + run: npx electron-forge publish --arch=x64 --platform=linux working-directory: apps/x/apps/main - name: Upload workflow artifacts @@ -187,9 +200,9 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v6 @@ -212,17 +225,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); " diff --git a/README.md b/README.md index 361b87a0..ade23320 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ - - rowboat-github-2 - -
+

Rowboat

+

A desktop AI coworker with a memory of your work and built-in surfaces to act on it.

+

rowboatlabs/rowboat | Trendshift @@ -25,28 +24,95 @@

-# Rowboat -**Open-source AI coworker that turns work into a knowledge graph and acts on it** - -Rowboat connects to your email and meeting notes, builds a long-lived knowledge graph, and uses that context to help you get work done - privately, on your machine. +Rowboat indexes your work into a living knowledge graph and uses that to get work done on your machine. It includes work surfaces for collaborating with AI: email client, notes, browser, code mode, meeting note taker, and workspaces for different projects. -You can do things like: -- `Build me a deck about our next quarter roadmap` → generates a PDF using context from your knowledge graph -- `Prep me for my meeting with Alex` → pulls past decisions, open questions, and relevant threads into a crisp brief (or a voice note) -- Track a person, company or topic through live notes -- Visualize, edit, and update your knowledge graph anytime (it’s just Markdown) -- Record voice memos that automatically capture and update key takeaways in the graph Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/downloads) +

+ Screenshot 2026-06-24 at 11 40 45 PM +

+ + +

+ Demo - email to code · Demo - knowledge graph +

+ + ⭐ If you find Rowboat useful, please star the repo. It helps more people find it. -## Demo -[![Demo](https://github.com/user-attachments/assets/8b9a859b-d4f1-47ca-9d1d-9d26d982e15d)](https://www.youtube.com/watch?v=7xTpciZCfpw) +--- +## Overview -[Watch the full video](https://www.youtube.com/watch?v=7xTpciZCfpw) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Brain

+Rowboat indexes email, meetings, slack and assistant conversations into a living Obsidian-style backlinked knowledge graph. +
+Screenshot 2026-06-24 at 11 22 52 PM +
+

Email

+The built-in email client sorts emails into important and everything else. Rowboat automatically drafts responses for important email using all the work context. +
+Email screenshot +
+

Background agents

+You can set up background agents that run on events like new email or on schedule like every day at 8am. They can connect to tools, search the web, use the browser and write code using Claude Code or Codex. +
+Background agents screenshot + +
+

Built-in Browser

+Rowboat includes a browser that lets you and assistant collaborate on web tasks. Because its isolated from your main browser, you can log in only to the accounts that want the assistant to access. +
+Browser screenshot +
+

Meeting Notes

+A local meeting note-taker that taps into mic & speaker, produces live transcript and summarizes the meeting in a markdown file and updates the knowledge graph. +
+Meeting notes screenshot +
+

Code Mode

+Code mode lets you spin up parallel coding agents with Claude Code or Codex, and have Rowboat drive them with all the work context where needed. +
+Code mode screenshot +
+

Integrations

+Includes one-click integrations to most popular products. +
+Integrations screenshot +
--- @@ -81,23 +147,6 @@ All API key files use the same format: } ``` -## What it does - -Rowboat is a **local-first AI coworker** that can: -- **Remember** the important context you don’t want to re-explain (people, projects, decisions, commitments) -- **Understand** what’s relevant right now (before a meeting, while replying to an email, when writing a doc) -- **Help you act** by drafting, summarizing, planning, and producing real artifacts (briefs, emails, docs, PDF slides) - -Under the hood, Rowboat maintains an **Obsidian-compatible vault** of plain Markdown notes with backlinks — a transparent “working memory” you can inspect and edit. - -## Integrations - -Rowboat builds memory from the work you already do, including: -- **Gmail** (email) -- **Google Calendar** -- **Rowboat meeting notes** or **Fireflies** - -It also contains a library of product integrations through Composio.dev ## How it’s different @@ -111,24 +160,6 @@ Rowboat maintains **long-lived knowledge** instead: The result is memory that compounds, rather than retrieval that starts cold every time. -## What you can do with it - -- **Meeting prep** from prior decisions, threads, and open questions -- **Email drafting** grounded in history and commitments -- **Docs & decks** generated from your ongoing context (including PDF slides) -- **Follow-ups**: capture decisions, action items, and owners so nothing gets dropped -- **On-your-machine help**: create files, summarize into notes, and run workflows using local tools (with explicit, reviewable actions) - -## Live notes - -Live notes are notes that stay updated automatically. You can create one by typing '@rowboat' on a note. - -- Track a competitor or market topic across X, Reddit, and the news -- Monitor a person, project, or deal across web or your communications -- Keep a running summary of any subject you care about - -Everything is written back into your local Markdown vault. You control what runs and when. - ## Bring your own model Rowboat works with the model setup you prefer: diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 2d9816d0..5ddfcf6e 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -24,7 +24,7 @@ Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run | Property | Type | Notes | |---|---|---| -| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` | +| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` / `code_session` | | `sub_use_case` | string? | Refines `use_case` — see taxonomy table below | | `agent_name` | string? | Present when the call goes through an agent run (`createRun`); omitted for direct `generateText`/`generateObject` | | `model` | string | e.g. `claude-sonnet-4-6` | @@ -57,6 +57,7 @@ Every `llm_usage` emit point in the codebase: | `knowledge_sync` | `inline_task_run` | yes | Inline `@rowboat` task execution (two call sites) | `packages/core/src/knowledge/inline_tasks.ts:471, 552` (createRun) | | `knowledge_sync` | `inline_task_classify` | no | Inline task scheduling classifier (`generateText`) | `packages/core/src/knowledge/inline_tasks.ts:673` | | `knowledge_sync` | `pre_built` | yes | Pre-built scheduled agents | `packages/core/src/pre_built/runner.ts:43` (createRun) | +| `code_session` | (none) | yes | Code-section coding session in Rowboat mode (direct mode talks to the on-device coding agent and emits no `llm_usage`) | `packages/core/src/code-mode/sessions/service.ts` (createRun) | ##### `live_note_agent` sub-use-case shape diff --git a/apps/x/CODE_MODE_ENGINES_PLAN.md b/apps/x/CODE_MODE_ENGINES_PLAN.md new file mode 100644 index 00000000..ce7a4958 --- /dev/null +++ b/apps/x/CODE_MODE_ENGINES_PLAN.md @@ -0,0 +1,271 @@ +# Code Mode — Managed Engine Provisioning Plan + +Branch: `feat/code-mode-managed-engines` (off `dev` @ `8ce24ebb`) + +## 1. Problem & Goal + +Code mode runs two coding agents — **Claude Code** and **Codex** — by spawning their +ACP adapters, which in turn spawn a heavy **native engine binary** (~205 MB claude, +~194 MB codex). We need code mode to work in **packaged releases with ~99% reliability +for both agents**, without shipping a ~400 MB installer. + +### Current state (HEAD = revert of #614) +- Packaged builds **do not stage** the ACP adapters, and `forge.config.cjs` + `ignore: /^\/node_modules\//` strips them. At runtime `agents.ts` resolves the + adapter via `require.resolve(...)` then spawns it — which **throws + `Cannot find module '@agentclientprotocol/...'`**. +- **Net: packaged code mode is broken in every release.** It only works in `dev` + because pnpm symlinks exist. There is no 400 MB bloat today — but no function either. + +### Why the two prior approaches are insufficient +- **Bundle engines (A):** +~400 MB per installer (one claude + one codex native binary + per OS/arch). Works offline, but installer is huge. +- **Drive from user's local install (B)** — what #614 settled on and was reverted: + requires the user to have **both** CLIs installed **and** logged in, correct version, + on the right PATH. Depends on the user's machine → **structurally cannot hit 99%** + (version skew, GUI-launch PATH stripping, missing installs). This is why #614 was + reverted. + +## 2. Chosen Architecture — Managed Engine Provisioning (validated against Conductor) + +Split the problem into **engine** vs **auth**, treat them differently — exactly what +Conductor (conductor.build) does: + +1. **Engine = owned by the app.** We provision **version-pinned** engine binaries into + **app-support** (`~/.rowboat/engines///`), download-on-demand on first + use, sha256-verified, symlink/path-pinned. Not the user's global npm, not their PATH. + → no version skew, no PATH quirks → this is what delivers the 99%. +2. **Auth = reused from the user.** The engines read existing credentials + (`~/.claude` API key / Pro / Max, `~/.codex` auth.json). No second login. `status.ts` + already inspects these. + +### Empirical proof from Conductor on this machine +- DMG is **123 MB** — far smaller than the ~400 MB of engines → engines are **not** in + the installer; they're downloaded after install. +- Layout observed at `~/Library/Application Support/com.conductor.app/`: + ``` + agent-binaries/claude/2.1.170/claude (222 MB, single Mach-O arm64) + agent-binaries/codex/0.138.0/codex (205 MB, single Mach-O arm64) + bin/claude -> agent-binaries/claude/2.1.170/claude (symlink to active version) + bin/codex -> agent-binaries/codex/0.138.0/codex + agent-binaries/.meta/claude-2.1.170.json = {sha256, size, downloaded_at_unix_ms, ...} + ``` +- So: versioned dirs + stable symlink + sha256 `.meta` ledger + download. We mirror this. + +## 3. Concrete facts that make this clean (verified) + +### Adapters honor an external engine via env var (no code change in adapters needed) +- **Claude** — `@agentclientprotocol/claude-agent-acp@0.39.0`, + `dist/acp-agent.js:39`: `if (process.env.CLAUDE_CODE_EXECUTABLE) return it;` + and line 1552 `pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? ...`. + If unset and no bundled native dep → it throws "set CLAUDE_CODE_EXECUTABLE". +- **Codex** — `@agentclientprotocol/codex-acp@0.0.44`, + `dist/index.js:20900`: `const codexPath = process.env["CODEX_PATH"] ?? "codex";` + → `spawn(codexPath, ["app-server"])`. +- **Implication:** provisioning + setting these two env vars is the *entire* engine story. + +### Our engine packages are already single self-contained binaries +- `@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156` → contains one `claude` + executable (+ LICENSE/README). +- `@openai/codex@0.128.0-darwin-arm64` → `vendor//codex/codex` native binary + **plus a bundled `rg` (ripgrep) at `vendor//path/rg`** — see Risk R1. + +### Pinned versions + platform package names (read from the installed adapter trees) +- **Claude:** adapter `claude-agent-acp@0.39.0` → engine `@anthropic-ai/claude-agent-sdk@0.3.156`. + Platform optional deps `@anthropic-ai/claude-agent-sdk-@0.3.156`: + `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `linux-x64-musl`, + `linux-arm64-musl`, `win32-x64`, `win32-arm64`. +- **Codex:** adapter `codex-acp@0.0.44` → `@openai/codex@^0.128.0` (pnpm-**patched**, see R2). + Platform deps aliased `@openai/codex-` → `npm:@openai/codex@0.128.0-`: + `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64`, `win32-arm64`. + +## 4. Distribution source — npm platform packages, adapter-pinned (DECIDED) + +We fetch the **per-platform engine packages from the npm registry at the exact versions +our ACP adapters depend on**, extract the native binary, and provision it into +`~/.rowboat/engines/...`. No self-host, no curl installer, no fallback. + +- **Tarball URL:** `https://registry.npmjs.org//-/-.tgz` + - claude: `@anthropic-ai/claude-agent-sdk-@0.3.156` → extract the `claude` binary. + - codex: `@openai/codex@0.128.0-` → extract `vendor//codex/codex` + **and keep `vendor//path/rg`** (see R1). +- **Why adapter-pinned npm (not curl installer, not release bucket, not self-host):** + - The binary is the **exact version our adapter was built/tested against** → ACP + handshake guaranteed → the key to ~99% reliability. + - npm registry is highly available, immutable per-version, and the packument provides + `dist.integrity` (sha512) + `dist.shasum` (sha1) → integrity verification with **zero + infra**. + - The official `curl | bash` installer is for end-users: it installs globally to + `~/.local/bin` and **auto-updates in the background** — exactly what we must avoid. We + want an isolated, pinned, app-managed copy that never moves under the adapter. + (Conductor likewise fetches the raw binary rather than running the installer.) +- **Versions are read from our lockfile at build time** and embedded in + `engine-manifest.json`, so the manifest can never drift from the adapters we ship. + +### Reference: how this relates to Conductor / official installers +- Conductor self-hosts the same kind of native binaries on `storage.conductor.build` + (raw/`.gz`/`.zst` + `{url,gzipUrl,zstdUrl,sha256}` manifest), pairing the adapters with + recent **standalone CLI** versions (claude 2.1.x, codex 0.138). That proves recent + versions work — but we deliberately pin to the **adapter's own** engine version for + determinism. +- Official Claude releases also publish a GPG-signed `manifest.json` (SHA256/platform) at + `downloads.claude.ai/claude-code-releases//`. We don't use it now (npm is simpler + and adapter-matched), but it's the upgrade path if we ever want the latest CLI line. +- If we later want control/availability/compression, we can mirror these npm tarballs to + our own bucket — **runtime stays identical, only manifest URLs change.** + +### Locked decisions +- **Source: npm platform packages, adapter-pinned versions (user).** +- **No self-host (user).** +- **Always provision; no fallback** to a user's pre-installed `claude`/`codex` (user). + Reverted path B is fully retired. +- **Codex pnpm patch — IRRELEVANT (user):** it targets the JS launcher; we point + `CODEX_PATH` at the native binary, so it does not apply. (Risk R2 removed.) + +## 5. Design + +### 5.1 Build-time: generate an engine manifest + stage adapter JS + +**(a) Engine manifest (`engine-manifest.json`, embedded in the app).** +A build script reads the installed adapter dependency trees and emits, per agent: +```jsonc +{ + "claude": { + "version": "0.3.156", + "platforms": { + "darwin-arm64": { "pkg": "@anthropic-ai/claude-agent-sdk-darwin-arm64", + "tarball": "https://registry.npmjs.org/.../-/...-0.3.156.tgz", + "integrity": "sha512-...", + "binRelPath": "claude" }, + "...": {} + } + }, + "codex": { + "version": "0.128.0", + "platforms": { + "darwin-arm64": { "pkg": "@openai/codex-darwin-arm64", + "tarball": "https://registry.npmjs.org/...", + "integrity": "sha512-...", + "binRelPath": "vendor/aarch64-apple-darwin/codex/codex", + "extraPaths": ["vendor/aarch64-apple-darwin/path/rg"] } + } + } +} +``` +- Versions + tarball URLs + integrity are pulled from the lockfile / npm packument so the + manifest is always in sync with the adapters we ship. (Generating it at build time + sidesteps hardcoding fragile package names.) +- `binRelPath` / `extraPaths` capture where the executable (and codex's `rg`) live inside + each tarball. + +**(b) Stage the ACP adapter JS into the package (the part of #614 we KEEP).** +The adapters themselves (tiny, ~15 MB total incl. their non-native JS deps) must exist on +disk in packaged builds so `agents.ts` can resolve + spawn them. In `forge.config.cjs` +`generateAssets`, stage the two adapters and their **non-native** production dependency +closure into `.package/acp/node_modules` (npm-style nested layout), and **exempt +`.package` from the `node_modules` ignore rule**. We **drop** #614's native-engine staging +entirely — engines come from provisioning, not the bundle. + +### 5.2 Runtime: engine provisioner (new module in `packages/core`) + +New file: `packages/core/src/code-mode/acp/engine-provisioner.ts`. + +``` +ensureEngine(agent): Promise<{ executablePath: string }> + 1. Read manifest entry for (agent, currentPlatform). Error clearly if unsupported. + 2. dir = ~/.rowboat/engines/// + 3. If dir exists AND .meta/-.json sha256 matches → return binPath. + 4. Else acquire a cross-process lock (avoid double download), then: + a. Download tarball to a temp file, streaming, with progress events. + b. Verify integrity (sha512/sha256 from manifest). + c. Extract into a temp dir, then atomic rename into / (tar gzip). + d. chmod +x the binary (and codex's rg) on unix. + e. Write .meta/-.json {sha256, size, downloaded_at_unix_ms}. + 5. Return absolute path to the engine executable (binRelPath joined to dir). +``` +- **Progress + cancellation** surfaced over IPC for the first-run "Downloading engine…" UI. +- **Offline / failure** → typed error with a clear, actionable message (not a hang). +- **Resumability / atomicity:** download to temp, verify, then rename; never leave a + half-extracted version dir that passes the existence check. + +### 5.3 Wire provisioning into launch + +In `agents.ts` `getAgentLaunchSpec()` (currently sets only `CLAUDE_CODE_EXECUTABLE`): +- Make it (or its caller in `client.ts` / `manager.ts`) `await ensureEngine(agent)` first, + then set: + - claude → `env.CLAUDE_CODE_EXECUTABLE = ` + - codex → `env.CODEX_PATH = ` (and ensure its `rg` sibling resolves; + keep the vendor dir layout intact so codex finds `../path/rg`). +- Keep `ELECTRON_RUN_AS_NODE=1` for the adapter spawn (unchanged). +- The provisioner replaces both the bundled-engine dependency *and* the reverted + "resolve user's local install" path. (Optionally: if a healthy provisioned engine is + absent but a compatible local install exists, we *may* fall back to it — but the default + and reliable path is the provisioned engine. Decide in review; default = provisioned only.) + +### 5.4 Status & UX (`status.ts` + renderer) +- `checkCodeModeAgentStatus()` becomes: engine = provisioned? (instead of "installed on + PATH?"); auth = existing credentials present? (unchanged logic). +- First-run flow: user picks agent → if engine missing, show "Downloading engine + (~200 MB), one time…" with progress; on completion, proceed. Subsequent uses: instant. +- Clear error states: download failed / offline / unsupported platform / auth missing. + +## 6. File-by-file change plan + +| File | Change | +|---|---| +| `apps/main/forge.config.cjs` | Stage adapter JS closure into `.package/acp/node_modules`; exempt `.package` from node_modules ignore; generate + copy `engine-manifest.json`. (No native engines staged.) | +| `apps/main/scripts/gen-engine-manifest.mjs` *(new)* | Build-time: read lockfile/packuments → emit `engine-manifest.json` (versions, tarball URLs, integrity, bin paths). | +| `packages/core/src/code-mode/acp/engine-provisioner.ts` *(new)* | `ensureEngine()` — download, verify, extract, lock, progress, `.meta` ledger. | +| `packages/core/src/code-mode/acp/agents.ts` | Resolve adapter from staged `.package/acp` first (fallback to node_modules in dev); `await ensureEngine`; set `CLAUDE_CODE_EXECUTABLE`/`CODEX_PATH` to provisioned binaries. | +| `packages/core/src/code-mode/acp/client.ts` / `manager.ts` | Await provisioning before spawn; surface provisioning progress/errors; keep startup deadline. | +| `packages/core/src/code-mode/status.ts` | Engine status = provisioned (not PATH); keep auth checks. | +| `apps/main/src/ipc.ts` + preload + renderer | IPC for provisioning progress + first-run download UI + error states. | +| CI (optional) | Smoke: packaged app boots each adapter, provisions a (small fake) engine, sets env var, answers ACP `initialize`; offline → clear error not a hang. | + +## 7. Edge cases & risks + +- **R1 — Codex needs `rg`.** The codex platform package bundles ripgrep at + `vendor//path/rg`. We must extract/keep the vendor layout so codex finds it + (don't extract the bare binary). Verify codex `app-server` boots from the provisioned dir. +- **R2 — Codex pnpm patch. RESOLVED (irrelevant).** The patch targets the JS launcher; we + point `CODEX_PATH` at the native binary, so it does not apply. +- **R3 — Platform/arch matrix.** Manifest must cover darwin x64/arm64, linux x64/arm64 + (+musl for claude), win32 x64/arm64. Windows engine is `claude.exe`/`codex.exe`. +- **R4 — Integrity & supply chain.** Always verify the manifest integrity hash before + chmod/exec. Treat a hash mismatch as a hard failure. +- **R5 — Disk + upgrades.** Versioned dirs accumulate. Add a cleanup that keeps only the + active pinned version per agent. +- **R6 — First-run network.** Required once per agent; cached forever after. Must be a + clear, cancellable UX, never a silent hang (reuse the #614 startup-deadline lesson). +- **R7 — code signing / Gatekeeper (macOS).** Downloaded native binaries aren't covered by + our app's signature. Verify they run under Gatekeeper (they're already + signed/notarized by Anthropic/OpenAI; quarantine attr may need clearing). Conductor runs + them fine from app-support — confirm we do too. +- **R8 — `engine-manifest` staleness.** Manifest must regenerate whenever the adapter/engine + versions change; tie generation into the build so it can't drift. + +## 8. Phasing + +1. **P1 — Packaging fix (unblocks dev→packaged parity):** stage adapter JS into `.package`, + resolver checks staged path first. Verify packaged code mode can at least *spawn* the + adapter. (Independent of provisioning; the part of #614 worth keeping.) +2. **P2 — Provisioner (core):** `ensureEngine()` + manifest + wire env vars. Verify both + engines provision + boot from `~/.rowboat/engines` on macOS arm64. +3. **P3 — UX + status:** first-run download UI, status panel, error states. +4. **P4 — Cross-platform + CI smoke:** matrix manifest, mac/linux/win smoke. +5. **P5 — Polish:** version cleanup, cancellation, offline messaging, optional local-install + fallback. + +## 9. Decisions & remaining questions + +### Resolved (locked) +1. **Source = npm platform packages, adapter-pinned versions** (not self-host, not curl + installer, not release bucket). +2. **Always provision; no local-install fallback.** +3. **Codex pnpm patch irrelevant.** + +### Still open (can decide during implementation) +- **Provision timing:** on first code-mode *use* (lazy) vs first app launch (eager, + background download). Plan assumes **lazy + cached**. +- **Version-dir cleanup:** keep only the active pinned version per agent (R5). +- **Verify R1** (codex `rg`) and **R7** (Gatekeeper on downloaded macOS binaries) during P2. diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index 976e8db3..548e037c 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -11,6 +11,10 @@ import * as esbuild from 'esbuild'; import { readFile } from 'node:fs/promises'; +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; // 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, @@ -24,7 +28,11 @@ await esbuild.build({ platform: 'node', target: 'node20', outfile: './.package/dist/main.cjs', - external: ['electron'], // Provided by Electron runtime + // electron is provided by the runtime. node-pty is a NATIVE module: it can't + // be inlined (its loader requires .node binaries + a spawn-helper relative to + // its own package dir), so it stays external and is copied into + // .package/node_modules below, where require() from dist/main.cjs finds it. + external: ['electron', 'node-pty'], // Use CommonJS format - many dependencies use require() which doesn't work // well with esbuild's ESM shim. CJS handles dynamic requires natively. format: 'cjs', @@ -42,4 +50,73 @@ await esbuild.build({ }, }); -console.log('✅ Main process bundled to .package/dist-bundle/main.js'); +// Ship node-pty next to the bundle. Resolve through pnpm's symlink to the real +// package dir and copy only what's needed at runtime (compiled JS + prebuilt +// binaries). The macOS spawn-helper must be executable — pnpm extraction drops +// the bit, and a non-executable helper makes every PTY spawn fail. +const here = path.dirname(fileURLToPath(import.meta.url)); +const ptySrc = fs.realpathSync(path.join(here, 'node_modules', 'node-pty')); +const ptyDest = path.join(here, '.package', 'node_modules', 'node-pty'); +fs.rmSync(ptyDest, { recursive: true, force: true }); +fs.mkdirSync(ptyDest, { recursive: true }); +for (const item of ['package.json', 'lib', 'prebuilds']) { + fs.cpSync(path.join(ptySrc, item), path.join(ptyDest, item), { recursive: true, dereference: true }); +} +const prebuildsDir = path.join(ptyDest, 'prebuilds'); +for (const dir of fs.readdirSync(prebuildsDir)) { + const helper = path.join(prebuildsDir, dir, 'spawn-helper'); + if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755); +} + +// Self-heal: node-pty ships prebuilt binaries only for darwin/win32, so on any +// host whose prebuild is absent (notably Linux) the staged package has no loadable +// pty.node and the app crashes on launch. Compile the native module for the host +// platform+arch if needed and stage it under prebuilds/-/, where +// node-pty's loader looks first. Keeps dev and CI working without a manual node-gyp +// step (the CI workflow's explicit build is the fast path; this is the safety net). +const hostTriple = `${process.platform}-${process.arch}`; +const stagedBinary = path.join(prebuildsDir, hostTriple, 'pty.node'); +if (!fs.existsSync(stagedBinary)) { + const builtBinary = path.join(ptySrc, 'build', 'Release', 'pty.node'); + if (!fs.existsSync(builtBinary)) { + console.log(`node-pty: no prebuilt binary for ${hostTriple}; compiling with node-gyp…`); + execSync('npx node-gyp rebuild', { cwd: ptySrc, stdio: 'inherit' }); + } + if (!fs.existsSync(builtBinary)) { + throw new Error(`node-pty: failed to produce a native binary for ${hostTriple}`); + } + fs.mkdirSync(path.dirname(stagedBinary), { recursive: true }); + fs.copyFileSync(builtBinary, stagedBinary); + console.log(`✅ node-pty: staged ${hostTriple}/pty.node`); +} +console.log('✅ node-pty staged in .package/node_modules'); + +// Bundle the vendored agent-slack CLI into a single self-contained script next +// to main.cjs. It runs as a child process (process.execPath with +// ELECTRON_RUN_AS_NODE=1), so it must exist as a real file on disk — it can't +// be inlined into main.cjs. Bundling here means the packaged app needs neither +// node_modules nor a global npm install. +const agentSlackPkg = JSON.parse( + await readFile(new URL('./node_modules/agent-slack/package.json', import.meta.url), 'utf8'), +); +await esbuild.build({ + entryPoints: ['./node_modules/agent-slack/dist/index.js'], + bundle: true, + platform: 'node', + target: 'node22', + outfile: './.package/dist/agent-slack.cjs', + format: 'cjs', + banner: { js: cjsBanner }, + define: { + 'import.meta.url': '__import_meta_url', + // Without this constant the CLI's --version walks up the directory tree + // for a package.json and would find Rowboat's instead of agent-slack's. + 'AGENT_SLACK_BUILD_VERSION': JSON.stringify(agentSlackPkg.version), + }, + // The CLI probes bun:sqlite via dynamic import inside a try/catch and falls + // back to node:sqlite; keep it external so the probe fails at runtime the + // same way it does under plain node. + external: ['bun:sqlite'], +}); + +console.log(`✅ Main process bundled to .package/dist/main.cjs (+ agent-slack ${agentSlackPkg.version} CLI)`); diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index ad639a86..3b967655 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -5,6 +5,189 @@ const path = require('path'); const pkg = require('./package.json'); +// The Arch Linux (pacman) package is meant only for local builds on an Arch host +// with makepkg. It already self-skips elsewhere (maker-pacman checks for makepkg), +// but CI sets ROWBOAT_SKIP_PACMAN=1 to disable it explicitly — GitHub runners are +// Ubuntu and shouldn't attempt to ship an Arch package. +const SKIP_PACMAN = process.env.ROWBOAT_SKIP_PACMAN === '1'; + +// Stage the ACP coding-adapters (@agentclientprotocol/*-acp) and their full +// production dependency closure into the packaged app. +// +// Why this is needed: code mode spawns each adapter as a SEPARATE `node ` +// process and locates it at runtime via require.resolve — so it must ship as a real +// on-disk file. esbuild can't inline it (dynamic resolve + spawn target), and Forge +// strips the workspace node_modules (see `ignore` below). Without this, packaged +// builds throw `Cannot find module '@agentclientprotocol/...'`. +// +// Why we reconstruct the tree instead of copying node_modules: pnpm's store is a +// symlink farm that legitimately holds multiple versions of the same package (e.g. +// @agentclientprotocol/sdk 0.21 for claude vs 0.22 for codex). We rebuild an npm-style +// node_modules — dereferencing symlinks — that resolves correctly regardless of pnpm +// layout. We HOIST every package to the top-level node_modules and only nest a package +// under its requirer on a genuine version conflict. Hoisting (vs. always nesting) keeps +// the tree shallow: without it, transitive chains like codex-acp → open → wsl-utils → +// is-wsl → is-inside-container → is-docker nest 5+ deep and produce ~260-char paths that +// break the Windows Squirrel/nuget maker's MAX_PATH limit. Node resolution stays correct +// because the top-level node_modules is an ancestor of every staged file, so a hoisted +// package resolves for all requirers and a conflicting version shadows it via nesting. +// verifyAcpStaging() below asserts this held for every dependency edge. +// +// What we DON'T bundle: the agents' native engines (claude / codex, ~200 MB each, shipped +// as platform-specific packages). Those are PROVISIONED on demand into +// ~/.rowboat/engines/// and the adapters are pointed at them via +// CLAUDE_CODE_EXECUTABLE / CODEX_PATH (see packages/core/src/code-mode/acp/). Skipping +// them keeps each OS installer ~400 MB smaller while code mode stays fully functional. +// Shared by stageAcpAdapters and verifyAcpStaging so staging and verification use +// identical resolution semantics. +const ACP_ADAPTERS = [ + '@agentclientprotocol/claude-agent-acp', + '@agentclientprotocol/codex-acp', +]; + +// The native engines, shipped as platform packages. Provisioned on demand +// (see header comment), so they're excluded from staging. +const isAcpNativeEngine = (key) => + /^@anthropic-ai\/claude-agent-sdk-(win32|darwin|linux)/.test(key) || // native claude + /^@openai\/codex-(win32|darwin|linux)/.test(key); // native codex + +// Resolve a dependency's real directory by walking node_modules the way Node does, +// looking for the package DIRECTORY. We deliberately do NOT use +// require.resolve(`${key}/package.json`): that throws for packages whose `exports` +// map doesn't expose package.json (e.g. @anthropic-ai/claude-agent-sdk), which would +// silently drop them and their subtrees. realpathSync dereferences pnpm's symlinks. +// Returns null for deps not installed for this OS (platform-optional binaries). +const acpRealDirOf = (key, fromDir) => { + const fs = require('fs'); + let dir = fromDir; + for (;;) { + const cand = path.join(dir, 'node_modules', ...key.split('/')); + if (fs.existsSync(path.join(cand, 'package.json'))) return fs.realpathSync(cand); + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +}; + +function stageAcpAdapters(mainDir, destNodeModules) { + const fs = require('fs'); + + let copied = 0; + const skippedEngines = new Set(); + // srcRealDir -> the staged directory whose content represents it. Lets + // verifyAcpStaging map every source package to where it landed. + const placements = new Map(); + // package key -> version placed at the TOP-LEVEL node_modules. We hoist every + // package to the top level and only nest a package under its requirer when a + // DIFFERENT version is already hoisted there. See the header comment for why + // (a shallow tree stays under Windows' MAX_PATH). + const rootHoisted = new Map(); + const install = (srcDir, key, parentNM, chain) => { + if (chain.has(srcDir)) return; // dependency cycle — resolves to ancestor copy + const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8')); + const version = pj.version; + const hoisted = rootHoisted.get(key); + let destNM; + if (hoisted === undefined) { + destNM = destNodeModules; // first sighting → hoist to the top level + rootHoisted.set(key, version); + } else if (hoisted === version) { + // identical version already hoisted at root → reuse it (its subtree is + // already staged); just record where this srcDir resolves to. + placements.set(srcDir, path.join(destNodeModules, ...key.split('/'))); + return; + } else { + destNM = parentNM; // genuine version conflict → nest under requirer + } + const destDir = path.join(destNM, ...key.split('/')); + placements.set(srcDir, destDir); + if (fs.existsSync(destDir)) return; // already placed at this exact location + fs.mkdirSync(path.dirname(destDir), { recursive: true }); + fs.cpSync(srcDir, destDir, { + recursive: true, + dereference: true, + filter: (s) => path.basename(s) !== 'node_modules', // deps handled by recursion + }); + copied++; + const deps = { ...pj.dependencies, ...pj.optionalDependencies }; + const nextChain = new Set(chain).add(srcDir); + for (const depKey of Object.keys(deps)) { + if (isAcpNativeEngine(depKey)) { skippedEngines.add(depKey); continue; } + const depDir = acpRealDirOf(depKey, srcDir); + if (depDir) install(depDir, depKey, path.join(destDir, 'node_modules'), nextChain); + } + }; + + for (const key of ACP_ADAPTERS) { + const srcDir = acpRealDirOf(key, mainDir); + if (!srcDir) { + throw new Error(`ACP adapter '${key}' is not installed in ${mainDir} — run pnpm install`); + } + install(srcDir, key, destNodeModules, new Set()); + } + if (skippedEngines.size) { + console.log(` (skipped native engines — provisioned on demand: ${[...skippedEngines].join(', ')})`); + } + return { copied, placements }; +} + +// Fail the build LOUDLY if hoisting misplaced anything. Re-walk the source dependency +// closure and assert that every (package → dependency) edge resolves, in the STAGED +// tree, to the SAME version it resolves to in the SOURCE pnpm tree. This converts a +// silent runtime "Cannot find module" (or a wrong-version resolution from a botched +// hoist) into an immediate build failure. Expectations are derived from the source +// tree — nothing is hardcoded — so it keeps working as the dependency set changes. +function verifyAcpStaging(mainDir, placements) { + const fs = require('fs'); + const versionAt = (dir) => + JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version; + // Resolve `key`'s staged version as seen from `fromStagedDir`, via Node's own + // upward node_modules walk. Reads package.json directly (not require.resolve, whose + // `${key}/package.json` subpath some exports maps block). + const stagedVersionOf = (key, fromStagedDir) => { + let dir = fromStagedDir; + for (;;) { + const cand = path.join(dir, 'node_modules', ...key.split('/')); + if (fs.existsSync(path.join(cand, 'package.json'))) return versionAt(cand); + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } + }; + const errors = []; + const visited = new Set(); + const walk = (srcDir) => { + if (visited.has(srcDir)) return; + visited.add(srcDir); + const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8')); + const stagedDir = placements.get(srcDir); + if (!stagedDir) { errors.push(`not staged: ${pj.name}@${pj.version}`); return; } + const deps = { ...pj.dependencies, ...pj.optionalDependencies }; + for (const depKey of Object.keys(deps)) { + if (isAcpNativeEngine(depKey)) continue; + const depSrc = acpRealDirOf(depKey, srcDir); + if (!depSrc) continue; // platform-optional / not installed for this OS + const want = versionAt(depSrc); + const got = stagedVersionOf(depKey, stagedDir); + if (got === null) { + errors.push(`${pj.name} → ${depKey}: unresolved in staged tree (expected ${want})`); + } else if (got !== want) { + errors.push(`${pj.name} → ${depKey}: staged resolves ${got}, source resolves ${want}`); + } + walk(depSrc); + } + }; + for (const key of ACP_ADAPTERS) { + const srcDir = acpRealDirOf(key, mainDir); + if (srcDir) walk(srcDir); + } + if (errors.length) { + throw new Error( + `ACP staging verification failed — the staged tree resolves differently than source:\n - ${errors.join('\n - ')}` + ); + } +} + module.exports = { packagerConfig: { executableName: 'rowboat', @@ -29,17 +212,23 @@ module.exports = { appleIdPassword: process.env.APPLE_PASSWORD, teamId: process.env.APPLE_TEAM_ID }, - // Since we bundle everything with esbuild, we don't need node_modules at all. - // These settings prevent Forge's dependency walker (flora-colossus) from trying - // to analyze/copy node_modules, which fails with pnpm's symlinked workspaces. + // Since we bundle the main process with esbuild, we don't need the workspace + // node_modules. These settings prevent Forge's dependency walker (flora-colossus) + // from trying to analyze/copy node_modules, which fails with pnpm's symlinked + // workspaces. prune: false, - ignore: [ - /src\//, - /node_modules\//, - /.gitignore/, - /bundle\.mjs/, - /tsconfig.json/, - ], + // Strip the workspace src/node_modules (paths are ANCHORED to the app root), BUT + // always keep everything under `.package/` — that's our staged output: the + // bundled main process, the ACP adapters + their dependency closure (staged by + // the generateAssets hook), and the native node-pty module (staged into + // .package/node_modules by bundle.mjs). Without the `.package` exemption the + // node_modules rule would strip those and code mode / the embedded terminal + // would break in packaged builds. + ignore: (p) => { + if (p === '/.package' || p.startsWith('/.package/')) return false; + return [/^\/src\//, /^\/node_modules\//, /\.gitignore/, /bundle\.mjs/, /tsconfig\.json/] + .some((re) => re.test(p)); + }, }, makers: [ { @@ -56,6 +245,7 @@ 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'), }) }, { @@ -66,7 +256,9 @@ module.exports = { bin: "rowboat", description: 'AI coworker with memory', maintainer: 'rowboatlabs', - homepage: 'https://rowboatlabs.com' + homepage: 'https://rowboatlabs.com', + icon: path.join(__dirname, 'icons/icon.png'), + mimeType: ['x-scheme-handler/rowboat'], } }) }, @@ -77,10 +269,28 @@ module.exports = { name: `Rowboat-linux`, bin: "rowboat", description: 'AI coworker with memory', - homepage: 'https://rowboatlabs.com' + homepage: 'https://rowboatlabs.com', + icon: path.join(__dirname, 'icons/icon.png'), + mimeType: ['x-scheme-handler/rowboat'], } } }, + // Arch Linux package — local-only; disabled in CI via ROWBOAT_SKIP_PACMAN. + ...(SKIP_PACMAN ? [] : [{ + name: require.resolve('./makers/maker-pacman.cjs'), + platforms: ['linux'], + config: { + name: 'rowboat', + bin: 'rowboat', + executableName: 'rowboat', + description: 'AI coworker with memory', + maintainer: 'rowboatlabs', + homepage: 'https://rowboatlabs.com', + license: 'Apache', + icon: path.join(__dirname, 'icons/icon.png'), + mimeType: ['x-scheme-handler/rowboat'], + } + }]), { name: '@electron-forge/maker-zip', platform: ["darwin", "win32", "linux"], @@ -173,7 +383,18 @@ module.exports = { fs.mkdirSync(rendererDest, { recursive: true }); fs.cpSync(rendererSrc, rendererDest, { recursive: true }); + // Stage the ACP coding-adapters (+ their JS dependency closure, minus native + // engines) into .package/acp/node_modules. They are spawned as separate node + // processes at runtime and Forge strips the workspace node_modules, so they + // must be copied in explicitly. See stageAcpAdapters() above for the why. + console.log('Staging ACP adapters...'); + const acpDest = path.join(packageDir, 'acp', 'node_modules'); + const { copied: staged, placements } = stageAcpAdapters(__dirname, acpDest); + // Assert the hoisted tree resolves identically to source before shipping it. + verifyAcpStaging(__dirname, placements); + console.log(`✅ Staged ${staged} ACP adapter packages into .package/acp/node_modules (resolution verified)`); + console.log('✅ All assets staged in .package/'); }, } -}; \ No newline at end of file +}; diff --git a/apps/x/apps/main/icons/icon.ico b/apps/x/apps/main/icons/icon.ico new file mode 100644 index 00000000..0e5ac870 Binary files /dev/null and b/apps/x/apps/main/icons/icon.ico differ diff --git a/apps/x/apps/main/makers/maker-pacman.cjs b/apps/x/apps/main/makers/maker-pacman.cjs new file mode 100644 index 00000000..4cae1da9 --- /dev/null +++ b/apps/x/apps/main/makers/maker-pacman.cjs @@ -0,0 +1,134 @@ +// Custom Electron Forge maker that produces Arch Linux .pkg.tar.zst packages +// via makepkg. Runs only on Linux with makepkg available (i.e. an Arch host). +// +// CJS on purpose: forge.config.cjs require()s us. + +const path = require('path'); +const fs = require('fs'); +const { execSync } = require('child_process'); + +const MakerBase = require('@electron-forge/maker-base').default; + +const ARCH_MAP = { x64: 'x86_64', arm64: 'aarch64', ia32: 'i686', armv7l: 'armv7h' }; + +class MakerPacman extends MakerBase { + name = 'pacman'; + defaultPlatforms = ['linux']; + + isSupportedOnCurrentPlatform() { + if (process.platform !== 'linux') return false; + try { + execSync('command -v makepkg', { stdio: 'ignore' }); + return true; + } catch { + return false; + } + } + + async make({ dir, makeDir, targetArch, packageJSON, appName }) { + const pkgArch = ARCH_MAP[targetArch] || targetArch; + + const cfg = this.config || {}; + const pkgName = (cfg.name || appName || packageJSON.name).toLowerCase(); + // pacman pkgver disallows '-'; map prerelease tags through. + const pkgVersion = String(packageJSON.version || '0.0.0').replace(/-/g, '_'); + const pkgDesc = (cfg.description || packageJSON.description || '').replace(/"/g, '\\"'); + const maintainer = cfg.maintainer || 'unknown'; + const homepage = cfg.homepage || packageJSON.homepage || ''; + const license = cfg.license || 'custom'; + const bin = cfg.bin || pkgName; + const execName = cfg.executableName || appName || pkgName; + const mimeTypes = cfg.mimeType || []; + const depends = cfg.depends || []; + const iconSrc = cfg.icon; + + const outDir = path.resolve(path.join(makeDir, 'pacman', targetArch)); + await this.ensureDirectory(outDir); + + // Clean prior contents so makepkg starts fresh each run. + for (const f of fs.readdirSync(outDir)) { + fs.rmSync(path.join(outDir, f), { recursive: true, force: true }); + } + + // Wrapper script — execs the packaged Electron binary, forwards args (incl. rowboat:// URLs). + fs.writeFileSync( + path.join(outDir, bin), + `#!/bin/sh\nexec "/opt/${pkgName}/${execName}" "$@"\n`, + { mode: 0o755 }, + ); + + const desktop = [ + '[Desktop Entry]', + `Name=${appName || pkgName}`, + `Comment=${pkgDesc}`, + `Exec=${bin} %U`, + `Icon=${pkgName}`, + 'Type=Application', + 'Categories=Utility;', + 'Terminal=false', + mimeTypes.length ? `MimeType=${mimeTypes.join(';')};` : null, + '', + ].filter(Boolean).join('\n'); + fs.writeFileSync(path.join(outDir, `${pkgName}.desktop`), desktop); + + const sources = [bin, `${pkgName}.desktop`]; + let iconInstall = ''; + if (iconSrc && fs.existsSync(iconSrc)) { + fs.copyFileSync(iconSrc, path.join(outDir, 'icon.png')); + sources.push('icon.png'); + iconInstall = ` install -Dm644 "$srcdir/icon.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/${pkgName}.png"`; + } + + const sumsLine = sources.map(() => "'SKIP'").join(' '); + const sourceLine = sources.map((s) => `'${s}'`).join(' '); + const dependsLine = depends.map((d) => `'${d}'`).join(' '); + // Embed the packager output dir as a bash-safe literal. + const appDirEscaped = dir.replace(/'/g, `'\\''`); + + const pkgbuild = `# Maintainer: ${maintainer} +# Auto-generated by maker-pacman.cjs — do not edit by hand. +pkgname=${pkgName} +pkgver=${pkgVersion} +pkgrel=1 +pkgdesc="${pkgDesc}" +arch=('${pkgArch}') +url="${homepage}" +license=('${license}') +depends=(${dependsLine}) +options=('!strip' '!debug') +source=(${sourceLine}) +sha256sums=(${sumsLine}) + +_appdir='${appDirEscaped}' + +package() { + install -dm755 "$pkgdir/opt/$pkgname" + cp -a "$_appdir/." "$pkgdir/opt/$pkgname/" + + # Electron's sandbox helper needs setuid root for the namespace sandbox. + if [ -f "$pkgdir/opt/$pkgname/chrome-sandbox" ]; then + chmod 4755 "$pkgdir/opt/$pkgname/chrome-sandbox" + fi + + install -Dm755 "$srcdir/${bin}" "$pkgdir/usr/bin/${bin}" + install -Dm644 "$srcdir/${pkgName}.desktop" "$pkgdir/usr/share/applications/${pkgName}.desktop" +${iconInstall} +} +`; + fs.writeFileSync(path.join(outDir, 'PKGBUILD'), pkgbuild); + + execSync('makepkg -f --noconfirm --nodeps', { + cwd: outDir, + stdio: 'inherit', + env: { ...process.env, PKGEXT: '.pkg.tar.zst', CARCH: pkgArch }, + }); + + return fs + .readdirSync(outDir) + .filter((f) => f.endsWith('.pkg.tar.zst')) + .map((f) => path.join(outDir, f)); + } +} + +module.exports = MakerPacman; +module.exports.default = MakerPacman; diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 74cb1598..a7eca38e 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -13,12 +13,16 @@ "make": "electron-forge make" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "^0.39.0", + "@agentclientprotocol/codex-acp": "^0.0.44", "@x/core": "workspace:*", "@x/shared": "workspace:*", + "agent-slack": "0.9.3", "chokidar": "^4.0.3", "electron-squirrel-startup": "^1.0.1", "html-to-docx": "^1.8.0", "mammoth": "^1.11.0", + "node-pty": "^1.1.0", "papaparse": "^5.5.3", "pdf-parse": "^2.4.5", "update-electron-app": "^3.1.2", @@ -27,6 +31,7 @@ }, "devDependencies": { "@electron-forge/cli": "^7.10.2", + "@electron-forge/maker-base": "^7.11.1", "@electron-forge/maker-deb": "^7.11.1", "@electron-forge/maker-dmg": "^7.10.2", "@electron-forge/maker-rpm": "^7.11.1", diff --git a/apps/x/apps/main/src/deeplink.ts b/apps/x/apps/main/src/deeplink.ts index aaaaa3bc..68877975 100644 --- a/apps/x/apps/main/src/deeplink.ts +++ b/apps/x/apps/main/src/deeplink.ts @@ -39,6 +39,8 @@ export function extractDeepLinkFromArgv(argv: readonly string[]): string | null export function dispatchUrl(url: string): void { if (parseAction(url)) { void dispatchAction(url); + } else if (parsePickerCompletion(url)) { + void dispatchPickerCompletion(url); } else if (parseOAuthCompletion(url)) { void dispatchOAuthCompletion(url); } else { @@ -158,6 +160,41 @@ async function dispatchOAuthCompletion(url: string): Promise { await completeRowboatGoogleConnect(parsed.state); } +// --- Managed OAuth-redirect Picker completion --- + +interface PickerCompletion { + state: string; +} + +/** + * Match rowboat://oauth/google/picker/done?session=. Distinct from the + * connect completion above (oauth/google/done) by the extra `picker` segment. + */ +function parsePickerCompletion(url: string): PickerCompletion | null { + if (!url.startsWith(URL_PREFIX)) return null; + const rest = url.slice(URL_PREFIX.length); + const queryIdx = rest.indexOf("?"); + const path = queryIdx >= 0 ? rest.slice(0, queryIdx) : rest; + const parts = path.split("/").filter(Boolean); + if (parts.length !== 4) return null; + if (parts[0] !== "oauth" || parts[1] !== "google" || parts[2] !== "picker" || parts[3] !== "done") return null; + const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : ""); + const state = params.get("session"); + return state ? { state } : null; +} + +async function dispatchPickerCompletion(url: string): Promise { + const parsed = parsePickerCompletion(url); + if (!parsed) return; + + const win = mainWindowRef; + if (win && !win.isDestroyed()) focusWindow(win); + + // Lazy-import to keep deeplink.ts free of the picker's OAuth/knowledge deps. + const { completeManagedGooglePick } = await import("./google-picker-managed.js"); + await completeManagedGooglePick(parsed.state); +} + function focusWindow(win: BrowserWindow): void { if (win.isMinimized()) win.restore(); win.show(); diff --git a/apps/x/apps/main/src/google-picker-managed.ts b/apps/x/apps/main/src/google-picker-managed.ts new file mode 100644 index 00000000..a9790ad9 --- /dev/null +++ b/apps/x/apps/main/src/google-picker-managed.ts @@ -0,0 +1,120 @@ +import { shell, BrowserWindow } from 'electron'; +import { getWebappUrl } from '@x/core/dist/config/remote-config.js'; +import { claimPickedFilesViaBackend } from '@x/core/dist/auth/google-backend-oauth.js'; +import { importGoogleDocWithToken } from '@x/core/dist/knowledge/google_docs.js'; +import type { GoogleDocListItem } from '@x/core/dist/knowledge/google_docs.js'; + +// Managed (rowboat-mode) OAuth-redirect Picker. Unlike BYOK, the OAuth runs on +// the Rowboat backend with the COMPANY Google client — the desktop never holds +// a client_id/secret or an API key. The desktop just opens the start URL, waits +// for the deep link, claims the picked file ids, and downloads them with the +// user's EXISTING managed Google token (which already holds drive.file from the +// main connect). No Picker API key, appId, ngrok, or local OAuth. +// +// Backend contract (Rowboat webapp/api — NOT this repo). Mirrors the existing +// managed Google-connect (start URL → park under session → deep-link back): +// +// GET ${webappUrl}/oauth/google/picker/start +// Runs Google OAuth with the company client, scope=drive.file ONLY, +// trigger_onepick=true, prompt=consent. Tied to the logged-in web +// session (cookies), exactly like /oauth/google/start. +// +// GET ${webappUrl}/oauth/google/picker/callback +// Google returns `picked_file_ids` (+ code). Park the ids under a +// one-shot `session` ticket, then deep-link the desktop: +// rowboat://oauth/google/picker/done?session= +// (No need to exchange the code: the file is granted to the company +// client, so the desktop's existing managed token can read it.) +// +// POST ${API_URL}/v1/google-oauth/claim-picked body { session } +// Authenticated with the user's Rowboat bearer. Returns +// { fileIds: string[], tokens: { access_token, ... } } — a fresh +// drive.file token minted during the picker's own authorization. + +export interface ManagedPickResult { + path: string; + doc: GoogleDocListItem; +} + +interface PendingPick { + targetFolder: string; + resolve: (result: ManagedPickResult | null) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; +} + +// Single in-flight pick (matches the one-at-a-time OAuth flow model). The deep +// link can't carry our targetFolder, so we stash it here for completion. +let pending: PendingPick | null = null; +const TIMEOUT_MS = 10 * 60 * 1000; + +function clearPending(): void { + if (pending) { + clearTimeout(pending.timer); + pending = null; + } +} + +function focusApp(): void { + const win = BrowserWindow.getAllWindows()[0]; + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); + } +} + +/** + * Open the managed picker in the browser and resolve once the deep link comes + * back with the user's selection (or null on cancel/timeout). The actual import + * happens in completeManagedGooglePick, fired by the deep-link dispatcher. + */ +export async function startManagedGooglePick(targetFolder: string): Promise { + // Supersede any abandoned flow so a stale deep link can't resolve this one. + if (pending) { + const stale = pending; + clearPending(); + stale.resolve(null); + } + + const webappUrl = await getWebappUrl(); + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (pending) { + clearPending(); + resolve(null); + } + }, TIMEOUT_MS); + pending = { targetFolder, resolve, reject, timer }; + void shell.openExternal(`${webappUrl}/oauth/google/picker/start`); + }); +} + +/** + * Deep-link handler for rowboat://oauth/google/picker/done?session=. + * Claims the picked file ids from the backend and imports the first one with + * the existing managed token, resolving the promise startManagedGooglePick + * returned. + */ +export async function completeManagedGooglePick(session: string): Promise { + const current = pending; + if (!current) { + console.warn('[Picker] managed pick completion with no pending flow (timed out or already handled)'); + return; + } + clearPending(); + focusApp(); + + try { + const { fileIds, accessToken } = await claimPickedFilesViaBackend(session); + if (fileIds.length === 0 || !accessToken) { + current.resolve(null); + return; + } + // Download with the picker's own fresh drive.file token (the main + // connection doesn't carry drive.file). + const result = await importGoogleDocWithToken(fileIds[0], current.targetFolder, accessToken); + current.resolve(result); + } catch (error) { + current.reject(error instanceof Error ? error : new Error(String(error))); + } +} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index ec2803aa..a1f6c4a6 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -16,27 +16,45 @@ import { bus } from '@x/core/dist/runs/bus.js'; import { serviceBus } from '@x/core/dist/services/service_bus.js'; import type { FSWatcher } from 'chokidar'; import fs from 'node:fs/promises'; -import { exec } from 'node:child_process'; +import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import z from 'zod'; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); + import { RunEvent } from '@x/shared/dist/runs.js'; import { ServiceEvent } from '@x/shared/dist/service-events.js'; import container from '@x/core/dist/di/container.js'; import { listOnboardingModels } from '@x/core/dist/models/models-dev.js'; -import { testModelConnection } from '@x/core/dist/models/models.js'; +import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js'; +import { getDefaultModelAndProvider } from '@x/core/dist/models/defaults.js'; import { isSignedIn } from '@x/core/dist/account/account.js'; import { listGatewayModels } from '@x/core/dist/models/gateway.js'; import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { 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 { ensureEngine } from '@x/core/dist/code-mode/acp/engine-provisioner.js'; +import type { ICodeProjectsRepo } from '@x/core/dist/code-mode/projects/repo.js'; +import type { ICodeSessionsRepo } from '@x/core/dist/code-mode/sessions/repo.js'; +import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js'; +import { CodeSessionStatusTracker } from '@x/core/dist/code-mode/sessions/status-tracker.js'; +import type { CodeModeManager } from '@x/core/dist/code-mode/acp/manager.js'; +import * as codeGit from '@x/core/dist/code-mode/git/service.js'; +import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js'; +import { ensureTerminal, writeTerminal, resizeTerminal, disposeTerminal } from './terminal.js'; +import type { CodeSession } from '@x/shared/dist/code-sessions.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 { runAgentSlack, getAgentSlackCliStatus, AgentSlackRunError } from '@x/core/dist/slack/agent-slack-exec.js'; +import { knowledgeSourcesRepo } from '@x/core/dist/knowledge/sources/repo.js'; +import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack_home.js'; +import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, getSlackKnowledgeSyncStatus } from '@x/core/dist/knowledge/sources/sync_slack.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; +import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js'; import * as composioHandler from './composio-handler.js'; import { consumePendingDeepLink } from './deeplink.js'; import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js'; @@ -51,7 +69,11 @@ 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 { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; +import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js'; +import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js'; +import { getGoogleDocsConnectionStatus, importGoogleDoc, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js'; +import { startManagedGooglePick } from './google-picker-managed.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'; @@ -72,6 +94,190 @@ import { listTasks, readRunIds as readTaskRunIds, } from '@x/core/dist/background-tasks/fileops.js'; + +type SlackHomeChannel = { + id: string; + name: string; + workspaceUrl?: string; + workspaceName?: string; +}; + +type SlackHomeMessage = { + id: string; + workspaceName?: string; + workspaceUrl?: string; + channelId?: string; + channelName?: string; + author?: string; + text: string; + ts: string; + url?: string; +}; + +function parseWhoamiWorkspaces(data: unknown): Array<{ url: string; name: string }> { + const parsed = (data ?? {}) as { workspaces?: Array<{ workspace_url?: string; workspace_name?: string }> }; + return (parsed.workspaces || []).map((w) => ({ + url: w.workspace_url || '', + name: w.workspace_name || '', + })); +} + +type SlackAuthResult = { + ok: boolean; + workspaces: Array<{ url: string; name: string }>; + error?: string; + errorKind?: 'not_installed' | 'timeout' | 'parse_error' | 'not_authed' | 'rate_limited' | 'network' | 'bad_channel' | 'unknown'; +}; + +// Run `auth import-desktop`, then read back the workspaces via `auth whoami`. +// Shared by the plain and the quit-Slack-first import handlers. +async function importDesktopAndReadWorkspaces(): Promise { + const imported = await runAgentSlack(['auth', 'import-desktop'], { timeoutMs: 20000, parseJson: false }); + if (!imported.ok) { + return { ok: false, workspaces: [], error: imported.message, errorKind: imported.kind }; + } + const whoami = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 }); + if (!whoami.ok) { + return { ok: false, workspaces: [], error: whoami.message, errorKind: whoami.kind }; + } + const workspaces = parseWhoamiWorkspaces(whoami.data); + if (workspaces.length === 0) { + return { ok: false, workspaces: [], error: 'No signed-in Slack workspaces found in the desktop app.', errorKind: 'not_authed' }; + } + return { ok: true, workspaces }; +} + +// Windows force-quits Slack so its exclusive Cookies-DB lock releases before +// desktop import (the EBUSY cause). No-op on mac/Linux, where import works with +// Slack open. taskkill exits non-zero when nothing matches — that's fine. +async function quitSlackIfWindows(): Promise { + if (process.platform !== 'win32') return; + try { + await execFileAsync('taskkill', ['/F', '/IM', 'Slack.exe'], { timeout: 10000, windowsHide: true }); + } catch { + // No running Slack process to kill — nothing to do. + } + // Give Windows a moment to release the file handles before we copy them. + await new Promise(resolve => setTimeout(resolve, 800)); +} + +function extractArrayPayload(parsed: unknown): unknown[] { + if (Array.isArray(parsed)) return parsed; + if (parsed && typeof parsed === 'object') { + const obj = parsed as Record; + for (const key of ['messages', 'channels', 'items', 'results', 'data']) { + if (Array.isArray(obj[key])) return obj[key] as unknown[]; + } + } + return []; +} + +function slackMessageText(message: Record): string { + const value = message.text ?? message.body ?? message.content; + return typeof value === 'string' ? value.trim() : ''; +} + +function slackMessageAuthor(message: Record): string | undefined { + const value = message.username ?? message.user ?? message.author; + return typeof value === 'string' ? value : undefined; +} + +function extractSlackUserName(raw: unknown): string | null { + if (!raw || typeof raw !== 'object') return null; + const obj = raw as Record; + const profile = obj.profile && typeof obj.profile === 'object' ? obj.profile as Record : undefined; + const user = obj.user && typeof obj.user === 'object' ? obj.user as Record : undefined; + const userProfile = user?.profile && typeof user.profile === 'object' ? user.profile as Record : undefined; + + const candidates = [ + profile?.display_name, + profile?.real_name, + userProfile?.display_name, + userProfile?.real_name, + obj.display_name, + obj.displayName, + obj.real_name, + obj.realName, + user?.display_name, + user?.displayName, + user?.real_name, + user?.realName, + obj.name, + user?.name, + ]; + + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim()) { + return candidate.trim(); + } + } + + return null; +} + +async function resolveSlackUserName( + userId: string, + workspaceUrl: string | undefined, + cache: Map, +): Promise { + const key = `${workspaceUrl ?? ''}:${userId}`; + if (cache.has(key)) return cache.get(key) ?? null; + + const args = ['user', 'get', userId]; + if (workspaceUrl) { + args.push('--workspace', workspaceUrl); + } + + const result = await runAgentSlack(args, { timeoutMs: 10000, maxBuffer: 512 * 1024 }); + if (result.ok) { + const name = extractSlackUserName(result.data ?? {}); + if (name) { + cache.set(key, name); + return name; + } + } else { + console.warn(`[Slack] Failed to resolve user ${userId}: ${result.message}`); + } + + cache.set(key, userId); + return null; +} + +async function resolveSlackMessageText( + text: string, + workspaceUrl: string | undefined, + cache: Map, +): Promise { + const matches = Array.from(text.matchAll(/<@([UW][A-Z0-9]+)(?:\|([^>]+))?>|@([UW][A-Z0-9]{6,})\b/g)); + if (matches.length === 0) return text; + + let resolved = text; + for (const match of matches) { + const userId = match[1] ?? match[3]; + if (!userId) continue; + const fallback = match[2] ?? match[0]; + const name = await resolveSlackUserName(userId, workspaceUrl, cache); + resolved = resolved.replaceAll(match[0], name ?? fallback); + } + return resolved; +} + +async function resolveSlackAuthor( + author: string | undefined, + workspaceUrl: string | undefined, + cache: Map, +): Promise { + if (!author) return undefined; + if (!/^[UW][A-Z0-9]{6,}$/.test(author)) return author; + return await resolveSlackUserName(author, workspaceUrl, cache) ?? author; +} + +function slackMessageUrl(message: Record, workspaceUrl: string | undefined, channelId: string | undefined, ts: string): string | undefined { + const direct = message.permalink ?? message.url; + if (typeof direct === 'string' && direct) return direct; + if (!workspaceUrl || !channelId) return undefined; + return `${workspaceUrl.replace(/\/$/, '')}/archives/${channelId}/p${ts.replace('.', '')}`; +} import { browserIpcHandlers } from './browser/ipc.js'; /** @@ -371,6 +577,32 @@ export function emitOAuthEvent(event: { provider: string; success: boolean; erro } } +async function requireCodeSession(sessionId: string): Promise { + const repo = container.resolve('codeSessionsRepo'); + const session = await repo.get(sessionId); + if (!session) { + throw new Error(`Unknown code session: ${sessionId}`); + } + return session; +} + +let codeSessionStatusWatcher: (() => void) | null = null; +export async function startCodeSessionStatusWatcher(): Promise { + if (codeSessionStatusWatcher) { + return; + } + const tracker = container.resolve('codeSessionStatusTracker'); + await tracker.start(); + codeSessionStatusWatcher = tracker.onTransition((sessionId, status) => { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('codeSession:status', { sessionId, status }); + } + } + }); +} + let runsWatcher: (() => void) | null = null; export async function startRunsWatcher(): Promise { if (runsWatcher) { @@ -443,6 +675,13 @@ export function setupIpcHandlers() { // Forward knowledge commit events to renderer for panel refresh versionHistory.onCommit(() => emitKnowledgeCommitEvent()); + // Pre-warm the Gmail contact indices so the first compose-box keystroke is instant. + // - warmContactIndex(): synchronous local-snapshot fallback (instant, narrow coverage). + // - warmSentContacts(): kicks off a background Gmail API sync of the SENT label + // for full historical coverage of people you've actually emailed. + warmContactIndex(); + warmSentContacts(); + registerIpcHandlers({ 'app:getVersions': async () => { // args is null for this channel (no request payload) @@ -507,6 +746,9 @@ export function setupIpcHandlers() { 'gmail:getAccountEmail': async () => { return { email: await getAccountEmail() }; }, + 'gmail:getAccountName': async () => { + return { name: await getAccountName() }; + }, 'gmail:archiveThread': async (_event, args) => { return archiveThread(args.threadId); }, @@ -520,6 +762,22 @@ export function setupIpcHandlers() { saveMessageBodyHeight(args.threadId, args.messageId, args.height); return {}; }, + 'gmail:searchContacts': async (_event, args) => { + const query = args?.query ?? ''; + const limit = args?.limit; + const excludeEmails = args?.excludeEmails; + + // Primary source: people you've actually sent mail to (Gmail SENT label, + // cached + refreshed via the Gmail API). Fallback: local-snapshot index + // — used only when the SENT index hasn't been populated yet (very first + // launch, before the background sync finishes). + const sent = await searchSentContacts(query, { limit, excludeEmails }).catch(() => []); + if (sent.length > 0) { + return { contacts: sent }; + } + const fallback = await searchGmailContacts(query, { limit, excludeEmails }); + return { contacts: fallback }; + }, 'mcp:listTools': async (_event, args) => { return mcpCore.listTools(args.serverName, args.cursor); }, @@ -530,12 +788,17 @@ 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, args.codeMode, args.codeCwd, args.codePolicy) }; }, '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 }; @@ -550,6 +813,9 @@ export function setupIpcHandlers() { 'runs:list': async (_event, args) => { return runsCore.listRuns(args.cursor); }, + 'runs:listByWorkDir': async (_event, args) => { + return runsCore.listRunsByWorkDir(args.dir); + }, 'runs:delete': async (_event, args) => { await runsCore.deleteRun(args.runId); return { success: true }; @@ -592,6 +858,24 @@ export function setupIpcHandlers() { 'models:test': async (_event, args) => { return await testModelConnection(args.provider, args.model); }, + 'models:listForProvider': async (_event, args) => { + try { + const models = await listModelsForProvider(args.provider); + return { success: true, models }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to list models'; + return { success: false, error: message }; + } + }, + 'llm:getDefaultModel': async () => { + return await getDefaultModelAndProvider(); + }, + 'llm:generate': async (_event, args) => { + console.log(`[llm:generate] requested provider=${args.provider ?? '(default)'} model=${args.model ?? '(default)'}`); + const result = await generateOneShot(args); + console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`); + return result; + }, 'models:saveConfig': async (_event, args) => { const repo = container.resolve('modelConfigRepo'); await repo.setConfig(args); @@ -637,17 +921,149 @@ export function setupIpcHandlers() { 'codeMode:getConfig': async () => { const repo = container.resolve('codeModeConfigRepo'); const config = await repo.getConfig(); - return { enabled: config.enabled }; + return { enabled: config.enabled, approvalPolicy: config.approvalPolicy }; }, 'codeMode:setConfig': async (_event, args) => { const repo = container.resolve('codeModeConfigRepo'); - await repo.setConfig({ enabled: args.enabled }); + await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy }); invalidateCopilotInstructionsCache(); return { success: true }; }, 'codeMode:checkAgentStatus': async () => { return await checkCodeModeAgentStatus(); }, + 'codeMode:provisionEngine': async (_event, args) => { + // Download + install the agent's engine, streaming progress back to the + // requesting window so Settings can show a live bar. 'check' is instant — skip it. + try { + await ensureEngine(args.agent, { + onProgress: (p) => { + if (p.phase === 'check') return; + _event.sender.send('codeMode:engineProgress', { + agent: args.agent, + phase: p.phase, + receivedBytes: p.receivedBytes, + totalBytes: p.totalBytes, + }); + }, + }); + return { success: true }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + 'codeProject:add': async (_event, args) => { + const repo = container.resolve('codeProjectsRepo'); + const project = await repo.add(args.path); + const git = await codeGit.repoInfo(project.path); + return { project, git }; + }, + 'codeProject:remove': async (_event, args) => { + const repo = container.resolve('codeProjectsRepo'); + await repo.remove(args.projectId); + return { success: true }; + }, + 'codeProject:list': async () => { + const repo = container.resolve('codeProjectsRepo'); + const projects = await repo.list(); + return { + projects: await Promise.all(projects.map(async (project) => ({ + project, + git: await codeGit.repoInfo(project.path), + }))), + }; + }, + 'codeSession:create': async (_event, args) => { + const service = container.resolve('codeSessionService'); + const session = await service.create(args); + return { session }; + }, + 'codeSession:list': async () => { + const repo = container.resolve('codeSessionsRepo'); + const tracker = container.resolve('codeSessionStatusTracker'); + return { sessions: await repo.list(), statuses: tracker.getStatuses() }; + }, + 'codeSession:update': async (_event, args) => { + const service = container.resolve('codeSessionService'); + return { session: await service.update(args.sessionId, args.patch) }; + }, + 'codeMode:listModelOptions': async (_event, args) => { + const manager = container.resolve('codeModeManager'); + return manager.listModelOptions(args.agent); + }, + 'codeSession:delete': async (_event, args) => { + const service = container.resolve('codeSessionService'); + disposeTerminal(args.sessionId); + await service.delete(args.sessionId, { + removeWorktree: args.removeWorktree, + deleteBranch: args.deleteBranch, + }); + return { success: true }; + }, + 'codeSession:sendMessage': async (_event, args) => { + const service = container.resolve('codeSessionService'); + // Intentionally not awaited: the turn can run for minutes and streams over + // runs:events. sendMessage validates synchronously enough that busy/unknown + // errors are reported via the run's error events instead. + const resultPromise = service.sendMessage(args.sessionId, args.text); + // Surface immediate rejections (busy session, unknown id) to the caller. + const result = await Promise.race([ + resultPromise, + new Promise<{ accepted: true }>((resolve) => setTimeout(() => resolve({ accepted: true }), 300)), + ]); + resultPromise.catch((err) => console.error('codeSession:sendMessage failed', err)); + return result; + }, + 'codeSession:stop': async (_event, args) => { + const service = container.resolve('codeSessionService'); + await service.stop(args.sessionId); + return { success: true }; + }, + 'codeSession:gitStatus': async (_event, args) => { + const session = await requireCodeSession(args.sessionId); + const info = await codeGit.repoInfo(session.cwd); + if (!info.isGitRepo) { + return { isRepo: false, branch: null, hasCommits: false, files: [] }; + } + let files = await codeGit.status(session.cwd); + if (session.worktree && !session.worktree.removedAt && session.worktree.baseBranch) { + const branchFiles = await codeGit.changedSinceBase(session.cwd, session.worktree.baseBranch); + const byPath = new Map(branchFiles.map((file) => [file.path, file])); + for (const file of files) { + if (!byPath.has(file.path)) byPath.set(file.path, file); + } + files = [...byPath.values()]; + } + return { isRepo: true, branch: info.branch, hasCommits: info.hasCommits, files }; + }, + 'codeSession:fileDiff': async (_event, args) => { + const session = await requireCodeSession(args.sessionId); + return codeGit.fileDiff(session.cwd, args.path, { + baseRef: session.worktree && !session.worktree.removedAt ? session.worktree.baseBranch : null, + }); + }, + 'codeSession:readdir': async (_event, args) => { + const session = await requireCodeSession(args.sessionId); + return { entries: await readProjectDir(session.cwd, args.relPath) }; + }, + 'codeSession:readFile': async (_event, args) => { + const session = await requireCodeSession(args.sessionId); + return readProjectFile(session.cwd, args.relPath); + }, + 'codeSession:mergeBack': async (_event, args) => { + const service = container.resolve('codeSessionService'); + return service.mergeBack(args.sessionId); + }, + 'codeSession:cleanupWorktree': async (_event, args) => { + const service = container.resolve('codeSessionService'); + try { + await service.cleanupWorktree(args.sessionId, args.deleteBranch); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to clean up worktree'; + return { success: false, error: message }; + } + }, 'granola:setConfig': async (_event, args) => { const repo = container.resolve('granolaConfigRepo'); await repo.setConfig({ enabled: args.enabled }); @@ -667,21 +1083,191 @@ export function setupIpcHandlers() { 'slack:setConfig': async (_event, args) => { const repo = container.resolve('slackConfigRepo'); await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces }); + // Connecting/disconnecting Slack changes the Copilot's routing (native + // `slack` skill vs. Composio), so rebuild its cached instructions. + invalidateCopilotInstructionsCache(); return { success: true }; }, + 'slack:cliStatus': async () => { + return await getAgentSlackCliStatus(); + }, + 'slack:knowledgeStatus': async () => { + return { + cli: await getAgentSlackCliStatus(), + sources: getSlackKnowledgeSyncStatus(), + }; + }, 'slack:listWorkspaces': async () => { - try { - const { stdout } = await execAsync('agent-slack auth whoami', { timeout: 10000 }); - const parsed = JSON.parse(stdout); - const workspaces = (parsed.workspaces || []).map((w: { workspace_url?: string; workspace_name?: string }) => ({ - url: w.workspace_url || '', - name: w.workspace_name || '', - })); - return { workspaces }; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to list Slack workspaces'; - return { workspaces: [], error: message }; + const result = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 }); + if (!result.ok) { + return { workspaces: [], error: result.message, errorKind: result.kind }; } + const workspaces = parseWhoamiWorkspaces(result.data); + return { workspaces }; + }, + 'slack:importDesktopAuth': async () => { + // Pull xoxc token(s) + cookie from the running/installed Slack desktop + // app into agent-slack's credential store, then read back the workspaces. + return await importDesktopAndReadWorkspaces(); + }, + 'slack:quitAndImportDesktop': async () => { + // Windows-only convenience: kill Slack (which locks its Cookies DB) then + // run the normal desktop import in one click. + await quitSlackIfWindows(); + return await importDesktopAndReadWorkspaces(); + }, + 'slack:parseCurlAuth': async (_event, args) => { + // Cross-OS fallback to desktop import: the user pastes a "Copy as cURL" + // request from a signed-in Slack web tab; parse-curl reads it from stdin + // and extracts the xoxc token + xoxd cookie. No leveldb, no OS keychain. + const curl = (args.curl ?? '').trim(); + if (!curl) { + return { ok: false, workspaces: [], error: 'Paste the copied cURL command first.', errorKind: 'unknown' as const }; + } + const imported = await runAgentSlack(['auth', 'parse-curl'], { timeoutMs: 15000, parseJson: false, input: curl }); + if (!imported.ok) { + return { ok: false, workspaces: [], error: imported.message, errorKind: imported.kind }; + } + const whoami = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 }); + if (!whoami.ok) { + return { ok: false, workspaces: [], error: whoami.message, errorKind: whoami.kind }; + } + const workspaces = parseWhoamiWorkspaces(whoami.data); + if (workspaces.length === 0) { + return { ok: false, workspaces: [], error: 'Tokens were saved but no workspace was found. Double-check the copied request.', errorKind: 'not_authed' as const }; + } + return { ok: true, workspaces }; + }, + 'slack:listChannels': async (_event, args) => { + const result = await runAgentSlack(['channel', 'list', '--all', '--workspace', args.workspaceUrl, '--limit', '200'], { timeoutMs: 15000 }); + if (!result.ok) { + return { channels: [], error: result.message }; + } + const rawChannels = extractArrayPayload(result.data) as Array<{ + id?: string; + name?: string; + is_private?: boolean; + isPrivate?: boolean; + is_member?: boolean; + isMember?: boolean; + }>; + const channels = rawChannels.map((ch) => ({ + id: ch.id || ch.name || '', + name: ch.name || ch.id || '', + isPrivate: ch.is_private ?? ch.isPrivate, + isMember: ch.is_member ?? ch.isMember, + })).filter((ch) => ch.id && ch.name); + return { channels }; + }, + 'slack:getRecentMessages': async (_event, args) => { + const repo = container.resolve('slackConfigRepo'); + const config = await repo.getConfig(); + if (!config.enabled || config.workspaces.length === 0) { + return { enabled: false, messages: [] }; + } + + const limit = Math.min(Math.max(args.limit ?? 5, 1), 20); + const messages: SlackHomeMessage[] = []; + const userNameCache = new Map(); + + try { + const knowledgeConfig = knowledgeSourcesRepo.getConfig(); + const slackSource = knowledgeConfig.sources.find(source => source.id === 'slack' && source.provider === 'slack' && source.enabled); + let channels: SlackHomeChannel[] = (slackSource?.scopes ?? []) + .filter(scope => scope.type === 'channel') + .map(scope => ({ + id: scope.id, + name: scope.name ?? scope.id, + workspaceUrl: scope.workspaceUrl, + workspaceName: config.workspaces.find(workspace => workspace.url === scope.workspaceUrl)?.name, + })); + + if (channels.length === 0) { + for (const workspace of config.workspaces) { + const channelList = await runAgentSlack(['channel', 'list', '--workspace', workspace.url, '--limit', '12'], { timeoutMs: 15000 }); + if (!channelList.ok) { + throw new AgentSlackRunError(channelList.kind, channelList.message); + } + const rawChannels = extractArrayPayload(channelList.data); + for (const raw of rawChannels) { + if (!raw || typeof raw !== 'object') continue; + const channel = raw as Record; + const id = typeof channel.id === 'string' ? channel.id : undefined; + const name = typeof channel.name === 'string' ? channel.name : id; + const isMember = channel.is_member ?? channel.isMember; + if (!id || !name || isMember === false) continue; + channels.push({ id, name, workspaceUrl: workspace.url, workspaceName: workspace.name }); + } + } + } + + channels = channels.slice(0, 8); + + for (const channel of channels) { + const commandArgs = ['message', 'list', channel.id, '--limit', '5', '--max-body-chars', '500']; + if (channel.workspaceUrl) { + commandArgs.push('--workspace', channel.workspaceUrl); + } + const messageList = await runAgentSlack(commandArgs, { timeoutMs: 15000, maxBuffer: 1024 * 1024 }); + if (!messageList.ok) { + console.warn(`[Slack] Failed to load messages for ${channel.name}: ${messageList.message}`); + continue; + } + const rawMessages = extractArrayPayload(messageList.data); + for (const raw of rawMessages) { + if (!raw || typeof raw !== 'object') continue; + const message = raw as Record; + const ts = typeof message.ts === 'string' ? message.ts : undefined; + const text = slackMessageText(message); + if (!ts || !text) continue; + const channelId = typeof message.channel_id === 'string' + ? message.channel_id + : typeof message.channel === 'string' + ? message.channel + : channel.id; + const resolvedAuthor = await resolveSlackAuthor(slackMessageAuthor(message), channel.workspaceUrl, userNameCache); + const resolvedText = await resolveSlackMessageText(text, channel.workspaceUrl, userNameCache); + messages.push({ + id: `${channel.workspaceUrl ?? 'workspace'}:${channelId}:${ts}`, + workspaceName: channel.workspaceName, + workspaceUrl: channel.workspaceUrl, + channelId, + channelName: channel.name, + author: resolvedAuthor, + text: resolvedText, + ts, + url: slackMessageUrl(message, channel.workspaceUrl, channelId, ts), + }); + } + } + + const rankedIds = await rankSlackHomeMessages(messages, limit); + const byId = new Map(messages.map(message => [message.id, message])); + const rankedMessages = rankedIds + .map(id => byId.get(id)) + .filter((message): message is SlackHomeMessage => Boolean(message)); + return { enabled: true, messages: rankedMessages }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to load Slack messages'; + const errorKind = err instanceof AgentSlackRunError ? err.kind : undefined; + return { enabled: true, messages: [], error: message, errorKind }; + } + }, + 'knowledgeSources:getConfig': async () => { + return knowledgeSourcesRepo.getConfig(); + }, + 'knowledgeSources:upsert': async (_event, args) => { + const config = knowledgeSourcesRepo.upsertSource(args); + if (args.provider === 'slack') { + // The Copilot prompt lists the selected Slack channels, so refresh it + // whenever the channel selection changes. + invalidateCopilotInstructionsCache(); + triggerSlackKnowledgeSync(); + void syncSlackKnowledgeSources().catch(error => { + console.error('[SlackKnowledge] Immediate sync after settings update failed:', error); + }); + } + return config; }, 'onboarding:getStatus': async () => { // Show onboarding if it hasn't been completed yet @@ -798,6 +1384,30 @@ export function setupIpcHandlers() { } return { path: result.filePaths[0] ?? null }; }, + 'terminal:ensure': async (_event, args) => { + return ensureTerminal(args.id, args.cwd, args.cols, args.rows); + }, + 'terminal:input': async (_event, args) => { + writeTerminal(args.id, args.data); + return { success: true }; + }, + 'terminal:resize': async (_event, args) => { + resizeTerminal(args.id, args.cols, args.rows); + return { success: true }; + }, + 'terminal:dispose': async (_event, args) => { + disposeTerminal(args.id); + return { success: true }; + }, + 'dialog:openFiles': async (event, args) => { + const win = BrowserWindow.fromWebContents(event.sender); + const result = await dialog.showOpenDialog(win!, { + title: args.title ?? 'Attach files', + ...(args.defaultPath ? { defaultPath: resolveShellPath(args.defaultPath) } : {}), + properties: ['openFile', 'multiSelections'], + }); + return { paths: result.canceled ? [] : result.filePaths }; + }, // Knowledge version history handlers 'knowledge:history': async (_event, args) => { const commits = await versionHistory.getFileHistory(args.path); @@ -811,6 +1421,40 @@ export function setupIpcHandlers() { await versionHistory.restoreFile(args.path, args.oid); return { ok: true }; }, + 'google-docs:getStatus': async () => { + return getGoogleDocsConnectionStatus(); + }, + 'google-docs:import': async (_event, args) => { + console.log(`[GoogleDocs] import fileId=${args.fileId} -> ${args.targetFolder}`); + try { + const result = await importGoogleDoc(args.fileId, args.targetFolder); + console.log(`[GoogleDocs] import OK -> ${result.path}`); + return result; + } catch (err) { + console.error('[GoogleDocs] import FAILED:', err instanceof Error ? err.message : err); + throw err; + } + }, + // Managed (rowboat-mode) OAuth-redirect Picker: the Rowboat backend runs the + // pick with the company Google client; the desktop opens the start URL, + // waits for the deep link, and imports the picked doc with the existing + // managed token. No API key, appId, or local credentials. + 'google-docs:pickViaManaged': async (_event, args) => { + console.log(`[GoogleDocs] managed pick -> ${args.targetFolder}`); + const result = await startManagedGooglePick(args.targetFolder); + if (!result) return null; + console.log(`[GoogleDocs] managed pick import OK -> ${result.path}`); + return result; + }, + 'google-docs:refreshSnapshot': async (_event, args) => { + return syncGoogleDocDown(args.path); + }, + 'google-docs:sync': async (_event, args) => { + return syncGoogleDocUp(args.path, { force: args.force }); + }, + 'google-docs:getLink': async (_event, args) => { + return { link: await getGoogleDocLink(args.path) }; + }, // Search handler 'search:query': async (_event, args) => { return search(args.query, args.limit, args.types); @@ -913,6 +1557,24 @@ export function setupIpcHandlers() { 'voice:synthesize': async (_event, args) => { return voice.synthesizeSpeech(args.text); }, + 'voice:ensureMicAccess': async () => { + if (process.platform !== 'darwin') return { granted: true }; + const status = systemPreferences.getMediaAccessStatus('microphone'); + console.log('[voice] Microphone permission status:', status); + if (status === 'granted') return { granted: true }; + // 'not-determined' shows the native TCC prompt and resolves once the + // user responds; 'denied'/'restricted' resolve false without prompting. + // Awaiting this here means the triggering mic click proceeds to + // getUserMedia only after permission is settled — fixing the first + // click silently failing while the prompt was still up. + try { + const granted = await systemPreferences.askForMediaAccess('microphone'); + console.log('[voice] Microphone permission after prompt:', granted); + return { granted }; + } catch { + return { granted: false }; + } + }, // Live-note handlers 'live-note:run': async (_event, args) => { const result = await runLiveNoteAgent(args.filePath, 'manual', args.context); @@ -1007,6 +1669,7 @@ export function setupIpcHandlers() { name: args.name, instructions: args.instructions, ...(args.triggers ? { triggers: args.triggers } : {}), + ...(args.projectId ? { projectId: args.projectId } : {}), ...(args.model ? { model: args.model } : {}), ...(args.provider ? { provider: args.provider } : {}), }); @@ -1046,6 +1709,13 @@ export function setupIpcHandlers() { 'billing:getInfo': async () => { return await getBillingInfo(); }, + 'notifications:getSettings': async () => { + return loadNotificationSettings(); + }, + 'notifications:setSettings': async (_event, args) => { + saveNotificationSettings(args); + return { success: true }; + }, // Embedded browser handlers (WebContentsView + navigation) ...browserIpcHandlers, }); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 30de0c1e..d1f9b6a1 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { setupIpcHandlers, startRunsWatcher, + startCodeSessionStatusWatcher, startServicesWatcher, startLiveNoteAgentWatcher, startBackgroundTaskAgentWatcher, @@ -11,6 +12,7 @@ import { stopServicesWatcher, stopWorkspaceWatcher } from "./ipc.js"; +import { disposeAllTerminals } from "./terminal.js"; import { fileURLToPath, pathToFileURL } from "node:url"; import { dirname } from "node:path"; import { updateElectronApp, UpdateSourceType } from "update-electron-app"; @@ -36,12 +38,13 @@ import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; +import { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js"; import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js"; import started from "electron-squirrel-startup"; -import { execSync, exec, execFileSync } from "node:child_process"; -import { promisify } from "node:util"; +import { execFileSync } from "node:child_process"; import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; -import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.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 { browserViewManager, BROWSER_PARTITION } from "./browser/view.js"; import { setupBrowserEventForwarding } from "./browser/ipc.js"; import { ElectronBrowserControlService } from "./browser/control-service.js"; @@ -54,7 +57,10 @@ import { } from "./deeplink.js"; import { disconnectGoogleIfScopesStale } from "./oauth-handler.js"; -const execAsync = promisify(exec); +// Captured as early as possible so it reflects actual process start. Used to +// gate grace-eligible notifications (e.g. the burst of background-task +// completions a reopen replays) — see ElectronNotificationService. +const APP_LAUNCHED_AT = Date.now(); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -211,6 +217,38 @@ function configureSessionPermissions(targetSession: Session): void { }); } +// Wire Ctrl/Cmd + (+ / − / 0) to zoom the renderer in/out/reset. +// The app sets no application menu, so the default menu's zoom roles aren't +// available — and on Linux the menu bar is suppressed by the frameless +// `hiddenInset` title bar — so handle the accelerators directly here. +// `event.preventDefault()` stops the keystroke from leaking into the editor. +function setupZoomShortcuts(win: BrowserWindow) { + const ZOOM_STEP = 0.5; // zoom-level units (factor = 1.2 ^ level, ~9.5% per step) + const MIN_ZOOM_LEVEL = -3; + const MAX_ZOOM_LEVEL = 3; + const wc = win.webContents; + + wc.on("before-input-event", (event, input) => { + if (input.type !== "keyDown") return; + // Cmd on macOS, Ctrl elsewhere. + if (!(process.platform === "darwin" ? input.meta : input.control)) return; + + // input.key is the produced character: "+"/"=" share a physical key (as do + // "-"/"_"), and numpad +/- produce the same characters, so this covers both. + const key = input.key; + if (key === "+" || key === "=") { + wc.setZoomLevel(Math.min(wc.getZoomLevel() + ZOOM_STEP, MAX_ZOOM_LEVEL)); + event.preventDefault(); + } else if (key === "-" || key === "_") { + wc.setZoomLevel(Math.max(wc.getZoomLevel() - ZOOM_STEP, MIN_ZOOM_LEVEL)); + event.preventDefault(); + } else if (key === "0") { + wc.setZoomLevel(0); + event.preventDefault(); + } + }); +} + function createWindow() { const win = new BrowserWindow({ width: 1280, @@ -221,6 +259,7 @@ 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, @@ -252,20 +291,43 @@ function createWindow() { return { action: "deny" }; }); - // Handle navigation to external URLs (e.g., clicking a link without target="_blank") - win.webContents.on("will-navigate", (event, url) => { + // Handle navigation to external URLs (e.g., clicking a link without target="_blank"). + // Returns true when the URL was external and routed to the system browser. + const routeExternalNavigation = (url: string): boolean => { const isInternal = url.startsWith("app://") || url.startsWith("http://localhost:5173"); - if (!isInternal) { - event.preventDefault(); - shell.openExternal(url); - } + if (isInternal) return false; + shell.openExternal(url); + return true; + }; + + win.webContents.on("will-navigate", (event, url) => { + if (routeExternalNavigation(url)) event.preventDefault(); + }); + + // Subframe navigations (e.g. links clicked inside the sandboxed iframe that + // renders a background-task / workspace `index.html`) fire `will-frame-navigate`, + // not `will-navigate`. Route their external links to the system browser too, + // so HTML reports behave like the markdown viewer. Main-frame navigations are + // already handled by `will-navigate` above — skip them here to avoid double-open. + // + // Scope this to our own HTML viewer frames (identified by their app://workspace + // document origin). Third-party note embeds (YouTube, Figma, Twitter via the + // embed/iframe blocks) load from their own origins — leave their internal + // navigation untouched so the embeds keep working. + win.webContents.on("will-frame-navigate", (event) => { + if (event.isMainFrame) return; + if (!event.frame?.url.startsWith("app://workspace/")) return; + if (routeExternalNavigation(event.url)) event.preventDefault(); }); // Attach the embedded browser pane manager to this window. // The WebContentsView is created lazily on first `browser:setVisible`. browserViewManager.attach(win); + // Cmd/Ctrl + (+ / − / 0) zoom shortcuts for the renderer UI. + setupZoomShortcuts(win); + if (app.isPackaged) { win.loadURL("app://-/index.html"); } else { @@ -290,18 +352,13 @@ app.whenReady().then(async () => { }); } - // Ensure agent-slack CLI is available - try { - execSync('agent-slack --version', { stdio: 'ignore', timeout: 5000 }); - } catch { - try { - console.log('agent-slack not found, installing...'); - await execAsync('npm install -g agent-slack', { timeout: 60000 }); - console.log('agent-slack installed successfully'); - } catch (e) { - console.error('Failed to install agent-slack:', e); - } - } + // The agent-slack CLI ships bundled with the app (.package/dist/agent-slack.cjs) + // and is resolved per call by the shared executor in @x/core. Availability is + // exposed to the UI via the slack:cliStatus IPC channel; this startup log is + // diagnostics only. + getAgentSlackCliStatus().then((status) => { + console.log('[Slack] agent-slack CLI status:', status); + }).catch(() => { /* probe failures already surface through slack:cliStatus */ }); // Initialize all config files before UI can access them await initConfigs(); @@ -314,7 +371,7 @@ app.whenReady().then(async () => { }); registerBrowserControlService(new ElectronBrowserControlService()); - registerNotificationService(new ElectronNotificationService()); + registerNotificationService(new ElectronNotificationService(APP_LAUNCHED_AT)); setupIpcHandlers(); setupBrowserEventForwarding(); @@ -331,6 +388,9 @@ app.whenReady().then(async () => { // start runs watcher startRunsWatcher(); + // start code-session status tracker (derives working/needs-you/idle + notifications) + startCodeSessionStatusWatcher(); + // start services watcher startServicesWatcher(); @@ -421,7 +481,15 @@ app.on("before-quit", () => { stopWorkspaceWatcher(); stopRunsWatcher(); stopServicesWatcher(); - stopSkillsWatcher(); +stopSkillsWatcher(); + // 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 + } + // Kill embedded terminal shells. + disposeAllTerminals(); shutdownLocalSites().catch((error) => { console.error('[LocalSites] Failed to shut down cleanly:', error); }); diff --git a/apps/x/apps/main/src/notification/electron-notification-service.ts b/apps/x/apps/main/src/notification/electron-notification-service.ts index dd37e37d..22660343 100644 --- a/apps/x/apps/main/src/notification/electron-notification-service.ts +++ b/apps/x/apps/main/src/notification/electron-notification-service.ts @@ -1,5 +1,6 @@ import { BrowserWindow, Notification, shell } from "electron"; import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js"; +import { shouldSuppressDuringStartupGrace } from "@x/core/dist/application/notification/service.js"; import { dispatchUrl } from "../deeplink.js"; const HTTP_URL = /^https?:\/\//i; @@ -11,11 +12,35 @@ export class ElectronNotificationService implements INotificationService { // gets dropped and macOS clicks just focus the app silently. private active = new Set(); + // Timestamp the app launched, used to gate grace-eligible notifications (see + // NotifyInput.suppressDuringStartupGrace). Captured by the caller in main.ts + // so it reflects process start rather than whenever the first notify fires. + private readonly launchedAt: number; + + constructor(launchedAt: number = Date.now()) { + this.launchedAt = launchedAt; + } + isSupported(): boolean { return Notification.isSupported(); } - notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void { + notify({ title = "Rowboat", message, link, actionLabel, secondaryActions, onlyWhenBackground, suppressDuringStartupGrace }: NotifyInput): void { + // Startup grace: a reopen replays every background task that completed + // while the app was closed, so grace-eligible notifications fired in the + // first moments after launch are dropped to avoid a notification flood. + if (shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace }, this.launchedAt)) { + return; + } + + // Ambient notifications are suppressed while the app is in the + // foreground — the user is already looking at it. A window counts as + // foreground only if it's actually focused (minimized / other-space + // windows are not), so this correctly treats those as background. + if (onlyWhenBackground && BrowserWindow.getAllWindows().some((w) => w.isFocused())) { + return; + } + // Build the actions array AND a parallel index → link map. // macOS shows actions[0] inline (Banner) or all of them (Alert); // additional ones live behind the chevron menu. diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 1048d9b8..e0d4b2fc 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -345,11 +345,11 @@ export async function connectProvider(provider: string, credentials?: { clientId signedInUserId = billing.userId; analyticsIdentify(billing.userId, { ...(billing.userEmail ? { email: billing.userEmail } : {}), - plan: billing.subscriptionPlan, + plan: billing.subscriptionPlanId, status: billing.subscriptionStatus, }); analyticsCapture('user_signed_in', { - plan: billing.subscriptionPlan, + plan: billing.subscriptionPlanId, status: billing.subscriptionStatus, }); } @@ -420,15 +420,23 @@ export async function connectProvider(provider: string, credentials?: { clientId scope: scopes.join(' '), code_challenge: codeChallenge, state, + // Google only returns a refresh_token when offline access is requested, + // and only re-issues one when re-consent is forced. Without these, a + // BYOK token expires after ~1h with no way to refresh (it goes stale and + // every Google call — including the Picker — starts failing). + ...(provider === 'google' ? { access_type: 'offline', prompt: 'consent' } : {}), }); - // Set timeout to clean up abandoned flows (2 minutes) + // Set timeout to clean up abandoned flows. Generous (10 min) because a + // first-time connect can involve creating/locating OAuth credentials in + // the Cloud Console mid-flow; a short window tears down the callback + // server before the user finishes consent, silently dropping the token. const cleanupTimeout = setTimeout(() => { if (activeFlow?.state === state) { console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`); cancelActiveFlow('timed_out'); } - }, 2 * 60 * 1000); + }, 10 * 60 * 1000); activeFlow = { provider, diff --git a/apps/x/apps/main/src/terminal.ts b/apps/x/apps/main/src/terminal.ts new file mode 100644 index 00000000..83d5a7c9 --- /dev/null +++ b/apps/x/apps/main/src/terminal.ts @@ -0,0 +1,126 @@ +import { BrowserWindow } from 'electron'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +// node-pty is a NATIVE module: it stays external to the esbuild bundle and is +// shipped alongside it in .package/node_modules (see bundle.mjs). +import * as pty from 'node-pty'; + +// One PTY per coding session, kept alive while the app runs so the terminal +// survives pane collapses and session switches. The renderer view re-attaches +// via `terminal:ensure`, which replays the recent backlog. + +const BACKLOG_LIMIT = 400_000; // chars (~400KB) of scrollback replay + +interface TerminalEntry { + proc: pty.IPty; + cwd: string; + backlog: string; + running: boolean; +} + +const terminals = new Map(); + +function broadcast(channel: 'terminal:data' | 'terminal:exit', payload: unknown): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send(channel, payload); + } + } +} + +// pnpm extracts node-pty's prebuilt macOS spawn-helper without its executable +// bit, which makes every spawn fail with "posix_spawnp failed". Repair it once. +let helperFixed = false; +function ensureSpawnHelperExecutable(): void { + if (helperFixed || process.platform === 'win32') return; + helperFixed = true; + try { + const pkgDir = path.dirname(require.resolve('node-pty/package.json')); + const helper = path.join(pkgDir, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'); + if (fs.existsSync(helper)) { + fs.chmodSync(helper, 0o755); + } + } catch { + // best effort — spawn() will surface a real error if this mattered + } +} + +function defaultShell(): { file: string; args: string[] } { + if (process.platform === 'win32') { + return { file: 'powershell.exe', args: [] }; + } + // Login shell so the user's PATH/aliases match their normal terminal. + return { file: process.env.SHELL || '/bin/zsh', args: ['-l'] }; +} + +function spawnEntry(id: string, cwd: string, cols: number, rows: number): TerminalEntry { + ensureSpawnHelperExecutable(); + const { file, args } = defaultShell(); + const proc = pty.spawn(file, args, { + name: 'xterm-256color', + cwd, + cols, + rows, + env: { ...process.env, TERM_PROGRAM: 'rowboat' } as Record, + }); + const entry: TerminalEntry = { proc, cwd, backlog: '', running: true }; + proc.onData((data) => { + entry.backlog = (entry.backlog + data).slice(-BACKLOG_LIMIT); + broadcast('terminal:data', { id, data }); + }); + proc.onExit(({ exitCode }) => { + entry.running = false; + broadcast('terminal:exit', { id, exitCode }); + }); + terminals.set(id, entry); + return entry; +} + +// Create-or-attach. A cwd change (e.g. the session's worktree was removed) or +// an exited shell gets a fresh PTY; otherwise the live one is reused and the +// caller repaints from the backlog. +export function ensureTerminal(id: string, cwd: string, cols: number, rows: number): { backlog: string; running: boolean } { + const existing = terminals.get(id); + if (existing && existing.running && existing.cwd === cwd) { + existing.proc.resize(cols, rows); + return { backlog: existing.backlog, running: true }; + } + if (existing) { + disposeTerminal(id); + } + const fallbackCwd = fs.existsSync(cwd) ? cwd : os.homedir(); + const entry = spawnEntry(id, fallbackCwd, cols, rows); + return { backlog: entry.backlog, running: entry.running }; +} + +export function writeTerminal(id: string, data: string): void { + const entry = terminals.get(id); + if (entry?.running) entry.proc.write(data); +} + +export function resizeTerminal(id: string, cols: number, rows: number): void { + const entry = terminals.get(id); + if (entry?.running) { + try { + entry.proc.resize(cols, rows); + } catch { + // resizing a dying pty throws — harmless + } + } +} + +export function disposeTerminal(id: string): void { + const entry = terminals.get(id); + if (!entry) return; + terminals.delete(id); + try { + entry.proc.kill(); + } catch { + // already gone + } +} + +export function disposeAllTerminals(): void { + for (const id of [...terminals.keys()]) disposeTerminal(id); +} diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index 67876189..eec078d6 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -9,7 +9,13 @@ "preview": "vite preview" }, "dependencies": { + "@codemirror/language": "^6.12.3", + "@codemirror/language-data": "^6.5.2", + "@codemirror/merge": "^6.12.2", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.1", "@eigenpal/docx-editor-react": "^1.0.3", + "@lezer/highlight": "^1.2.3", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", @@ -38,10 +44,13 @@ "@tiptap/starter-kit": "3.22.4", "@x/preload": "workspace:*", "@x/shared": "workspace:*", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "ai": "^5.0.117", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "codemirror": "^6.0.2", "lucide-react": "^0.562.0", "mermaid": "^11.14.0", "motion": "^12.23.26", diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 86c6535d..1fd8c287 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -82,64 +82,40 @@ background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px); } +/* Inbox surface — mapped onto the app's design tokens so it follows the same + palette, accent, and theme switching as the rest of Rowboat. Light/dark is + handled by the app tokens (:root / .dark), so no separate .light override. */ .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; + --gm-bg: var(--background); + --gm-bg-card: var(--card-surface); + --gm-bg-input: var(--muted); + --gm-bg-row-hover: var(--accent); + --gm-bg-row-selected: var(--rowboat-wash); + --gm-bg-row-selected-hover: color-mix(in oklab, var(--primary) 18%, var(--background)); + --gm-bg-iframe: #ffffff; + --gm-bg-pill: var(--background); + --gm-bg-pill-hover: var(--accent); + --gm-text: var(--foreground); + --gm-text-strong: var(--foreground); + --gm-text-muted: var(--muted-foreground); + --gm-text-faint: color-mix(in oklab, var(--muted-foreground) 70%, var(--background)); + --gm-text-body: color-mix(in oklab, var(--foreground) 88%, var(--background)); + --gm-border: var(--border); + --gm-border-strong: var(--rowboat-hairline); + --gm-accent: var(--primary); + --gm-accent-hover: color-mix(in oklab, var(--primary) 90%, transparent); + --gm-accent-glow: color-mix(in oklab, var(--primary) 40%, transparent); + --gm-accent-fg: var(--primary-foreground); + --gm-icon-hover-bg: var(--accent); + --gm-placeholder: var(--muted-foreground); 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; + background: var(--background); + color: var(--foreground); } .gmail-main { @@ -160,6 +136,13 @@ border-bottom: 1px solid var(--gm-border); } +.gmail-topbar-actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + .gmail-search { display: flex; align-items: center; @@ -319,7 +302,7 @@ } .gmail-row-action-danger:hover { - color: #e8453c; + color: var(--destructive); } .gmail-row:hover { @@ -707,411 +690,78 @@ 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 { +/* Email composer body — Tiptap content, styled to the app's design system. */ +.compose-content { outline: none; min-height: 120px; padding: 12px; background: transparent; - color: var(--gm-text); - font: 13px/1.55 inherit; + color: var(--foreground); + font-size: 0.875rem; + line-height: 1.6; } -.gmail-compose-content p { +.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 { +.compose-content p + p, +.compose-content p + ul, +.compose-content p + ol, +.compose-content p + blockquote { margin-top: 8px; } -.gmail-compose-content ul, -.gmail-compose-content ol { +.compose-content ul, +.compose-content ol { margin: 0; padding-left: 22px; } -.gmail-compose-content ul { +.compose-content ul { list-style: disc; } -.gmail-compose-content ol { +.compose-content ol { list-style: decimal; } -.gmail-compose-content li { +.compose-content li { margin: 2px 0; } -.gmail-compose-content li > p { +.compose-content li > p { margin: 0; } -.gmail-compose-content blockquote { +.compose-content blockquote { margin: 4px 0; padding-left: 12px; - border-left: 2px solid var(--gm-border-strong); - color: var(--gm-text-muted); + border-left: 2px solid var(--border); + color: var(--muted-foreground); } -.gmail-compose-content a { - color: var(--gm-accent); +.compose-content a { + color: var(--primary); text-decoration: underline; } -.gmail-compose-content code { +.compose-content code { padding: 1px 4px; border-radius: 3px; - background: var(--gm-bg-pill-hover); + background: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 12px; + font-size: 0.8125rem; } -.gmail-compose-content p.is-editor-empty:first-child::before { +.compose-content p.is-editor-empty:first-child::before { content: attr(data-placeholder); - color: var(--gm-placeholder); + color: var(--muted-foreground); 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; @@ -1367,6 +1017,17 @@ var(--rowboat-shadow); } +.dark .rowboat-code-chat-input { + border-color: color-mix(in oklab, var(--border) 76%, transparent); + background: #000000; + box-shadow: none; +} + +.dark .rowboat-code-chat-input:focus-within { + border-color: color-mix(in oklab, var(--ring) 70%, var(--border) 30%); + box-shadow: 0 0 0 1px color-mix(in oklab, var(--ring) 30%, transparent); +} + [data-slot="message-content"] { line-height: 1.62; } diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 57a03727..cf1d3d8f 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,13 +5,13 @@ 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, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, 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 StagedAttachment } from './components/chat-input-with-mentions'; +import { ChatInputWithMentions, type PermissionMode, 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'; @@ -29,10 +29,15 @@ 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 { KnowledgeView } from '@/components/knowledge-view'; +import { CodingRunBlock } from '@/components/coding-run'; +import { KnowledgeView, type KnowledgeViewMode } from '@/components/knowledge-view'; +import { GoogleDocPickerDialog } from '@/components/google-doc-picker-dialog'; import { ChatHistoryView } from '@/components/chat-history-view'; import { HomeView } from '@/components/home-view'; import { MeetingsView } from '@/components/meetings-view'; +import { CodeView, type ActiveCodeSession } from '@/components/code/code-view'; +import { CodeChat } from '@/components/code/code-chat'; +import { ResizableRightPane } from '@/components/code/resizable-right-pane'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { Conversation, @@ -56,9 +61,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 { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; +import { ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; import { SidebarInset, SidebarProvider, @@ -116,6 +122,7 @@ 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 @@ -164,6 +171,7 @@ 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 }, @@ -195,6 +203,7 @@ 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 CODE_TAB_PATH = '__rowboat_code__' const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) @@ -247,6 +256,43 @@ const stripKnowledgePrefixForWiki = (relPath: string) => { const stripMarkdownExtensionForWiki = (wikiPath: string) => wikiPath.toLowerCase().endsWith('.md') ? wikiPath.slice(0, -3) : wikiPath +type LinkedGoogleDocMeta = { + id: string + title: string + url?: string + syncedAt?: string +} + +const parseLinkedGoogleDocFrontmatter = (raw: string | null | undefined): LinkedGoogleDocMeta | null => { + if (!raw?.includes('google_doc:')) return null + const doc: Partial = {} + let inGoogleDoc = false + for (const line of raw.split('\n')) { + if (line.trim() === '---') { + inGoogleDoc = false + continue + } + const topLevel = line.match(/^([A-Za-z_][\w-]*):\s*.*$/) + if (topLevel) { + inGoogleDoc = topLevel[1] === 'google_doc' + continue + } + if (!inGoogleDoc) continue + const nested = line.match(/^\s+([A-Za-z_][\w-]*):\s*(.*)$/) + if (!nested) continue + const key = nested[1] as keyof LinkedGoogleDocMeta + if (!['id', 'title', 'url', 'syncedAt'].includes(key)) continue + let value = nested[2].trim() + try { + value = JSON.parse(value) + } catch { + value = value.replace(/^['"]|['"]$/g, '') + } + doc[key] = value + } + return doc.id && doc.title ? doc as LinkedGoogleDocMeta : null +} + const wikiPathCompareKey = (wikiPath: string) => stripMarkdownExtensionForWiki(wikiPath).toLowerCase() @@ -332,6 +378,7 @@ const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PAT 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 isCodeTabPath = (path: string) => path === CODE_TAB_PATH const getSuggestedTopicTargetFolder = (category?: string) => { const normalized = category?.trim().toLowerCase() @@ -580,11 +627,13 @@ type ViewState = | { type: 'suggested-topics' } | { type: 'meetings' } | { type: 'live-notes' } - | { type: 'email' } + | { type: 'email'; threadId?: string } | { type: 'workspace'; path?: string } - | { type: 'knowledge-view'; folderPath?: string } + | { type: 'knowledge-view'; folderPath?: string; mode?: KnowledgeViewMode } | { type: 'chat-history' } | { type: 'home' } + | { type: 'code' } + | { type: 'bg-tasks' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false @@ -592,7 +641,8 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { 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 ?? '') + if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '') && (a.mode ?? '') === (b.mode ?? '') + if (a.type === 'email' && b.type === 'email') return (a.threadId ?? '') === (b.threadId ?? '') return true // both graph } @@ -600,7 +650,7 @@ 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 @@ -608,6 +658,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { * suggested-topics: ?type=suggested-topics * meetings: ?type=meetings * live-notes: ?type=live-notes + * email: ?type=email */ function parseDeepLink(input: string): ViewState | null { const SCHEME = 'rowboat://' @@ -636,18 +687,31 @@ function parseDeepLink(input: string): ViewState | null { return { type: 'meetings' } case 'live-notes': return { type: 'live-notes' } + case 'email': { + const threadId = params.get('threadId') + return { type: 'email', threadId: threadId || undefined } + } 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 } + const mode = params.get('mode') + return { + type: 'knowledge-view', + folderPath: folderPath ?? undefined, + mode: mode === 'graph' || mode === 'basis' || mode === 'files' ? mode : undefined, + } } case 'chat-history': return { type: 'chat-history' } case 'home': return { type: 'home' } + case 'code': + return { type: 'code' } + case 'bg-tasks': + return { type: 'bg-tasks' } default: return null } @@ -735,6 +799,9 @@ function ContentHeader({ } function App() { + const { chatPanePlacement, chatPaneSize } = useTheme() + const isChatPaneInMiddle = chatPanePlacement === 'middle' + type ShortcutPane = 'left' | 'right' type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean } @@ -760,11 +827,14 @@ function App() { const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false) const [workspaceInitialPath, setWorkspaceInitialPath] = useState(null) const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false) + const [knowledgeViewMode, setKnowledgeViewMode] = useState('graph') // Folder being browsed inside the knowledge view (null = root overview). // Lives in ViewState so folder drill-down participates in back/forward history. const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState(null) + const [googleDocPickerOpen, setGoogleDocPickerOpen] = useState(false) + const [googleDocPickerTargetFolder, setGoogleDocPickerTargetFolder] = useState('knowledge') const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false) - // Default landing view: Home in the middle with the chat docked on the right. + // Default landing view: Home with the chat docked according to appearance settings. const [isHomeOpen, setIsHomeOpen] = useState(true) const [emailInitialThreadId, setEmailInitialThreadId] = useState(null) const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0) @@ -829,6 +899,7 @@ function App() { // Auto-save state const [isSaving, setIsSaving] = useState(false) const [lastSaved, setLastSaved] = useState(null) + const [googleDocSyncDirection, setGoogleDocSyncDirection] = useState<'up' | 'down' | null>(null) const debouncedContent = useDebounce(editorContent, 500) const initialContentRef = useRef('') const renameInProgressRef = useRef(false) @@ -961,7 +1032,7 @@ function App() { voice.start() }, [voice]) - const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => Promise) | null>(null) + const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => Promise) | null>(null) const pendingVoiceInputRef = useRef(false) // Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload @@ -969,8 +1040,9 @@ function App() { const editorRefsByTabId = useRef>(new Map()) const [pendingPaletteSubmit, setPendingPaletteSubmit] = useState<{ text: string; mention: CommandPaletteMention | null } | null>(null) - const handleSubmitRecording = useCallback(() => { - const text = voice.submit() + const handleSubmitRecording = useCallback(async () => { + if (!isRecordingRef.current) return + const text = await voice.submit() setIsRecording(false) isRecordingRef.current = false if (text) { @@ -1027,7 +1099,7 @@ function App() { }, []) // Runs history state - type RunListItem = { id: string; title?: string; createdAt: string; agentId: string } + type RunListItem = { id: string; title?: string; createdAt: string; modifiedAt: string; agentId: string; useCase?: string } const [runs, setRuns] = useState([]) // Chat tab state @@ -1152,6 +1224,23 @@ function App() { const [activeFileTabId, setActiveFileTabId] = useState('home-tab') const activeFileTabIdRef = useRef(activeFileTabId) activeFileTabIdRef.current = activeFileTabId + // The Code section is tab-derived (no boolean to keep in sync with the other + // section flags): it is open exactly while its sentinel tab is active. + const isCodeOpen = React.useMemo(() => { + const activeTab = fileTabs.find((tab) => tab.id === activeFileTabId) + return activeTab ? isCodeTabPath(activeTab.path) : false + }, [fileTabs, activeFileTabId]) + // The code session that owns the right-hand chat pane: rowboat-mode sessions + // bind the assistant chat to their run; direct-mode sessions swap the pane + // for the direct-drive chat. + const [activeCodeSession, setActiveCodeSession] = useState(null) + // A file the code chat asked to review — consumed by the workspace pane. + const [codeDiffPath, setCodeDiffPath] = useState(null) + const boundCodeSessionRef = useRef(null) + // Composer locks for runs that are code sessions: the session's cwd + agent + // are frozen in the chat input (the backend pins them server-side anyway). + // Kept after the Code view unmounts — the chat tab stays bound to the run. + const [codeSessionLocks, setCodeSessionLocks] = useState>({}) const [editorSessionByTabId, setEditorSessionByTabId] = useState>({}) const fileHistoryHandlersRef = useRef>(new Map()) const fileTabIdCounterRef = useRef(0) @@ -1165,9 +1254,10 @@ function App() { if (isBgTasksTabPath(tab.path)) return 'Background tasks' if (isEmailTabPath(tab.path)) return 'Email' if (isWorkspaceTabPath(tab.path)) return 'Workspace' - if (isKnowledgeViewTabPath(tab.path)) return 'Notes' + if (isKnowledgeViewTabPath(tab.path)) return 'Brain' if (isChatHistoryTabPath(tab.path)) return 'Chat history' if (isHomeTabPath(tab.path)) return 'Home' + if (isCodeTabPath(tab.path)) return 'Code' if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path @@ -1180,6 +1270,7 @@ function App() { const [allPermissionRequests, setAllPermissionRequests] = useState>>(new Map()) // Track permission responses (toolCallId -> response) const [permissionResponses, setPermissionResponses] = useState>(new Map()) + const [autoPermissionDecisions, setAutoPermissionDecisions] = useState>>(new Map()) useEffect(() => { chatViewStateByTabRef.current = chatViewStateByTab @@ -1193,6 +1284,7 @@ function App() { pendingAskHumanRequests: new Map(pendingAskHumanRequests), allPermissionRequests: new Map(allPermissionRequests), permissionResponses: new Map(permissionResponses), + autoPermissionDecisions: new Map(autoPermissionDecisions), } setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot })) }, [ @@ -1203,6 +1295,7 @@ function App() { pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, ]) useEffect(() => { @@ -1347,6 +1440,30 @@ function App() { return isRecent }, []) + const reloadMarkdownFileIntoEditor = useCallback(async (path: string) => { + const result = await window.ipc.invoke('workspace:readFile', { path, encoding: 'utf8' }) + const { raw: fm, body } = splitFrontmatter(result.data) + frontmatterByPathRef.current.set(path, fm) + setFileContent(result.data) + setEditorContent(body) + setEditorCacheForPath(path, body) + editorContentRef.current = body + editorPathRef.current = path + initialContentByPathRef.current.set(path, body) + initialContentRef.current = body + setLastSaved(new Date()) + setEditorSessionByTabId((prev) => { + let changed = false + const next = { ...prev } + for (const tab of fileTabs) { + if (tab.path !== path) continue + next[tab.id] = (next[tab.id] ?? 0) + 1 + changed = true + } + return changed ? next : prev + }) + }, [fileTabs, setEditorCacheForPath]) + const handleEditorChange = useCallback((path: string, markdown: string) => { setEditorCacheForPath(path, markdown) const nextSelectedPath = selectedPathRef.current @@ -1360,6 +1477,61 @@ function App() { editorContentRef.current = markdown setEditorContent(markdown) }, [setEditorCacheForPath]) + + const syncGoogleDocDown = useCallback(async (targetPath?: string) => { + const path = targetPath ?? selectedPathRef.current + if (!path || !path.startsWith('knowledge/') || !path.endsWith('.md')) return + + setGoogleDocSyncDirection('down') + markRecentLocalMarkdownWrite(path) + try { + await window.ipc.invoke('google-docs:refreshSnapshot', { path }) + markRecentLocalMarkdownWrite(path) + await reloadMarkdownFileIntoEditor(path) + toast.success('Pulled latest Google Doc') + } catch (err) { + console.error('Failed to sync Google Doc down:', err) + toast.error(err instanceof Error ? err.message : 'Failed to pull Google Doc') + } finally { + setGoogleDocSyncDirection(null) + } + }, [markRecentLocalMarkdownWrite, reloadMarkdownFileIntoEditor]) + + const syncGoogleDocUp = useCallback(async (targetPath?: string) => { + const path = targetPath ?? selectedPathRef.current + if (!path || !path.startsWith('knowledge/') || !path.endsWith('.md')) return + + const body = editorContentByPathRef.current.get(path) ?? editorContentRef.current + const markdown = joinFrontmatter(frontmatterByPathRef.current.get(path) ?? null, body) + setGoogleDocSyncDirection('up') + markRecentLocalMarkdownWrite(path) + try { + let result = await window.ipc.invoke('google-docs:sync', { path, markdown }) + if (result.conflict) { + const overwrite = window.confirm( + 'This Google Doc changed since your last sync.\n\n' + + 'Overwrite it with your local version? Cancel to keep the remote copy ' + + '(use “Sync down” to pull it first).', + ) + if (!overwrite) { + toast.info('Sync up cancelled — remote Google Doc is unchanged') + return + } + result = await window.ipc.invoke('google-docs:sync', { path, markdown, force: true }) + } + if (!result.synced) { + throw new Error(result.error || 'This note is not linked to a Google Doc.') + } + markRecentLocalMarkdownWrite(path) + await reloadMarkdownFileIntoEditor(path) + toast.success('Pushed changes to Google Doc') + } catch (err) { + console.error('Failed to sync Google Doc up:', err) + toast.error(err instanceof Error ? err.message : 'Failed to push Google Doc') + } finally { + setGoogleDocSyncDirection(null) + } + }, [markRecentLocalMarkdownWrite, reloadMarkdownFileIntoEditor]) // Keep processingRunIdsRef in sync for use in async callbacks useEffect(() => { processingRunIdsRef.current = processingRunIds @@ -1651,6 +1823,7 @@ function App() { const baseline = initialContentByPathRef.current.get(pathAtStart) ?? initialContentRef.current if (debouncedContent === baseline) return if (!debouncedContent) return + if (selectedPathRef.current === pathAtStart && debouncedContent !== editorContentRef.current) return const saveFile = async () => { const wasActiveAtStart = selectedPathRef.current === pathAtStart @@ -1797,8 +1970,8 @@ function App() { cursor = result.nextCursor } while (cursor) - // Filter for copilot runs only - const copilotRuns = allRuns.filter((run: RunListItem) => run.agentId === 'copilot') + // Filter for copilot chats only (Code-section sessions live in the Code view) + const copilotRuns = allRuns.filter((run: RunListItem) => run.agentId === 'copilot' && run.useCase !== 'code_session') setRuns(copilotRuns) } catch (err) { console.error('Failed to load runs:', err) @@ -2026,6 +2199,7 @@ function App() { // Track permission requests and responses from history const allPermissionRequests = new Map>() const permResponseMap = new Map() + const autoPermissionDecisions = new Map>() const askHumanRequests = new Map>() const respondedAskHumanIds = new Set() @@ -2034,6 +2208,8 @@ function App() { allPermissionRequests.set(event.toolCall.toolCallId, event) } else if (event.type === 'tool-permission-response') { permResponseMap.set(event.toolCallId, event.response) + } else if (event.type === 'tool-permission-auto-decision') { + autoPermissionDecisions.set(event.toolCallId, event) } else if (event.type === 'ask-human-request') { askHumanRequests.set(event.toolCallId, event) } else if (event.type === 'ask-human-response') { @@ -2062,10 +2238,20 @@ function App() { setConversation(items) setRunId(id) setMessage('') + // Reconcile composer state with THIS run. Loading a run while another one + // is mid-turn (e.g. binding a code session steals the single chat tab) + // must not leave isProcessing/isStopping pointing at the old run — that + // wedges the composer: stop targets the new run (a no-op) while the old + // run's processing-end arrives flagged as non-active and clears nothing. + setIsProcessing(processingRunIdsRef.current.has(id)) + setIsStopping(false) + setStopClickedAt(null) + setCurrentAssistantMessage(streamingBuffersRef.current.get(id)?.assistant ?? '') setPendingPermissionRequests(pendingPerms) setPendingAskHumanRequests(pendingAsks) setAllPermissionRequests(allPermissionRequests) setPermissionResponses(permResponseMap) + setAutoPermissionDecisions(autoPermissionDecisions) // Restore the run's per-chat work directory into the tab it was loaded into. const tabId = activeChatTabIdRef.current @@ -2131,6 +2317,11 @@ function App() { break case 'start': + // Run creation alone isn't a turn. Code-session runs are created when + // the session is (no message follows until the user sends one), so + // marking them processing here would never be cleared — and wedge the + // composer (Stop shown, send blocked) once the session binds a chat tab. + if (event.useCase === 'code_session') return setProcessingRunIds(prev => { if (prev.has(event.runId)) return prev const next = new Set(prev) @@ -2185,19 +2376,6 @@ function App() { status: 'running', timestamp: Date.now(), }]) - // Detect acpx-driven coding-agent runs so the composer can retroactively - // flip code mode on with the right agent (when the user reached the skill - // via plain prompt rather than the explicit toggle). - if (llmEvent.toolName === 'executeCommand') { - const input = llmEvent.input as { command?: unknown } | undefined - const cmd = typeof input?.command === 'string' ? input.command : '' - const match = cmd.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b/) - if (match) { - window.dispatchEvent(new CustomEvent('code-mode-detected', { - detail: { runId: event.runId, agent: match[1] as 'claude' | 'codex' }, - })) - } - } } else if (llmEvent.type === 'finish-step') { const nextUsage = normalizeUsage(llmEvent.usage) if (nextUsage) { @@ -2295,6 +2473,8 @@ function App() { ...item, result: event.result as ToolUIPart['output'], status: 'completed' as const, + // a code_agent_run finished — drop any lingering permission card + pendingCodePermission: null, } } return item @@ -2375,6 +2555,43 @@ function App() { break } + case 'code-run-event': { + if (!isActiveRun) return + setConversation(prev => prev.map(item => { + if (isToolCall(item) && item.id === event.toolCallId) { + const existing = item.codeRunEvents ?? [] + if (existing.length === 0) { + setToolOpenForTab(activeChatTabIdRef.current, item.id, true) + } + return { ...item, codeRunEvents: [...existing, event.event] } + } + return item + })) + break + } + + case 'code-run-permission-request': { + if (!isActiveRun) return + setConversation(prev => prev.map(item => { + if (isToolCall(item) && item.id === event.toolCallId) { + setToolOpenForTab(activeChatTabIdRef.current, item.id, true) + return { ...item, pendingCodePermission: { requestId: event.requestId, ask: event.ask } } + } + return item + })) + break + } + + case 'tool-permission-auto-decision': { + if (!isActiveRun) return + setAutoPermissionDecisions(prev => { + const next = new Map(prev) + next.set(event.toolCallId, event) + return next + }) + break + } + case 'ask-human-request': { if (!isActiveRun) return const key = event.toolCallId @@ -2491,6 +2708,7 @@ function App() { stagedAttachments: StagedAttachment[] = [], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', + permissionMode?: PermissionMode, ) => { if (isProcessing) return @@ -2530,6 +2748,7 @@ function App() { const run = await window.ipc.invoke('runs:create', { agentId, ...(selected ? { model: selected.model, provider: selected.provider } : {}), + permissionMode: permissionMode ?? 'manual', }) currentRunId = run.id newRunCreatedAt = run.createdAt @@ -2634,10 +2853,12 @@ function App() { const inferredTitle = inferRunTitleFromMessage(titleSource) setRuns((prev) => { const withoutCurrent = prev.filter((run) => run.id !== currentRunId) + const createdAt = newRunCreatedAt ?? new Date().toISOString() return [{ id: currentRunId!, title: inferredTitle, - createdAt: newRunCreatedAt ?? new Date().toISOString(), + createdAt, + modifiedAt: createdAt, agentId, }, ...withoutCurrent] }) @@ -2705,6 +2926,26 @@ function App() { } }, [runId]) + // Answer a mid-run permission request from a code_agent_run coding turn. The + // pending ask lives on the tool call itself, so we optimistically clear it and + // tell main which decision the user picked (keyed by the request id). + const handleCodePermissionResponse = useCallback(async ( + toolCallId: string, + requestId: string, + decision: 'allow_once' | 'allow_always' | 'reject', + ) => { + setConversation(prev => prev.map(item => + isToolCall(item) && item.id === toolCallId + ? { ...item, pendingCodePermission: null } + : item + )) + try { + await window.ipc.invoke('codeRun:resolvePermission', { requestId, decision }) + } catch (error) { + console.error('Failed to resolve code permission:', error) + } + }, []) + const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => { if (!runId) return try { @@ -2734,6 +2975,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setAutoPermissionDecisions(new Map()) setSelectedBackgroundTask(null) setChatViewportAnchor(activeChatTabIdRef.current, null) setChatViewStateByTab(prev => ({ @@ -2760,6 +3002,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setAutoPermissionDecisions(new Map()) setChatViewportAnchor(tab.id, null) } }, [loadRun, setChatViewportAnchor]) @@ -2785,6 +3028,7 @@ function App() { setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests)) setAllPermissionRequests(new Map(cached.allPermissionRequests)) setPermissionResponses(new Map(cached.permissionResponses)) + setAutoPermissionDecisions(new Map(cached.autoPermissionDecisions)) setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId))) return true }, []) @@ -2813,6 +3057,38 @@ function App() { } }, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState, saveChatScrollForTab]) + // A code session was selected (or changed mode/status) in the Code view. + // Rowboat-mode sessions take over the assistant chat pane by binding their + // run to a chat tab — the conversation IS the assistant chat, no copy. + // Direct-mode sessions render their own pane instead (see right-pane JSX). + const handleCodeSessionSelected = useCallback((active: ActiveCodeSession | null) => { + setActiveCodeSession(active) + if (active) { + const { id, cwd, agent } = active.session + setCodeSessionLocks((prev) => ( + prev[id]?.cwd === cwd && prev[id]?.agent === agent + ? prev + : { ...prev, [id]: { cwd, agent } } + )) + } + const rowboatSessionId = active && active.session.mode === 'rowboat' ? active.session.id : null + if (!rowboatSessionId) { + boundCodeSessionRef.current = null + return + } + if (boundCodeSessionRef.current === rowboatSessionId) return + boundCodeSessionRef.current = rowboatSessionId + const existingTab = chatTabsRef.current.find((t) => t.runId === rowboatSessionId) + if (existingTab) { + switchChatTab(existingTab.id) + return + } + setChatTabs((prev) => prev.map((t) => ( + t.id === activeChatTabIdRef.current ? { ...t, runId: rowboatSessionId } : t + ))) + loadRun(rowboatSessionId) + }, [switchChatTab, loadRun]) + const closeChatTab = useCallback((tabId: string) => { if (chatTabs.length <= 1) return const idx = chatTabs.findIndex(t => t.id === tabId) @@ -3082,6 +3358,14 @@ function App() { setIsHomeOpen(true) return } + if (isCodeTabPath(tab.path)) { + // isCodeOpen itself is derived from the active tab — just clear the rest. + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) + return + } setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) @@ -3090,7 +3374,7 @@ function App() { const closeFileTab = useCallback((tabId: string) => { const closingTab = fileTabs.find(t => t.id === tabId) - if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isChatHistoryTabPath(closingTab.path) && !isHomeTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { + if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isChatHistoryTabPath(closingTab.path) && !isHomeTabPath(closingTab.path) && !isCodeTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { removeEditorCacheForPath(closingTab.path) initialContentByPathRef.current.delete(closingTab.path) untitledRenameReadyPathsRef.current.delete(closingTab.path) @@ -3480,13 +3764,15 @@ function App() { if (isLiveNotesOpen) return { type: 'live-notes' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined } - if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined } + if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined, mode: knowledgeViewMode } if (isChatHistoryOpen) return { type: 'chat-history' } if (isHomeOpen) return { type: 'home' } + if (isCodeOpen) return { type: 'code' } + if (isBgTasksOpen) return { type: 'bg-tasks' } if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } - }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, isChatHistoryOpen, isHomeOpen, workspaceInitialPath, runId]) + }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, knowledgeViewMode, isChatHistoryOpen, isHomeOpen, isCodeOpen, workspaceInitialPath, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -3631,6 +3917,17 @@ function App() { setActiveFileTabId(id) }, [fileTabs]) + const ensureCodeFileTab = useCallback(() => { + const existing = fileTabs.find((tab) => isCodeTabPath(tab.path)) + if (existing) { + setActiveFileTabId(existing.id) + return + } + const id = newFileTabId() + setFileTabs((prev) => [...prev, { id, path: CODE_TAB_PATH }]) + setActiveFileTabId(id) + }, [fileTabs]) + const openEmailView = useCallback((threadId?: string) => { setSelectedPath(null) setIsGraphOpen(false) @@ -3686,6 +3983,18 @@ function App() { ensureMeetingsFileTab() }, [ensureMeetingsFileTab]) + const openCodeView = useCallback(() => { + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) + setSelectedBackgroundTask(null) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + ensureCodeFileTab() + }, [ensureCodeFileTab]) + const applyViewState = useCallback(async (view: ViewState) => { switch (view.type) { case 'file': @@ -3791,6 +4100,12 @@ function App() { setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) setIsHomeOpen(false) + // Deep links (e.g. a new-email notification) carry the thread to open; + // bump the version so EmailView re-selects it even if email is already open. + if (view.threadId) { + setEmailInitialThreadId(view.threadId) + setEmailThreadIdVersion((v) => v + 1) + } ensureEmailFileTab() return case 'workspace': @@ -3826,6 +4141,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(true) + setKnowledgeViewMode(view.mode ?? (view.folderPath ? 'files' : 'graph')) setKnowledgeViewFolderPath(view.folderPath ?? null) setIsChatHistoryOpen(false) setIsHomeOpen(false) @@ -3866,6 +4182,29 @@ function App() { setIsHomeOpen(true) ensureHomeFileTab() return + case 'code': + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setSelectedBackgroundTask(null) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) + ensureCodeFileTab() + return + case 'bg-tasks': + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setSelectedBackgroundTask(null) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) + setIsBgTasksOpen(true) + ensureBgTasksFileTab() + return case 'chat': setSelectedPath(null) setIsGraphOpen(false) @@ -3894,7 +4233,7 @@ function App() { } return } - }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, handleNewChat, isRightPaneMaximized, loadRun]) + }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, ensureCodeFileTab, ensureBgTasksFileTab, handleNewChat, isRightPaneMaximized, loadRun]) const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState @@ -4041,10 +4380,9 @@ function App() { setBaseConfigByPath((prev) => ({ ...prev, [path]: config })) }, []) - const handleBaseSave = useCallback(async (name: string | null) => { - if (!selectedPath) return - const isDefault = selectedPath === BASES_DEFAULT_TAB_PATH - const config = baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG + const handleBaseSave = useCallback(async (path: string, name: string | null) => { + const isDefault = path === BASES_DEFAULT_TAB_PATH + const config = baseConfigByPath[path] ?? DEFAULT_BASE_CONFIG if (isDefault && name) { // Save as new base file @@ -4068,14 +4406,14 @@ function App() { // Save in place try { await window.ipc.invoke('workspace:writeFile', { - path: selectedPath, + path, data: JSON.stringify(config, null, 2), }) } catch (err) { console.error('Failed to save base:', err) } } - }, [selectedPath, baseConfigByPath, loadDirectory, navigateToView]) + }, [baseConfigByPath, loadDirectory, navigateToView]) // External search set by app-navigation tool (passed to BasesView) const [externalBaseSearch, setExternalBaseSearch] = useState(undefined) @@ -4229,7 +4567,7 @@ function App() { }, []) // Keyboard shortcut: Ctrl+L to toggle main chat view - const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen && !selectedBackgroundTask && !isBrowserOpen + const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen && !isCodeOpen && !selectedBackgroundTask && !isBrowserOpen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'l') { @@ -4390,6 +4728,25 @@ function App() { } return } + + // Ctrl+Tab — next tab, Ctrl+Shift+Tab — previous tab (browser-style). + // Bound to Ctrl specifically (Cmd+Tab is the OS app switcher on macOS). + if (e.ctrlKey && e.key === 'Tab') { + e.preventDefault() + const direction = e.shiftKey ? -1 : 1 + if (inFileView) { + const currentIdx = fileTabs.findIndex(t => t.id === targetFileTabId) + if (currentIdx === -1) return + const nextIdx = (currentIdx + direction + fileTabs.length) % fileTabs.length + switchFileTab(fileTabs[nextIdx].id) + } else { + const currentIdx = chatTabs.findIndex(t => t.id === activeChatTabId) + if (currentIdx === -1) return + const nextIdx = (currentIdx + direction + chatTabs.length) % chatTabs.length + switchChatTab(chatTabs[nextIdx].id) + } + return + } } document.addEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown) @@ -4528,6 +4885,10 @@ function App() { throw err } }, + addGoogleDoc: (parentPath: string = 'knowledge') => { + setGoogleDocPickerTargetFolder(parentPath) + setGoogleDocPickerOpen(true) + }, createFolder: async (parentPath: string = 'knowledge'): Promise => { try { let index = 1 @@ -4939,8 +5300,10 @@ function App() { }, }), [knowledgeFiles, recentWikiFiles, openWikiLink, ensureWikiFile]) + const isBrainGraphOpen = isKnowledgeViewOpen && knowledgeViewMode === 'graph' + useEffect(() => { - if (!isGraphOpen) return + if (!isGraphOpen && !isBrainGraphOpen) return let cancelled = false const buildGraph = async () => { @@ -5055,9 +5418,13 @@ function App() { return () => { cancelled = true } - }, [isGraphOpen, knowledgeFilePaths]) + }, [isGraphOpen, isBrainGraphOpen, knowledgeFilePaths]) - const renderConversationItem = (item: ConversationItem, tabId: string) => { + const renderConversationItem = ( + item: ConversationItem, + tabId: string, + options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } }, + ) => { if (isChatMessage(item)) { if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { @@ -5115,6 +5482,21 @@ function App() { } if (isToolCall(item)) { + if (item.name === 'code_agent_run') { + return ( + setToolOpenForTab(tabId, item.id, open)} + onPermissionDecision={(decision) => { + if (item.pendingCodePermission) { + handleCodePermissionResponse(item.id, item.pendingCodePermission.requestId, decision) + } + }} + /> + ) + } const appActionData = getAppActionCardData(item) if (appActionData) { return @@ -5155,6 +5537,7 @@ function App() { key={item.id} open={isToolOpenForTab(tabId, item.id)} onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)} + autoPermissionDetail={options?.autoPermissionDetail} > (() => createEmptyChatTabViewState(), []) const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => { @@ -5213,9 +5598,20 @@ function App() { const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null - const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen) + const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode + const nonChatPaneStyle = React.useMemo(() => { + const style: React.CSSProperties = { maxWidth: insetMaxWidth } + if (!isRightPaneContext || !isChatSidebarOpen || isRightPaneMaximized) return style + if (chatPaneSize === 'chat-equal') { + return { ...style, width: 0, flex: '1 1 0' } + } + if (chatPaneSize === 'chat-bigger') { + return { ...style, width: DEFAULT_CHAT_PANE_WIDTH, flex: '0 0 auto' } + } + return style + }, [chatPaneSize, insetMaxWidth, isChatSidebarOpen, isRightPaneContext, isRightPaneMaximized]) // Collapsing: pin max-width to the snapshot px (no transition) for one frame so it's // binding immediately (no flex jump), then animate to 0. Expanding goes back to 100% // — its non-binding range lands at the end of the range, where it isn't visible. @@ -5247,7 +5643,6 @@ function App() { } return markdownTabs }, [fileTabs, selectedPath]) - return ( { @@ -5271,16 +5666,19 @@ function App() { isHomeOpen ? 'home' : isEmailOpen ? 'email' : isMeetingsOpen ? 'meetings' + : isCodeOpen ? 'code' : (isKnowledgeViewOpen || isGraphOpen || (selectedPath != null && selectedPath.startsWith('knowledge/'))) ? 'knowledge' : isBgTasksOpen ? 'agents' : isWorkspaceOpen ? 'workspaces' : null } onOpenMeetings={openMeetingsView} + onOpenCode={openCodeView} onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }} onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }} recentRuns={runs} onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })} + onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })} onOpenEmail={(threadId) => openEmailView(threadId)} onOpenHome={() => void navigateToView({ type: 'home' })} onNewChat={handleNewChatTab} @@ -5293,10 +5691,11 @@ function App() { setActiveShortcutPane('left')} onFocusCapture={() => setActiveShortcutPane('left')} @@ -5309,7 +5708,7 @@ function App() { canNavigateForward={canNavigateForward} collapsedLeftPaddingPx={collapsedLeftPaddingPx} > - {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && fileTabs.length >= 1 ? ( + {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen) && fileTabs.length >= 1 ? ( t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} /> ) : isFullScreenChat ? ( Version history )} - {!isFullScreenChat && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedTask && !isBrowserOpen && ( + {!isFullScreenChat && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isCodeOpen && !selectedTask && !isBrowserOpen && ( - {otherAgent && otherDisplay && onSwitchAgent && ( - - )} )} diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx index 61ba6fbd..9635b244 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx @@ -5,12 +5,18 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { ToolUIPart } from "ai"; import { ChevronDownIcon, CircleCheck, LoaderIcon, + ShieldCheckIcon, XCircleIcon, } from "lucide-react"; import { type ComponentProps, type ReactNode, isValidElement, useState } from "react"; @@ -45,17 +51,51 @@ const ToolCode = ({ ); -export type ToolProps = ComponentProps; +export type ToolAutoPermissionDetail = { + decision: "allow"; + reason: string; +}; -export const Tool = ({ className, ...props }: ToolProps) => ( - -); +export type ToolProps = ComponentProps & { + autoPermissionDetail?: ToolAutoPermissionDetail; +}; + +export const Tool = ({ className, children, autoPermissionDetail, ...props }: ToolProps) => { + const toolCard = ( + + {children} + + ); + + if (!autoPermissionDetail) return toolCard; + + return ( +
+ {toolCard} +
+ + + + + Auto-approved + + + + {autoPermissionDetail.reason} + + +
+
+ ); +}; export type ToolHeaderProps = { title?: string; diff --git a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx index b4574b58..c48df79e 100644 --- a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx +++ b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx @@ -4,6 +4,7 @@ import { ListChecks, Play, Square, Loader2, Trash2, Plus, X, AlertCircle, Repeat, Clock, Zap, ChevronLeft, ChevronDown, ChevronRight, Pencil, Check, PanelRightClose, PanelRightOpen, Sparkles, + Code2, FolderOpen, LayoutTemplate, } from 'lucide-react' import type { z } from 'zod' import type { BackgroundTask, BackgroundTaskSummary, Triggers } from '@x/shared/dist/background-task.js' @@ -19,6 +20,7 @@ import type { ConversationItem } from '@/lib/chat-conversation' import { runLogToConversation } from '@/lib/run-to-conversation' import { CompactConversation } from '@/components/compact-conversation' import { RichMarkdownViewer } from '@/components/rich-markdown-viewer' +import { HtmlFileViewer } from '@/components/html-file-viewer' // --------------------------------------------------------------------------- // Trigger helpers (inlined; extract to shared as a follow-up) @@ -270,7 +272,16 @@ function TriggersEditor({ // New Task dialog // --------------------------------------------------------------------------- -type DialogMode = 'describe' | 'manual' +type DialogMode = 'describe' | 'manual' | 'templates' | 'coding' + +// Prefills for the "Coding from meetings" preset. +const CODING_PRESET = { + name: 'Implement coding items from meetings', + instructions: `After a meeting's notes are ready, scan them for coding action items (bugs to fix, features to build, concrete changes requested) for me or my team. + +Conservatively implement the clearly-scoped, self-contained ones in the configured repo using the launch-code-task tool — group related items into one session, split unrelated ones. Note ambiguous, large/architectural, or other-repo items as "needs review" instead of coding them. If nothing is actionable, do nothing.`, + eventMatchCriteria: `A meeting's notes or transcript just became available (engineering standup, planning, sprint, or technical discussion) that may contain coding action items, bugs to fix, or features to build.`, +} function NewTaskDialog({ open, @@ -294,6 +305,9 @@ function NewTaskDialog({ const [name, setName] = useState('') const [instructions, setInstructions] = useState('') const [triggers, setTriggers] = useState(undefined) + const [projectId, setProjectId] = useState(undefined) + const [projectName, setProjectName] = useState(undefined) + const [addingProject, setAddingProject] = useState(false) const [submitting, setSubmitting] = useState(false) useEffect(() => { @@ -303,11 +317,64 @@ function NewTaskDialog({ setName('') setInstructions('') setTriggers(undefined) + setProjectId(undefined) + setProjectName(undefined) } }, [open, copilotEnabled]) + // Switch into the coding preset: prefill name/instructions/trigger once. + const enterCodingMode = () => { + setMode('coding') + setName(CODING_PRESET.name) + setInstructions(CODING_PRESET.instructions) + setTriggers({ eventMatchCriteria: CODING_PRESET.eventMatchCriteria }) + } + + const pickRepo = async () => { + setAddingProject(true) + try { + const res = await window.ipc.invoke('dialog:openDirectory', { title: 'Choose the repository for this task' }) + const dir = res.path + if (!dir) return + const added = await window.ipc.invoke('codeProject:add', { path: dir }) + if (!added.git?.isGitRepo) { + toast('That folder is not a git repository — coding tasks need one.', 'error') + return + } + setProjectId(added.project.id) + setProjectName(added.project.name) + } catch (err) { + toast(err instanceof Error ? err.message : String(err), 'error') + } finally { + setAddingProject(false) + } + } + const canSubmitDescribe = description.trim().length > 0 && !submitting const canSubmitManual = name.trim().length > 0 && instructions.trim().length > 0 && !submitting + const canSubmitCoding = name.trim().length > 0 && instructions.trim().length > 0 && !!projectId && !submitting + + const submitCoding = async () => { + if (!canSubmitCoding) return + setSubmitting(true) + try { + const result = await window.ipc.invoke('bg-task:create', { + name: name.trim(), + instructions: instructions.trim(), + ...(triggers ? { triggers } : {}), + ...(projectId ? { projectId } : {}), + }) + if (result.success && result.slug) { + onCreated(result.slug) + } else { + toast(result.error ?? 'Failed to create task', 'error') + } + } catch (err) { + toast(err instanceof Error ? err.message : String(err), 'error') + } finally { + setSubmitting(false) + } + } const submitDescribe = () => { if (!canSubmitDescribe || !onCreateWithCopilot) return @@ -358,7 +425,116 @@ function NewTaskDialog({ - {mode === 'describe' ? ( + {(mode === 'describe' || mode === 'manual') && ( + + )} + + {mode === 'templates' ? ( + <> +
+ {[ + { + id: 'coding-from-meetings', + title: 'Coding from meetings', + description: "When a meeting's notes are ready, scan them for coding action items and auto-implement them in a repo — each on its own isolated branch, with a summary.", + icon: Code2, + onSelect: enterCodingMode, + }, + ].map(preset => ( + + ))} +
+ +
+ + +
+ + ) : mode === 'coding' ? ( + <> +
+
+ + {projectName ? ( +
+ + + {projectName} + + +
+ ) : ( + + )} +

+ Code changes run full-auto in an isolated git worktree — your working checkout is never touched. +

+
+
+ + setName(e.target.value)} /> +
+
+ +