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 2adf3938..fa62d0db 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -11,6 +11,9 @@ import * as esbuild from 'esbuild'; import { readFile } from 'node:fs/promises'; +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 +27,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,6 +49,25 @@ await esbuild.build({ }, }); +// 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); +} +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 diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index b6f15b66..5e33bb49 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -5,6 +5,92 @@ const path = require('path'); const pkg = require('./package.json'); +// 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 a nested 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 +// nested node_modules — dereferencing symlinks and nesting on version conflict — which +// resolves correctly regardless of pnpm layout. +// +// 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. +function stageAcpAdapters(mainDir, destNodeModules) { + const fs = require('fs'); + const ADAPTERS = [ + '@agentclientprotocol/claude-agent-acp', + '@agentclientprotocol/codex-acp', + ]; + + // The native engines, shipped as platform packages. Provisioned on demand instead + // (see comment above), so they're excluded from staging. + const isNativeEngine = (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 realDirOf = (key, fromDir) => { + 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; + } + }; + + let copied = 0; + const skippedEngines = new Set(); + const install = (srcDir, key, destNM, chain) => { + const destDir = path.join(destNM, ...key.split('/')); + if (fs.existsSync(destDir)) return; // already placed at this exact location + if (chain.has(srcDir)) return; // dependency cycle — resolves to ancestor copy + 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 pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8')); + const deps = { ...pj.dependencies, ...pj.optionalDependencies }; + const nextChain = new Set(chain).add(srcDir); + for (const depKey of Object.keys(deps)) { + if (isNativeEngine(depKey)) { skippedEngines.add(depKey); continue; } + const depDir = realDirOf(depKey, srcDir); + if (depDir) install(depDir, depKey, path.join(destDir, 'node_modules'), nextChain); + } + }; + + for (const key of ADAPTERS) { + const srcDir = realDirOf(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; +} + module.exports = { packagerConfig: { executableName: 'rowboat', @@ -29,17 +115,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: [ { @@ -193,6 +285,15 @@ 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 staged = stageAcpAdapters(__dirname, acpDest); + console.log(`✅ Staged ${staged} ACP adapter packages into .package/acp/node_modules`); + console.log('✅ All assets staged in .package/'); }, } diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index cbee1d3e..a7eca38e 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -22,6 +22,7 @@ "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", diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 4914037a..88c0fdc1 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -35,6 +35,15 @@ 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 * 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'; @@ -564,6 +573,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) { @@ -746,7 +781,7 @@ 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); @@ -869,6 +904,124 @@ export function setupIpcHandlers() { '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) }; + }, + '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: [] }; + } + const files = await codeGit.status(session.cwd); + 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); + }, + '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 }); @@ -1183,6 +1336,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); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 1dc72ec0..0c7bc9b9 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"; @@ -345,6 +347,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(); @@ -437,6 +442,8 @@ app.on("before-quit", () => { } 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/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.tsx b/apps/x/apps/renderer/src/App.tsx index b850b57f..2f4337a9 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -34,6 +34,9 @@ import { KnowledgeView } from '@/components/knowledge-view'; import { ChatHistoryView } from '@/components/chat-history-view'; import { HomeView } from '@/components/home-view'; import { MeetingsView } from '@/components/meetings-view'; +import { 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, @@ -199,6 +202,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)) @@ -336,6 +340,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() @@ -589,6 +594,7 @@ type ViewState = | { type: 'knowledge-view'; folderPath?: string } | { type: 'chat-history' } | { type: 'home' } + | { type: 'code' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false @@ -652,6 +658,8 @@ function parseDeepLink(input: string): ViewState | null { return { type: 'chat-history' } case 'home': return { type: 'home' } + case 'code': + return { type: 'code' } default: return null } @@ -1034,7 +1042,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 @@ -1159,6 +1167,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) @@ -1175,6 +1200,7 @@ function App() { if (isKnowledgeViewTabPath(tab.path)) return 'Notes' 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 @@ -1807,8 +1833,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) @@ -2075,6 +2101,15 @@ 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) @@ -2145,6 +2180,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) @@ -2676,10 +2716,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] }) @@ -2878,6 +2920,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) @@ -3147,6 +3221,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) @@ -3155,7 +3237,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) @@ -3548,10 +3630,11 @@ function App() { if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined } if (isChatHistoryOpen) return { type: 'chat-history' } if (isHomeOpen) return { type: 'home' } + if (isCodeOpen) return { type: 'code' } 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, isChatHistoryOpen, isHomeOpen, isCodeOpen, workspaceInitialPath, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -3696,6 +3779,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) @@ -3751,6 +3845,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': @@ -3931,6 +4037,17 @@ 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 'chat': setSelectedPath(null) setIsGraphOpen(false) @@ -3959,7 +4076,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, handleNewChat, isRightPaneMaximized, loadRun]) const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState @@ -4294,7 +4411,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') { @@ -5300,7 +5417,7 @@ 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(() => { @@ -5369,16 +5486,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} @@ -5408,7 +5528,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 && ( diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 0254cdfd..00bcaa8e 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -18,6 +18,7 @@ import { Headphones, ImagePlus, LoaderIcon, + Lock, Mic, MoreHorizontal, Plus, @@ -71,6 +72,7 @@ export type StagedAttachment = { const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB const MAX_VISIBLE_RECENT_WORK_DIRS = 3 const MAX_STORED_RECENT_WORK_DIRS = 8 +const CHAT_INPUT_TOOLTIP_DELAY_MS = 1000 // Stored in the workspace (~/.rowboat/config) so it travels with the workspace and // stays consistent with the other config/*.json files (e.g. coding-agents.json). const RECENT_WORK_DIRS_CONFIG_PATH = 'config/recent-work-dirs.json' @@ -237,6 +239,12 @@ interface ChatInputInnerProps { workDir?: string | null /** Fired when the user sets/changes/clears the work directory for this chat. */ onWorkDirChange?: (value: string | null) => void + /** + * Set when this chat is bound to a Code-section session: the work directory + * and coding agent come from the session and are FROZEN — the backend pins + * them server-side regardless, so the composer must not pretend otherwise. + */ + codeSessionLock?: { cwd: string; agent: 'claude' | 'codex' } | null } function ChatInputInner({ @@ -265,6 +273,7 @@ function ChatInputInner({ onSelectedModelChange, workDir = null, onWorkDirChange, + codeSessionLock = null, }: ChatInputInnerProps) { const controller = usePromptInputController() const message = controller.textInput.value @@ -491,22 +500,33 @@ function ChatInputInner({ }) }, []) + // A chat bound to a Code-section session has its work directory and coding + // agent frozen to the session's — the backend pins them server-side, so the + // composer reflects that instead of offering controls that wouldn't apply. + const isCodeLocked = Boolean(codeSessionLock) + const effectiveWorkDir = codeSessionLock?.cwd ?? workDir + // Work directory is owned per-chat by the parent (App). This component only // drives the picker dialog and reports changes up via onWorkDirChange. Whenever // the work directory changes, load its persisted coding-agent preference. useEffect(() => { + if (codeSessionLock) { + setCodingAgent(codeSessionLock.agent) + return + } let cancelled = false loadCodingAgentFor(workDir).then((agent) => { if (!cancelled) setCodingAgent(agent) }) return () => { cancelled = true } - }, [workDir, loadCodingAgentFor]) + }, [workDir, loadCodingAgentFor, codeSessionLock]) useEffect(() => { - if (isActive && workDir) void rememberWorkDir(workDir) - }, [isActive, workDir, rememberWorkDir]) + if (isActive && workDir && !isCodeLocked) void rememberWorkDir(workDir) + }, [isActive, workDir, rememberWorkDir, isCodeLocked]) const handleSetWorkDir = useCallback(async () => { + if (isCodeLocked) return try { let defaultPath: string | undefined = workDir ?? undefined try { @@ -533,7 +553,7 @@ function ChatInputInner({ console.error('Failed to set work directory', err) toast.error('Failed to set work directory') } - }, [workDir, onWorkDirChange, rememberWorkDir, loadCodingAgentFor]) + }, [workDir, onWorkDirChange, rememberWorkDir, loadCodingAgentFor, isCodeLocked]) const handleSelectRecentWorkDir = useCallback(async (dir: string) => { onWorkDirChange?.(dir) @@ -543,12 +563,14 @@ function ChatInputInner({ }, [onWorkDirChange, rememberWorkDir, loadCodingAgentFor]) const handleClearWorkDir = useCallback(() => { + if (isCodeLocked) return onWorkDirChange?.(null) setCodingAgent('claude') toast.success('Work directory cleared') - }, [onWorkDirChange]) + }, [onWorkDirChange, isCodeLocked]) const handleToggleCodingAgent = useCallback(async () => { + if (isCodeLocked) return const next: 'claude' | 'codex' = codingAgent === 'claude' ? 'codex' : 'claude' setCodingAgent(next) // Persist only when scoped to a workdir; without one there's nothing to key on. @@ -561,7 +583,7 @@ function ChatInputInner({ // revert on failure setCodingAgent(codingAgent) } - }, [workDir, codingAgent, persistCodingAgent]) + }, [workDir, codingAgent, persistCodingAgent, isCodeLocked]) // Check search tool availability (exa or signed-in via gateway) useEffect(() => { @@ -647,15 +669,16 @@ function ChatInputInner({ const handleSubmit = useCallback(() => { if (!canSubmit) return - // codeMode is sticky per conversation — don't reset after send. - const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined + // codeMode is sticky per conversation — don't reset after send. A code + // session forces it (the backend pins the agent anyway). + const effectiveCodeMode = codeSessionLock ? codeSessionLock.agent : (codeModeEnabled ? codingAgent : undefined) onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode, permissionMode) controller.textInput.clear() controller.mentions.clearMentions() setAttachments([]) // Web search toggle stays on for the rest of the chat session; the user // turns it off explicitly. (Not persisted across app restarts.) - }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir]) + }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir, codeSessionLock]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -697,8 +720,8 @@ function ChatInputInner({ const visibleRecentWorkDirs = recentWorkDirs .filter((entry) => entry.path !== workDir) .slice(0, MAX_VISIBLE_RECENT_WORK_DIRS) - const currentWorkDirLabel = workDir ? basename(workDir) || workDir : 'Not set' - const currentWorkDirPath = workDir ? compactWorkDirPath(workDir) : '' + const currentWorkDirLabel = effectiveWorkDir ? basename(effectiveWorkDir) || effectiveWorkDir : 'Not set' + const currentWorkDirPath = effectiveWorkDir ? compactWorkDirPath(effectiveWorkDir) : '' return (
@@ -807,7 +830,7 @@ function ChatInputInner({
- +
- {workDir && collapseLevel < 8 && ( - + {effectiveWorkDir && collapseLevel < 8 && ( + {/* Level 4: collapse to a square icon */}
= 4 ? "w-7 justify-center" : "max-w-[180px] pl-2.5 pr-2" )}> - {collapseLevel < 4 && ( + {collapseLevel < 4 && !isCodeLocked && ( )} {collapseLevel < 6 && ( - + - Code mode on ({codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable + + {isCodeLocked + ? `Coding session — ${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}` + : `Code mode on (${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable`} + ) : (
- + - Code mode on — click to disable + + {isCodeLocked ? 'Pinned by the coding session' : 'Code mode on — click to disable'} + · - + - Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap + {isCodeLocked + ? `Coding agent fixed by the session: ${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}` + : `Coding agent: ${codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap`}
) ) : ( - + + + + + + + {isStopping ? 'Click again to force stop' : 'Stop generation'} + + ) : ( + + ))} +
+ )} +
+